@shaykec/bridge 0.1.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,329 @@
1
+ /**
2
+ * Tests for ClaudeTeach bridge EventRouter.
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
6
+ import { EventRouter } from './router.js';
7
+ import {
8
+ createEnvelope,
9
+ MSG_SYS_HEARTBEAT,
10
+ MSG_SYS_CONNECT,
11
+ MSG_SYS_DISCONNECT,
12
+ MSG_EVENT_QUIZ_ANSWER,
13
+ MSG_EVENT_CLICK,
14
+ MSG_CAPTURE_TEXT,
15
+ MSG_CONTEXT_TOPIC,
16
+ MSG_CANVAS_DIAGRAM,
17
+ } from '@shaykec/shared';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function createMockTierManager() {
24
+ return {
25
+ broadcastWs: vi.fn(),
26
+ broadcastSse: vi.fn(),
27
+ };
28
+ }
29
+
30
+ /** Build a valid envelope string */
31
+ function makeEnvelope(type, payload = {}, source = 'canvas') {
32
+ return JSON.stringify(createEnvelope(type, payload, source));
33
+ }
34
+
35
+ /** Build a valid envelope object */
36
+ function makeEnvelopeObj(type, payload = {}, source = 'canvas') {
37
+ return createEnvelope(type, payload, source);
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // routeVisualCommand
42
+ // ---------------------------------------------------------------------------
43
+
44
+ describe('EventRouter — routeVisualCommand', () => {
45
+ let tm;
46
+ let router;
47
+
48
+ beforeEach(() => {
49
+ tm = createMockTierManager();
50
+ router = new EventRouter(tm);
51
+ });
52
+
53
+ it('broadcasts to WS and SSE clients', () => {
54
+ const envelope = makeEnvelopeObj(MSG_CANVAS_DIAGRAM, { format: 'mermaid' }, 'plugin');
55
+ router.routeVisualCommand(envelope);
56
+
57
+ expect(tm.broadcastWs).toHaveBeenCalledTimes(1);
58
+ expect(tm.broadcastSse).toHaveBeenCalledTimes(1);
59
+
60
+ const sentData = tm.broadcastWs.mock.calls[0][0];
61
+ expect(JSON.parse(sentData)).toMatchObject({ type: MSG_CANVAS_DIAGRAM });
62
+ });
63
+
64
+ it('serialises the envelope to JSON for both channels', () => {
65
+ const envelope = makeEnvelopeObj(MSG_CANVAS_DIAGRAM, { x: 1 });
66
+ router.routeVisualCommand(envelope);
67
+
68
+ const wsData = tm.broadcastWs.mock.calls[0][0];
69
+ const sseData = tm.broadcastSse.mock.calls[0][0];
70
+ expect(wsData).toBe(sseData); // same serialised string
71
+ expect(typeof wsData).toBe('string');
72
+ });
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // handleWsMessage
77
+ // ---------------------------------------------------------------------------
78
+
79
+ describe('EventRouter — handleWsMessage', () => {
80
+ let tm;
81
+ let router;
82
+
83
+ beforeEach(() => {
84
+ tm = createMockTierManager();
85
+ router = new EventRouter(tm);
86
+ });
87
+
88
+ // --- User events get enqueued ---
89
+
90
+ it('enqueues a valid user event (event:*)', () => {
91
+ const raw = makeEnvelope(MSG_EVENT_QUIZ_ANSWER, { answer: 42 });
92
+ const result = router.handleWsMessage(raw, 'c1');
93
+
94
+ expect(result).toEqual({ handled: true, error: null });
95
+ expect(router.getQueueSize()).toBe(1);
96
+ expect(router.peekEvents()[0].type).toBe(MSG_EVENT_QUIZ_ANSWER);
97
+ });
98
+
99
+ it('enqueues capture events (capture:*)', () => {
100
+ const raw = makeEnvelope(MSG_CAPTURE_TEXT, { text: 'hello' });
101
+ const result = router.handleWsMessage(raw, 'c1');
102
+
103
+ expect(result).toEqual({ handled: true, error: null });
104
+ expect(router.getQueueSize()).toBe(1);
105
+ });
106
+
107
+ it('enqueues context events (context:*)', () => {
108
+ const raw = makeEnvelope(MSG_CONTEXT_TOPIC, { topic: 'math' });
109
+ const result = router.handleWsMessage(raw, 'c1');
110
+
111
+ expect(result).toEqual({ handled: true, error: null });
112
+ expect(router.getQueueSize()).toBe(1);
113
+ });
114
+
115
+ // --- System messages are not enqueued ---
116
+
117
+ it('does not enqueue heartbeat messages', () => {
118
+ const raw = makeEnvelope(MSG_SYS_HEARTBEAT);
119
+ const result = router.handleWsMessage(raw, 'c1');
120
+
121
+ expect(result).toEqual({ handled: true, error: null });
122
+ expect(router.getQueueSize()).toBe(0);
123
+ });
124
+
125
+ it('does not enqueue sys:connect messages', () => {
126
+ const raw = makeEnvelope(MSG_SYS_CONNECT, { clientType: 'canvas', protocolVersion: 1 });
127
+ const result = router.handleWsMessage(raw, 'c1');
128
+
129
+ expect(result).toEqual({ handled: true, error: null });
130
+ expect(router.getQueueSize()).toBe(0);
131
+ });
132
+
133
+ it('does not enqueue sys:disconnect messages', () => {
134
+ const raw = makeEnvelope(MSG_SYS_DISCONNECT);
135
+ const result = router.handleWsMessage(raw, 'c1');
136
+
137
+ expect(result).toEqual({ handled: true, error: null });
138
+ expect(router.getQueueSize()).toBe(0);
139
+ });
140
+
141
+ // --- Invalid JSON ---
142
+
143
+ it('rejects invalid JSON', () => {
144
+ const result = router.handleWsMessage('not json at all', 'c1');
145
+
146
+ expect(result.handled).toBe(false);
147
+ expect(result.error).toBeDefined();
148
+ expect(router.getQueueSize()).toBe(0);
149
+ });
150
+
151
+ it('rejects an envelope missing required fields', () => {
152
+ const result = router.handleWsMessage(JSON.stringify({ foo: 'bar' }), 'c1');
153
+
154
+ expect(result.handled).toBe(false);
155
+ expect(result.error).toBeDefined();
156
+ });
157
+
158
+ // --- Canvas commands fan out ---
159
+
160
+ it('broadcasts canvas commands to connected clients', () => {
161
+ const raw = makeEnvelope(MSG_CANVAS_DIAGRAM, { format: 'mermaid' }, 'extension');
162
+ const result = router.handleWsMessage(raw, 'e1');
163
+
164
+ expect(result).toEqual({ handled: true, error: null });
165
+ expect(tm.broadcastWs).toHaveBeenCalledTimes(1);
166
+ expect(tm.broadcastSse).toHaveBeenCalledTimes(1);
167
+ // Canvas commands are not enqueued
168
+ expect(router.getQueueSize()).toBe(0);
169
+ });
170
+
171
+ // --- Unknown types ---
172
+
173
+ it('handles unknown types silently (accepted, not routed)', () => {
174
+ const raw = JSON.stringify({ v: 1, type: 'custom:unknown', payload: {}, source: 'x' });
175
+ const result = router.handleWsMessage(raw, 'c1');
176
+
177
+ expect(result).toEqual({ handled: true, error: null });
178
+ expect(router.getQueueSize()).toBe(0);
179
+ expect(tm.broadcastWs).not.toHaveBeenCalled();
180
+ });
181
+ });
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // handleRestEvent
185
+ // ---------------------------------------------------------------------------
186
+
187
+ describe('EventRouter — handleRestEvent', () => {
188
+ let tm;
189
+ let router;
190
+
191
+ beforeEach(() => {
192
+ tm = createMockTierManager();
193
+ router = new EventRouter(tm);
194
+ });
195
+
196
+ it('accepts and enqueues a valid envelope object', () => {
197
+ const body = makeEnvelopeObj(MSG_EVENT_CLICK, { x: 10, y: 20 });
198
+ const result = router.handleRestEvent(body);
199
+
200
+ expect(result).toEqual({ ok: true, error: null });
201
+ expect(router.getQueueSize()).toBe(1);
202
+ });
203
+
204
+ it('rejects invalid body (missing required fields)', () => {
205
+ const result = router.handleRestEvent({ random: 'stuff' });
206
+
207
+ expect(result.ok).toBe(false);
208
+ expect(result.error).toBeDefined();
209
+ expect(router.getQueueSize()).toBe(0);
210
+ });
211
+
212
+ it('rejects null body', () => {
213
+ const result = router.handleRestEvent(null);
214
+
215
+ expect(result.ok).toBe(false);
216
+ expect(result.error).toBeDefined();
217
+ });
218
+
219
+ it('accepts a JSON string body', () => {
220
+ const body = makeEnvelope(MSG_EVENT_CLICK, { x: 5 });
221
+ const result = router.handleRestEvent(body);
222
+
223
+ expect(result).toEqual({ ok: true, error: null });
224
+ expect(router.getQueueSize()).toBe(1);
225
+ });
226
+ });
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // pollEvents / peekEvents / getQueueSize
230
+ // ---------------------------------------------------------------------------
231
+
232
+ describe('EventRouter — queue operations', () => {
233
+ let tm;
234
+ let router;
235
+
236
+ beforeEach(() => {
237
+ tm = createMockTierManager();
238
+ router = new EventRouter(tm);
239
+ });
240
+
241
+ it('pollEvents returns events and clears the queue', () => {
242
+ router.handleRestEvent(makeEnvelopeObj(MSG_EVENT_CLICK, { a: 1 }));
243
+ router.handleRestEvent(makeEnvelopeObj(MSG_EVENT_CLICK, { a: 2 }));
244
+
245
+ expect(router.getQueueSize()).toBe(2);
246
+
247
+ const events = router.pollEvents();
248
+ expect(events).toHaveLength(2);
249
+ expect(events[0].type).toBe(MSG_EVENT_CLICK);
250
+ expect(router.getQueueSize()).toBe(0);
251
+ });
252
+
253
+ it('peekEvents returns events without clearing the queue', () => {
254
+ router.handleRestEvent(makeEnvelopeObj(MSG_EVENT_CLICK, { a: 1 }));
255
+
256
+ const peeked = router.peekEvents();
257
+ expect(peeked).toHaveLength(1);
258
+ expect(router.getQueueSize()).toBe(1); // still there
259
+ });
260
+
261
+ it('peekEvents returns a copy (modifying it does not affect internal queue)', () => {
262
+ router.handleRestEvent(makeEnvelopeObj(MSG_EVENT_CLICK, { a: 1 }));
263
+
264
+ const peeked = router.peekEvents();
265
+ peeked.push({ fake: true });
266
+
267
+ expect(router.getQueueSize()).toBe(1); // unaffected
268
+ });
269
+
270
+ it('pollEvents returns a copy (modifying it does not affect future polls)', () => {
271
+ router.handleRestEvent(makeEnvelopeObj(MSG_EVENT_CLICK, { a: 1 }));
272
+
273
+ const polled = router.pollEvents();
274
+ polled.push({ fake: true });
275
+
276
+ // Next poll should be empty (not affected by the push above)
277
+ expect(router.pollEvents()).toHaveLength(0);
278
+ });
279
+
280
+ it('getQueueSize returns 0 when empty', () => {
281
+ expect(router.getQueueSize()).toBe(0);
282
+ });
283
+ });
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Queue overflow
287
+ // ---------------------------------------------------------------------------
288
+
289
+ describe('EventRouter — queue overflow', () => {
290
+ let tm;
291
+ let router;
292
+
293
+ beforeEach(() => {
294
+ tm = createMockTierManager();
295
+ router = new EventRouter(tm);
296
+ });
297
+
298
+ it('drops oldest 10% when queue reaches max capacity (1000)', () => {
299
+ // Fill to exactly maxQueueSize
300
+ for (let i = 0; i < 1000; i++) {
301
+ router.handleRestEvent(makeEnvelopeObj(MSG_EVENT_CLICK, { i }));
302
+ }
303
+ expect(router.getQueueSize()).toBe(1000);
304
+
305
+ // Adding one more triggers the trim
306
+ router.handleRestEvent(makeEnvelopeObj(MSG_EVENT_CLICK, { i: 1000 }));
307
+
308
+ // 1000 - 100 (drop 10%) + 1 (newly added) = 901
309
+ expect(router.getQueueSize()).toBe(901);
310
+
311
+ // The oldest events (indices 0..99) should be gone
312
+ const events = router.peekEvents();
313
+ // The first event in the queue should have i === 100 (after dropping 0-99)
314
+ expect(events[0].payload.i).toBe(100);
315
+ // The last event should be the newly added one
316
+ expect(events[events.length - 1].payload.i).toBe(1000);
317
+ });
318
+
319
+ it('handles multiple overflows correctly', () => {
320
+ // Fill past capacity twice
321
+ for (let i = 0; i < 1100; i++) {
322
+ router.handleRestEvent(makeEnvelopeObj(MSG_EVENT_CLICK, { i }));
323
+ }
324
+
325
+ // Should have trimmed and still be under control
326
+ expect(router.getQueueSize()).toBeLessThanOrEqual(1000);
327
+ expect(router.getQueueSize()).toBeGreaterThan(0);
328
+ });
329
+ });