zero-query 1.0.9 → 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.
Files changed (154) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -0
  3. package/cli/args.js +33 -33
  4. package/cli/commands/build-api.js +443 -0
  5. package/cli/commands/build.js +254 -216
  6. package/cli/commands/bundle.js +1228 -1183
  7. package/cli/commands/create.js +137 -121
  8. package/cli/commands/dev/devtools/index.js +56 -56
  9. package/cli/commands/dev/devtools/js/components.js +49 -49
  10. package/cli/commands/dev/devtools/js/core.js +423 -423
  11. package/cli/commands/dev/devtools/js/elements.js +421 -421
  12. package/cli/commands/dev/devtools/js/network.js +166 -166
  13. package/cli/commands/dev/devtools/js/performance.js +73 -73
  14. package/cli/commands/dev/devtools/js/router.js +105 -105
  15. package/cli/commands/dev/devtools/js/source.js +132 -132
  16. package/cli/commands/dev/devtools/js/stats.js +35 -35
  17. package/cli/commands/dev/devtools/js/tabs.js +79 -79
  18. package/cli/commands/dev/devtools/panel.html +95 -95
  19. package/cli/commands/dev/devtools/styles.css +244 -244
  20. package/cli/commands/dev/index.js +107 -107
  21. package/cli/commands/dev/logger.js +75 -75
  22. package/cli/commands/dev/overlay.js +858 -858
  23. package/cli/commands/dev/server.js +220 -167
  24. package/cli/commands/dev/validator.js +94 -94
  25. package/cli/commands/dev/watcher.js +172 -172
  26. package/cli/help.js +114 -112
  27. package/cli/index.js +52 -52
  28. package/cli/scaffold/default/LICENSE +21 -21
  29. package/cli/scaffold/default/app/app.js +207 -207
  30. package/cli/scaffold/default/app/components/about.js +201 -201
  31. package/cli/scaffold/default/app/components/api-demo.js +143 -143
  32. package/cli/scaffold/default/app/components/contact-card.js +231 -231
  33. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
  34. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
  35. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
  36. package/cli/scaffold/default/app/components/counter.js +127 -127
  37. package/cli/scaffold/default/app/components/home.js +249 -249
  38. package/cli/scaffold/default/app/components/not-found.js +16 -16
  39. package/cli/scaffold/default/app/components/playground/playground.css +115 -115
  40. package/cli/scaffold/default/app/components/playground/playground.html +161 -161
  41. package/cli/scaffold/default/app/components/playground/playground.js +116 -116
  42. package/cli/scaffold/default/app/components/todos.js +225 -225
  43. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
  44. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
  45. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
  46. package/cli/scaffold/default/app/routes.js +15 -15
  47. package/cli/scaffold/default/app/store.js +101 -101
  48. package/cli/scaffold/default/global.css +552 -552
  49. package/cli/scaffold/default/index.html +99 -99
  50. package/cli/scaffold/minimal/app/app.js +85 -85
  51. package/cli/scaffold/minimal/app/components/about.js +68 -68
  52. package/cli/scaffold/minimal/app/components/counter.js +122 -122
  53. package/cli/scaffold/minimal/app/components/home.js +68 -68
  54. package/cli/scaffold/minimal/app/components/not-found.js +16 -16
  55. package/cli/scaffold/minimal/app/routes.js +9 -9
  56. package/cli/scaffold/minimal/app/store.js +36 -36
  57. package/cli/scaffold/minimal/global.css +300 -300
  58. package/cli/scaffold/minimal/index.html +44 -44
  59. package/cli/scaffold/ssr/app/app.js +41 -41
  60. package/cli/scaffold/ssr/app/components/about.js +55 -55
  61. package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
  62. package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
  63. package/cli/scaffold/ssr/app/components/home.js +37 -37
  64. package/cli/scaffold/ssr/app/components/not-found.js +15 -15
  65. package/cli/scaffold/ssr/app/routes.js +8 -8
  66. package/cli/scaffold/ssr/global.css +228 -228
  67. package/cli/scaffold/ssr/index.html +37 -37
  68. package/cli/scaffold/ssr/package.json +8 -8
  69. package/cli/scaffold/ssr/server/data/posts.js +144 -144
  70. package/cli/scaffold/ssr/server/index.js +213 -213
  71. package/cli/scaffold/webrtc/app/app.js +11 -0
  72. package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
  73. package/cli/scaffold/webrtc/app/lib/room.js +252 -0
  74. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  75. package/cli/scaffold/webrtc/global.css +250 -0
  76. package/cli/scaffold/webrtc/index.html +21 -0
  77. package/cli/utils.js +305 -287
  78. package/dist/API.md +7264 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6252
  81. package/dist/zquery.min.js +8 -601
  82. package/index.d.ts +570 -365
  83. package/index.js +311 -232
  84. package/package.json +76 -69
  85. package/src/component.js +1709 -1454
  86. package/src/core.js +921 -921
  87. package/src/diff.js +497 -497
  88. package/src/errors.js +209 -209
  89. package/src/expression.js +922 -922
  90. package/src/http.js +242 -242
  91. package/src/package.json +1 -1
  92. package/src/reactive.js +255 -254
  93. package/src/router.js +843 -773
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -272
  96. package/src/utils.js +515 -515
  97. package/src/webrtc/e2ee.js +351 -0
  98. package/src/webrtc/errors.js +116 -0
  99. package/src/webrtc/ice.js +301 -0
  100. package/src/webrtc/index.js +131 -0
  101. package/src/webrtc/joinToken.js +119 -0
  102. package/src/webrtc/observe.js +172 -0
  103. package/src/webrtc/peer.js +351 -0
  104. package/src/webrtc/reactive.js +268 -0
  105. package/src/webrtc/room.js +625 -0
  106. package/src/webrtc/sdp.js +302 -0
  107. package/src/webrtc/sfu/index.js +43 -0
  108. package/src/webrtc/sfu/livekit.js +131 -0
  109. package/src/webrtc/sfu/mediasoup.js +150 -0
  110. package/src/webrtc/signaling.js +373 -0
  111. package/src/webrtc/turn.js +237 -0
  112. package/tests/_helpers/webrtcFakes.js +289 -0
  113. package/tests/audit.test.js +4158 -4158
  114. package/tests/cli.test.js +1136 -1023
  115. package/tests/compare.test.js +497 -0
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -0
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -0
  121. package/tests/electron-features.test.js +864 -0
  122. package/tests/errors.test.js +619 -619
  123. package/tests/expression.test.js +1056 -1056
  124. package/tests/http.test.js +648 -648
  125. package/tests/reactive.test.js +819 -819
  126. package/tests/router.test.js +2327 -2327
  127. package/tests/ssr.test.js +870 -870
  128. package/tests/store.test.js +830 -830
  129. package/tests/test-minifier.js +153 -153
  130. package/tests/test-ssr.js +27 -27
  131. package/tests/utils.test.js +1377 -1377
  132. package/tests/webrtc/e2ee.test.js +283 -0
  133. package/tests/webrtc/ice.test.js +202 -0
  134. package/tests/webrtc/joinToken.test.js +89 -0
  135. package/tests/webrtc/observe.test.js +111 -0
  136. package/tests/webrtc/peer.test.js +373 -0
  137. package/tests/webrtc/reactive.test.js +235 -0
  138. package/tests/webrtc/room.test.js +406 -0
  139. package/tests/webrtc/sdp.test.js +151 -0
  140. package/tests/webrtc/sfu-livekit.test.js +119 -0
  141. package/tests/webrtc/sfu.test.js +160 -0
  142. package/tests/webrtc/signaling.test.js +251 -0
  143. package/tests/webrtc/turn.test.js +256 -0
  144. package/types/collection.d.ts +383 -383
  145. package/types/component.d.ts +186 -186
  146. package/types/errors.d.ts +135 -135
  147. package/types/http.d.ts +92 -92
  148. package/types/misc.d.ts +201 -201
  149. package/types/reactive.d.ts +98 -98
  150. package/types/router.d.ts +190 -190
  151. package/types/ssr.d.ts +102 -102
  152. package/types/store.d.ts +146 -145
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
@@ -0,0 +1,406 @@
1
+ /**
2
+ * tests/webrtc/room.test.js
3
+ *
4
+ * Coverage for the high-level `Room` class and the `webrtc.join()`
5
+ * orchestrator:
6
+ * - construction guards
7
+ * - signaling `peer-joined` / `peer-left` mutate the `peers` Signal
8
+ * - perfect-negotiation polite flag derived from lex comparison
9
+ * - publish() adds tracks to every existing peer and remembers them
10
+ * - publish before join: new peers receive remembered tracks
11
+ * - unpublish() removes the sender on every peer
12
+ * - dataChannel() opens a channel on every peer + on later joiners
13
+ * - dataChannel send() broadcasts; on('message') fans-in from all peers
14
+ * - peer's `track` event updates PeerInfo audio/video flags
15
+ * - leave() closes every peer, sends `leave`, becomes idempotent
16
+ * - event bus delivers `peer-joined`, `peer-left`, `error`
17
+ * - join() handshake: connect → hello → join → joined → roster bootstrap
18
+ * - join() timeout error code
19
+ */
20
+
21
+ import { describe, it, expect, beforeEach } from 'vitest';
22
+ import { Room, join } from '../../src/webrtc/room.js';
23
+ import { Peer } from '../../src/webrtc/peer.js';
24
+ import { SignalingClient } from '../../src/webrtc/signaling.js';
25
+ import { WebRtcError } from '../../src/webrtc/errors.js';
26
+ import {
27
+ FakeWebSocket, fakeSockets, resetFakeSockets,
28
+ FakeRTCPeerConnection, fakePeerConnections, resetFakePeerConnections,
29
+ } from '../_helpers/webrtcFakes.js';
30
+
31
+
32
+ /** Build a signaling client wired to a freshly opened FakeWebSocket. */
33
+ async function openSignaling(selfId = 'self_z') {
34
+ const client = new SignalingClient('ws://localhost/rtc', {
35
+ WebSocket: FakeWebSocket,
36
+ reconnect: false,
37
+ });
38
+ const p = client.connect();
39
+ fakeSockets[0].fakeOpen();
40
+ fakeSockets[0].fakeMessage({ type: 'hello', peerId: selfId });
41
+ await p;
42
+ return client;
43
+ }
44
+
45
+ function makeRoom(selfId = 'self_z') {
46
+ return openSignaling(selfId).then((signaling) => new Room({
47
+ id: 'room1',
48
+ self: selfId,
49
+ signaling,
50
+ peerOptions: { RTCPeerConnection: FakeRTCPeerConnection },
51
+ }));
52
+ }
53
+
54
+
55
+ describe('Room (construction guards)', () => {
56
+ beforeEach(() => { resetFakeSockets(); resetFakePeerConnections(); });
57
+
58
+ it('throws when id is missing', async () => {
59
+ const sig = await openSignaling();
60
+ expect(() => new Room({ id: '', self: 'a', signaling: sig })).toThrow(WebRtcError);
61
+ });
62
+
63
+ it('throws when self is missing', async () => {
64
+ const sig = await openSignaling();
65
+ expect(() => new Room({ id: 'r', self: '', signaling: sig })).toThrow(WebRtcError);
66
+ });
67
+
68
+ it('throws when signaling is missing', () => {
69
+ expect(() => new Room({ id: 'r', self: 'a', signaling: null })).toThrow(WebRtcError);
70
+ });
71
+ });
72
+
73
+
74
+ describe('Room (mesh management)', () => {
75
+ beforeEach(() => { resetFakeSockets(); resetFakePeerConnections(); });
76
+
77
+ it('adds a peer when signaling emits peer-joined', async () => {
78
+ const room = await makeRoom('self_z');
79
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
80
+ expect(room.peers.peek().size).toBe(1);
81
+ const info = room.peers.peek().get('peer_a');
82
+ expect(info).toBeDefined();
83
+ expect(info.id).toBe('peer_a');
84
+ expect(info.peer).toBeInstanceOf(Peer);
85
+ expect(info.connection).toBe('new');
86
+ });
87
+
88
+ it('ignores duplicate peer-joined frames', async () => {
89
+ const room = await makeRoom('self_z');
90
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
91
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
92
+ expect(room.peers.peek().size).toBe(1);
93
+ });
94
+
95
+ it('ignores a peer-joined frame for self', async () => {
96
+ const room = await makeRoom('self_z');
97
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'self_z' });
98
+ expect(room.peers.peek().size).toBe(0);
99
+ });
100
+
101
+ it('derives polite from lex compare (self > remote → polite)', async () => {
102
+ const room = await makeRoom('self_z');
103
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
104
+ expect(room.peers.peek().get('peer_a').peer.polite).toBe(true);
105
+ });
106
+
107
+ it('derives polite from lex compare (self < remote → impolite)', async () => {
108
+ const room = await makeRoom('aa_self');
109
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'zz_peer' });
110
+ expect(room.peers.peek().get('zz_peer').peer.polite).toBe(false);
111
+ });
112
+
113
+ it('removes a peer on peer-left and closes its PC', async () => {
114
+ const room = await makeRoom('self_z');
115
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
116
+ const info = room.peers.peek().get('peer_a');
117
+ fakeSockets[0].fakeMessage({ type: 'peer-left', id: 'peer_a' });
118
+ expect(room.peers.peek().size).toBe(0);
119
+ expect(info.peer.pc.closeCalls).toBe(1);
120
+ });
121
+
122
+ it('notifies subscribers when peers mutate', async () => {
123
+ const room = await makeRoom('self_z');
124
+ let snapshots = 0;
125
+ const unsub = room.peers.subscribe(() => { snapshots++; });
126
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
127
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_b' });
128
+ fakeSockets[0].fakeMessage({ type: 'peer-left', id: 'peer_a' });
129
+ unsub();
130
+ expect(snapshots).toBe(3);
131
+ });
132
+ });
133
+
134
+
135
+ describe('Room (publish / unpublish)', () => {
136
+ beforeEach(() => { resetFakeSockets(); resetFakePeerConnections(); });
137
+
138
+ function fakeStream(tracks) {
139
+ return { id: 'stream_fake', getTracks: () => tracks.slice() };
140
+ }
141
+
142
+ it('publish() addTrack on every existing peer', async () => {
143
+ const room = await makeRoom('self_z');
144
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
145
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_b' });
146
+
147
+ const t1 = { kind: 'audio', id: 't1' };
148
+ await room.publish(fakeStream([t1]));
149
+
150
+ const a = room.peers.peek().get('peer_a').pc;
151
+ const b = room.peers.peek().get('peer_b').pc;
152
+ expect(a.addTrackCalls).toHaveLength(1);
153
+ expect(b.addTrackCalls).toHaveLength(1);
154
+ expect(a.addTrackCalls[0].track).toBe(t1);
155
+ expect(room.localTracks.peek()).toEqual([t1]);
156
+ });
157
+
158
+ it('publish() then late peer-joined → late peer also receives the track', async () => {
159
+ const room = await makeRoom('self_z');
160
+ const t1 = { kind: 'video', id: 't1' };
161
+ await room.publish(fakeStream([t1]));
162
+
163
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
164
+ const a = room.peers.peek().get('peer_a').pc;
165
+ expect(a.addTrackCalls).toHaveLength(1);
166
+ expect(a.addTrackCalls[0].track).toBe(t1);
167
+ });
168
+
169
+ it('publish() is idempotent for the same track', async () => {
170
+ const room = await makeRoom('self_z');
171
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
172
+ const t1 = { kind: 'audio', id: 't1' };
173
+ await room.publish(fakeStream([t1]));
174
+ await room.publish(fakeStream([t1]));
175
+ expect(room.peers.peek().get('peer_a').pc.addTrackCalls).toHaveLength(1);
176
+ });
177
+
178
+ it('unpublish() removes the sender on every peer', async () => {
179
+ const room = await makeRoom('self_z');
180
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
181
+ const t1 = { kind: 'audio', id: 't1' };
182
+ const stream = fakeStream([t1]);
183
+ await room.publish(stream);
184
+ await room.unpublish(stream);
185
+ const a = room.peers.peek().get('peer_a').pc;
186
+ expect(a.removeTrackCalls).toHaveLength(1);
187
+ expect(room.localTracks.peek()).toEqual([]);
188
+ });
189
+
190
+ it('publish() rejects a non-MediaStream argument', async () => {
191
+ const room = await makeRoom('self_z');
192
+ await expect(room.publish(null)).rejects.toThrow(WebRtcError);
193
+ });
194
+ });
195
+
196
+
197
+ describe('Room (dataChannel multiplex)', () => {
198
+ beforeEach(() => { resetFakeSockets(); resetFakePeerConnections(); });
199
+
200
+ it('returns the same wrapper for the same label', async () => {
201
+ const room = await makeRoom('self_z');
202
+ const a = room.dataChannel('chat');
203
+ const b = room.dataChannel('chat');
204
+ expect(a).toBe(b);
205
+ });
206
+
207
+ it('opens the channel on every existing peer + every late joiner', async () => {
208
+ const room = await makeRoom('self_z');
209
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
210
+ const dc = room.dataChannel('chat', { ordered: true });
211
+
212
+ const a = room.peers.peek().get('peer_a').pc;
213
+ expect(a.dataChannelCalls).toHaveLength(1);
214
+ expect(a.dataChannelCalls[0].label).toBe('chat');
215
+
216
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_b' });
217
+ const b = room.peers.peek().get('peer_b').pc;
218
+ expect(b.dataChannelCalls).toHaveLength(1);
219
+ expect(b.dataChannelCalls[0].label).toBe('chat');
220
+ void dc;
221
+ });
222
+
223
+ it('send() broadcasts to every per-peer channel', async () => {
224
+ const room = await makeRoom('self_z');
225
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
226
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_b' });
227
+ const dc = room.dataChannel('chat');
228
+
229
+ const sendsA = [];
230
+ const sendsB = [];
231
+ const aPc = room.peers.peek().get('peer_a').pc;
232
+ const bPc = room.peers.peek().get('peer_b').pc;
233
+ aPc.dataChannelCalls[0].send = (d) => sendsA.push(d);
234
+ bPc.dataChannelCalls[0].send = (d) => sendsB.push(d);
235
+
236
+ dc.send('hello');
237
+ expect(sendsA).toEqual(['hello']);
238
+ expect(sendsB).toEqual(['hello']);
239
+ });
240
+
241
+ it('on("message") fans-in from every peer with (data, peerId)', async () => {
242
+ const room = await makeRoom('self_z');
243
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
244
+ const dc = room.dataChannel('chat');
245
+ const received = [];
246
+ dc.on('message', (data, from) => received.push({ data, from }));
247
+
248
+ const aDc = room.peers.peek().get('peer_a').pc.dataChannelCalls[0];
249
+ // The wrapper attached an onmessage handler in tests (no addEventListener on the stub).
250
+ aDc.onmessage({ data: 'ping' });
251
+ expect(received).toEqual([{ data: 'ping', from: 'peer_a' }]);
252
+ });
253
+ });
254
+
255
+
256
+ describe('Room (peer events update PeerInfo)', () => {
257
+ beforeEach(() => { resetFakeSockets(); resetFakePeerConnections(); });
258
+
259
+ it('ontrack flips audio/video flags and adds the track to the stream', async () => {
260
+ const room = await makeRoom('self_z');
261
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
262
+ const pc = room.peers.peek().get('peer_a').pc;
263
+ // Synthesize a remote audio track with no incoming stream to force fallback addTrack().
264
+ pc.fakeTrack({ track: { kind: 'audio', id: 't_audio' }, streams: [] });
265
+ pc.fakeTrack({ track: { kind: 'video', id: 't_video' }, streams: [] });
266
+ const info = room.peers.peek().get('peer_a');
267
+ expect(info.audio).toBe(true);
268
+ expect(info.video).toBe(true);
269
+ });
270
+
271
+ it('connectionstatechange propagates onto PeerInfo.connection', async () => {
272
+ const room = await makeRoom('self_z');
273
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
274
+ const pc = room.peers.peek().get('peer_a').pc;
275
+ pc.fakeConnectionStateChange('connected');
276
+ expect(room.peers.peek().get('peer_a').connection).toBe('connected');
277
+ });
278
+ });
279
+
280
+
281
+ describe('Room (event bus)', () => {
282
+ beforeEach(() => { resetFakeSockets(); resetFakePeerConnections(); });
283
+
284
+ it('peer-joined and peer-left fire the corresponding events', async () => {
285
+ const room = await makeRoom('self_z');
286
+ const joined = [];
287
+ const left = [];
288
+ room.on('peer-joined', (info) => joined.push(info.id));
289
+ room.on('peer-left', (info) => left.push(info.id));
290
+
291
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
292
+ fakeSockets[0].fakeMessage({ type: 'peer-left', id: 'peer_a' });
293
+ expect(joined).toEqual(['peer_a']);
294
+ expect(left).toEqual(['peer_a']);
295
+ });
296
+
297
+ it('error fires when a peer\'s connection state goes "failed"', async () => {
298
+ const room = await makeRoom('self_z');
299
+ const errors = [];
300
+ room.on('error', (err) => errors.push(err));
301
+
302
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
303
+ const pc = room.peers.peek().get('peer_a').pc;
304
+ pc.fakeConnectionStateChange('failed');
305
+ expect(errors.length).toBeGreaterThanOrEqual(1);
306
+ expect(errors[0]).toBeInstanceOf(WebRtcError);
307
+ expect(errors[0].code).toBe('ZQ_WEBRTC_PEER_FAILED');
308
+ });
309
+
310
+ it('off() removes a previously registered listener', async () => {
311
+ const room = await makeRoom('self_z');
312
+ const seen = [];
313
+ const cb = (info) => seen.push(info.id);
314
+ room.on('peer-joined', cb);
315
+ room.off('peer-joined', cb);
316
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
317
+ expect(seen).toEqual([]);
318
+ });
319
+ });
320
+
321
+
322
+ describe('Room (leave)', () => {
323
+ beforeEach(() => { resetFakeSockets(); resetFakePeerConnections(); });
324
+
325
+ it('closes every peer, sends a leave frame, and clears state', async () => {
326
+ const room = await makeRoom('self_z');
327
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
328
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_b' });
329
+ const pcs = [
330
+ room.peers.peek().get('peer_a').pc,
331
+ room.peers.peek().get('peer_b').pc,
332
+ ];
333
+ await room.leave();
334
+ expect(room.closed).toBe(true);
335
+ expect(room.peers.peek().size).toBe(0);
336
+ for (const pc of pcs) expect(pc.closeCalls).toBe(1);
337
+ const leaves = fakeSockets[0].sentFrames.filter((f) => f.type === 'leave');
338
+ expect(leaves).toHaveLength(1);
339
+ });
340
+
341
+ it('is idempotent', async () => {
342
+ const room = await makeRoom('self_z');
343
+ await room.leave();
344
+ await room.leave();
345
+ expect(room.closed).toBe(true);
346
+ });
347
+
348
+ it('further peer-joined frames after leave() are ignored', async () => {
349
+ const room = await makeRoom('self_z');
350
+ await room.leave();
351
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
352
+ expect(room.peers.peek().size).toBe(0);
353
+ });
354
+ });
355
+
356
+
357
+ describe('webrtc.join()', () => {
358
+ beforeEach(() => { resetFakeSockets(); resetFakePeerConnections(); });
359
+
360
+ it('completes the handshake and seeds existing peers from joined.peers', async () => {
361
+ const p = join('ws://localhost/rtc', {
362
+ room: 'room1',
363
+ WebSocket: FakeWebSocket,
364
+ RTCPeerConnection: FakeRTCPeerConnection,
365
+ reconnect: false,
366
+ });
367
+ // Drive the handshake.
368
+ await Promise.resolve();
369
+ fakeSockets[0].fakeOpen();
370
+ fakeSockets[0].fakeMessage({ type: 'hello', peerId: 'self_z' });
371
+ fakeSockets[0].fakeMessage({ type: 'joined', room: 'room1', peerId: 'self_z', peers: ['peer_a', 'peer_b'] });
372
+ const room = await p;
373
+
374
+ expect(room).toBeInstanceOf(Room);
375
+ expect(room.id).toBe('room1');
376
+ expect(room.self).toBe('self_z');
377
+ expect(room.peers.peek().size).toBe(2);
378
+ expect(room.peers.peek().has('peer_a')).toBe(true);
379
+ expect(room.peers.peek().has('peer_b')).toBe(true);
380
+ const joinFrames = fakeSockets[0].sentFrames.filter((f) => f.type === 'join');
381
+ expect(joinFrames).toHaveLength(1);
382
+ expect(joinFrames[0].room).toBe('room1');
383
+ });
384
+
385
+ it('rejects when url is missing', async () => {
386
+ await expect(join('', { room: 'r' })).rejects.toThrow(WebRtcError);
387
+ });
388
+
389
+ it('rejects when opts.room is missing', async () => {
390
+ await expect(join('ws://x', {})).rejects.toThrow(WebRtcError);
391
+ });
392
+
393
+ it('rejects with timeout when hello never arrives', async () => {
394
+ const p = join('ws://localhost/rtc', {
395
+ room: 'room1',
396
+ WebSocket: FakeWebSocket,
397
+ RTCPeerConnection: FakeRTCPeerConnection,
398
+ reconnect: false,
399
+ signalingTimeoutMs: 20,
400
+ });
401
+ await Promise.resolve();
402
+ fakeSockets[0].fakeOpen();
403
+ // Do not deliver `hello`.
404
+ await expect(p).rejects.toThrow(/timed out/i);
405
+ });
406
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * tests/webrtc/sdp.test.js
3
+ *
4
+ * Coverage for `src/webrtc/sdp.js`: parse extracts the WebRTC-relevant
5
+ * subset, validate enforces the same constraints the server-side hub
6
+ * applies, and both throw `SdpError` with stable codes on bad input.
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest';
10
+ import { parseSdp, validateSdp, SDP_DIRECTIONS } from '../../src/webrtc/sdp.js';
11
+ import { SdpError } from '../../src/webrtc/errors.js';
12
+ import { MIN_SDP } from '../_helpers/webrtcFakes.js';
13
+
14
+
15
+ describe('sdp.parseSdp', () => {
16
+ it('parses the canonical minimal SDP fixture', () => {
17
+ const d = parseSdp(MIN_SDP);
18
+ expect(d.version).toBe(0);
19
+ expect(d.media).toHaveLength(1);
20
+ const m = d.media[0];
21
+ expect(m.kind).toBe('audio');
22
+ expect(m.port).toBe(9);
23
+ expect(m.proto).toBe('UDP/TLS/RTP/SAVPF');
24
+ expect(m.fmts).toEqual(['111']);
25
+ expect(m.mid).toBe('0');
26
+ expect(m.iceUfrag).toBe('abcd');
27
+ expect(m.icePwd).toBeDefined();
28
+ expect(m.fingerprint).toBeDefined();
29
+ expect(m.fingerprint.algorithm).toBe('sha-256');
30
+ expect(m.setup).toBe('actpass');
31
+ expect(m.direction).toBe('sendrecv');
32
+ expect(m.rtpmaps).toHaveLength(1);
33
+ expect(m.rtpmaps[0]).toEqual({ payload: 111, codec: 'opus', clockRate: 48000, channels: 2 });
34
+ });
35
+
36
+ it('preserves unknown attributes on the raw attribute list', () => {
37
+ const sdp = MIN_SDP + 'a=custom-thing:hello\r\n';
38
+ const d = parseSdp(sdp);
39
+ const a = d.media[0].attributes.find((x) => x.key === 'custom-thing');
40
+ expect(a).toBeDefined();
41
+ expect(a.value).toBe('hello');
42
+ });
43
+
44
+ it('extracts a=candidate lines into media.candidates', () => {
45
+ const sdp = MIN_SDP + 'a=candidate:1 1 udp 1 1.2.3.4 5000 typ host\r\n';
46
+ const d = parseSdp(sdp);
47
+ expect(d.media[0].candidates).toHaveLength(1);
48
+ expect(d.media[0].candidates[0]).toBe('candidate:1 1 udp 1 1.2.3.4 5000 typ host');
49
+ });
50
+
51
+ it('throws SdpError on non-string input', () => {
52
+ expect(() => parseSdp(null)).toThrowError(SdpError);
53
+ expect(() => parseSdp(42)).toThrowError(SdpError);
54
+ });
55
+
56
+ it('throws SdpError on empty input', () => {
57
+ expect(() => parseSdp('')).toThrowError(SdpError);
58
+ });
59
+
60
+ it('throws SdpError when payload exceeds maxBytes', () => {
61
+ const huge = 'v=0\r\n' + 'a=x:'.padEnd(10_000, 'y') + '\r\n';
62
+ expect(() => parseSdp(huge, { maxBytes: 100 })).toThrowError(SdpError);
63
+ });
64
+
65
+ it('throws SdpError when SDP does not start with v=', () => {
66
+ expect(() => parseSdp('s=foo\r\n')).toThrowError(SdpError);
67
+ });
68
+
69
+ it('throws SdpError on malformed line', () => {
70
+ expect(() => parseSdp('v=0\r\nxxx\r\n')).toThrowError(SdpError);
71
+ });
72
+
73
+ it('honors LF-only line endings', () => {
74
+ const lf = MIN_SDP.replace(/\r\n/g, '\n');
75
+ const d = parseSdp(lf);
76
+ expect(d.media[0].iceUfrag).toBe('abcd');
77
+ });
78
+ });
79
+
80
+
81
+ describe('sdp.validateSdp', () => {
82
+ it('accepts a known-good SDP and returns the parsed structure', () => {
83
+ const d = validateSdp(MIN_SDP);
84
+ expect(d.media[0].iceUfrag).toBe('abcd');
85
+ });
86
+
87
+ it('rejects SDP with no m-lines', () => {
88
+ const noMedia = 'v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n';
89
+ expect(() => validateSdp(noMedia)).toThrowError(SdpError);
90
+ });
91
+
92
+ it('rejects SDP with wrong proto on a non-rejected m-line', () => {
93
+ const bad = MIN_SDP.replace('UDP/TLS/RTP/SAVPF', 'RTP/AVP');
94
+ expect(() => validateSdp(bad)).toThrowError(SdpError);
95
+ });
96
+
97
+ it('rejects SDP missing ice-ufrag', () => {
98
+ const bad = MIN_SDP.replace(/a=ice-ufrag:abcd\r\n/, '');
99
+ expect(() => validateSdp(bad)).toThrowError(SdpError);
100
+ });
101
+
102
+ it('rejects SDP missing ice-pwd', () => {
103
+ const bad = MIN_SDP.replace(/a=ice-pwd:[^\r\n]+\r\n/, '');
104
+ expect(() => validateSdp(bad)).toThrowError(SdpError);
105
+ });
106
+
107
+ it('rejects SDP missing fingerprint', () => {
108
+ const bad = MIN_SDP.replace(/a=fingerprint:[^\r\n]+\r\n/, '');
109
+ expect(() => validateSdp(bad)).toThrowError(SdpError);
110
+ });
111
+
112
+ it('accepts session-level ice-ufrag/ice-pwd/fingerprint as fallback', () => {
113
+ // Move the m-section attrs up to session level
114
+ const m = MIN_SDP;
115
+ const sessionLevel =
116
+ 'v=0\r\n' +
117
+ 'o=- 1 1 IN IP4 127.0.0.1\r\n' +
118
+ 's=-\r\n' +
119
+ 'a=ice-ufrag:abcd\r\n' +
120
+ 'a=ice-pwd:0123456789abcdef0123456789abcd\r\n' +
121
+ 'a=fingerprint:sha-256 AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99\r\n' +
122
+ 't=0 0\r\n' +
123
+ 'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' +
124
+ 'a=setup:actpass\r\n' +
125
+ 'a=mid:0\r\n' +
126
+ 'a=sendrecv\r\n' +
127
+ 'a=rtpmap:111 opus/48000/2\r\n';
128
+ // Sanity: it should NOT throw, because session-level attrs are honored.
129
+ const d = validateSdp(sessionLevel);
130
+ expect(d.media[0].iceUfrag).toBeUndefined(); // raw subset: not on m-line
131
+ expect(d.attributes.find((a) => a.key === 'ice-ufrag').value).toBe('abcd');
132
+ // suppress unused-var lint
133
+ expect(m.length).toBeGreaterThan(0);
134
+ });
135
+
136
+ it('skips rejected m-lines (port=0)', () => {
137
+ const rejected = MIN_SDP.replace('m=audio 9 ', 'm=audio 0 ').replace(/a=ice-ufrag:[^\r\n]+\r\n/, '');
138
+ // port=0 means rejected and unvalidated, but no other m-line exists so should still fail "no m-lines"?
139
+ // It IS an m-line, just rejected; validator should not throw on it.
140
+ const d = validateSdp(rejected);
141
+ expect(d.media[0].port).toBe(0);
142
+ });
143
+ });
144
+
145
+
146
+ describe('sdp module constants', () => {
147
+ it('exports SDP_DIRECTIONS frozen', () => {
148
+ expect(SDP_DIRECTIONS).toEqual(['sendrecv', 'sendonly', 'recvonly', 'inactive']);
149
+ expect(Object.isFrozen(SDP_DIRECTIONS)).toBe(true);
150
+ });
151
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * tests/webrtc/sfu-livekit.test.js
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { loadSfuAdapter, createLivekitAdapter, SfuError } from '../../src/webrtc/index.js';
7
+
8
+
9
+ function makeFakeLivekitClient({ failConnect = false } = {}) {
10
+ const events = { connect: [], disconnect: [] };
11
+ class FakeRoom {
12
+ constructor(opts) {
13
+ this.opts = opts;
14
+ events.connect.push([]);
15
+ events.disconnect.push([]);
16
+ }
17
+ async connect(url, token, connectOpts) {
18
+ this.lastConnect = { url, token, connectOpts };
19
+ if (failConnect) throw new Error('connect rejected');
20
+ }
21
+ async disconnect() {
22
+ this.disconnected = true;
23
+ }
24
+ }
25
+ return { mod: { Room: FakeRoom }, events };
26
+ }
27
+
28
+
29
+ describe('createLivekitAdapter (with mock)', () => {
30
+ it('loadSfuAdapter(\'livekit\') without livekit-client throws ZQ_WEBRTC_SFU_PEER_MISSING', async () => {
31
+ await expect(loadSfuAdapter('livekit')).rejects.toMatchObject({
32
+ code: 'ZQ_WEBRTC_SFU_PEER_MISSING',
33
+ });
34
+ });
35
+
36
+ it('throws ZQ_WEBRTC_SFU_BAD_MODULE when Room is missing', async () => {
37
+ await expect(createLivekitAdapter({ client: {} })).rejects.toMatchObject({
38
+ code: 'ZQ_WEBRTC_SFU_BAD_MODULE',
39
+ });
40
+ });
41
+
42
+ it('accepts a `default` export wrapper', async () => {
43
+ const { mod } = makeFakeLivekitClient();
44
+ const a = await createLivekitAdapter({ client: { default: mod } });
45
+ expect(a.name).toBe('livekit');
46
+ });
47
+
48
+ it('wraps Room-constructor exceptions in SfuError', async () => {
49
+ class BoomRoom { constructor() { throw new Error('boom'); } }
50
+ await expect(createLivekitAdapter({ client: { Room: BoomRoom } })).rejects.toMatchObject({
51
+ code: 'ZQ_WEBRTC_SFU_ROOM_FAILED',
52
+ });
53
+ });
54
+
55
+ it('builds an adapter with name=livekit, room, connected=false', async () => {
56
+ const { mod } = makeFakeLivekitClient();
57
+ const a = await loadSfuAdapter('livekit', { client: mod, roomOptions: { adaptiveStream: true } });
58
+ expect(a.name).toBe('livekit');
59
+ expect(a.room).toBeDefined();
60
+ expect(a.room.opts).toEqual({ adaptiveStream: true });
61
+ expect(a.connected).toBe(false);
62
+ });
63
+
64
+ it('connect() validates url and token', async () => {
65
+ const { mod } = makeFakeLivekitClient();
66
+ const a = await createLivekitAdapter({ client: mod });
67
+ await expect(a.connect('', 'tok')).rejects.toMatchObject({ code: 'ZQ_WEBRTC_SFU_BAD_URL' });
68
+ await expect(a.connect('wss://x', '')).rejects.toMatchObject({ code: 'ZQ_WEBRTC_SFU_BAD_TOKEN' });
69
+ });
70
+
71
+ it('connect() forwards url + token + opts and flips connected=true', async () => {
72
+ const { mod } = makeFakeLivekitClient();
73
+ const a = await createLivekitAdapter({ client: mod });
74
+ await a.connect('wss://lk.example', 'tok-123', { autoSubscribe: true });
75
+ expect(a.connected).toBe(true);
76
+ expect(a.room.lastConnect).toEqual({
77
+ url: 'wss://lk.example',
78
+ token: 'tok-123',
79
+ connectOpts: { autoSubscribe: true },
80
+ });
81
+ });
82
+
83
+ it('connect() wraps underlying failures', async () => {
84
+ const { mod } = makeFakeLivekitClient({ failConnect: true });
85
+ const a = await createLivekitAdapter({ client: mod });
86
+ await expect(a.connect('wss://lk.example', 'tok')).rejects.toMatchObject({
87
+ code: 'ZQ_WEBRTC_SFU_CONNECT_FAILED',
88
+ });
89
+ expect(a.connected).toBe(false);
90
+ });
91
+
92
+ it('disconnect() is a no-op when not connected', async () => {
93
+ const { mod } = makeFakeLivekitClient();
94
+ const a = await createLivekitAdapter({ client: mod });
95
+ await a.disconnect();
96
+ expect(a.room.disconnected).toBeUndefined();
97
+ });
98
+
99
+ it('disconnect() calls underlying disconnect and resets connected', async () => {
100
+ const { mod } = makeFakeLivekitClient();
101
+ const a = await createLivekitAdapter({ client: mod });
102
+ await a.connect('wss://lk.example', 'tok');
103
+ await a.disconnect();
104
+ expect(a.room.disconnected).toBe(true);
105
+ expect(a.connected).toBe(false);
106
+ });
107
+
108
+ it('join() throws ZQ_WEBRTC_SFU_JOIN_UNAVAILABLE (not yet wired)', async () => {
109
+ const { mod } = makeFakeLivekitClient();
110
+ const a = await createLivekitAdapter({ client: mod });
111
+ try {
112
+ await a.join({});
113
+ throw new Error('expected throw');
114
+ } catch (err) {
115
+ expect(err).toBeInstanceOf(SfuError);
116
+ expect(err.code).toBe('ZQ_WEBRTC_SFU_JOIN_UNAVAILABLE');
117
+ }
118
+ });
119
+ });