zero-query 1.1.1 → 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 -442
  5. package/cli/commands/build.js +254 -247
  6. package/cli/commands/bundle.js +1228 -1224
  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 -220
  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 +661 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6614
  81. package/dist/zquery.min.js +8 -631
  82. package/index.d.ts +570 -371
  83. package/index.js +311 -240
  84. package/package.json +76 -70
  85. package/src/component.js +1709 -1691
  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 -255
  93. package/src/router.js +843 -843
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -318
  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 -1103
  115. package/tests/compare.test.js +497 -486
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -489
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -1650
  121. package/tests/electron-features.test.js +864 -864
  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 -146
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * tests/webrtc/peer.test.js
3
+ *
4
+ * Coverage for the `Peer` wrapper:
5
+ * - construction guards
6
+ * - negotiationneeded -> sends `offer` frame with sdp string
7
+ * - ICE trickle: candidates routed to signaling with `to: peerId`
8
+ * - ICE cap of 30 candidates, mDNS filter, EOC marker
9
+ * - incoming `offer` -> sets remote, replies with `answer`
10
+ * - incoming `answer` -> sets remote, no extra frames
11
+ * - incoming `ice` -> addIceCandidate
12
+ * - frames addressed to OTHER peerIds are ignored
13
+ * - perfect-negotiation collision: impolite peer ignores; polite peer rolls back
14
+ * - `connectionstatechange = 'failed'` triggers `restartIce()`
15
+ * - `error` event fires `SdpError` / `IceError` on failures
16
+ * - `close()` detaches signaling listeners and is idempotent
17
+ */
18
+
19
+ import { describe, it, expect, beforeEach } from 'vitest';
20
+ import { SignalingClient } from '../../src/webrtc/signaling.js';
21
+ import { Peer } from '../../src/webrtc/peer.js';
22
+ import { SdpError, IceError, WebRtcError } from '../../src/webrtc/errors.js';
23
+ import {
24
+ FakeWebSocket, fakeSockets, resetFakeSockets,
25
+ FakeRTCPeerConnection, fakePeerConnections, resetFakePeerConnections,
26
+ } from '../_helpers/webrtcFakes.js';
27
+
28
+
29
+ /** Build a signaling client wired to a freshly opened FakeWebSocket. */
30
+ async function makeOpenSignaling() {
31
+ const client = new SignalingClient('ws://localhost/rtc', {
32
+ WebSocket: FakeWebSocket,
33
+ reconnect: false,
34
+ });
35
+ const p = client.connect();
36
+ fakeSockets[0].fakeOpen();
37
+ fakeSockets[0].fakeMessage({ type: 'hello', peerId: 'self_1' });
38
+ await p;
39
+ return client;
40
+ }
41
+
42
+ /** Pick the only `Peer`-owned PC out of the fake registry. */
43
+ function lastPc() { return fakePeerConnections[fakePeerConnections.length - 1]; }
44
+
45
+ /** Helper: extract send frames of a given type from the open socket. */
46
+ function sentOfType(type) {
47
+ return fakeSockets[0].sentFrames.filter((f) => f.type === type);
48
+ }
49
+
50
+
51
+ describe('Peer (perfect negotiation)', () => {
52
+ beforeEach(() => {
53
+ resetFakeSockets();
54
+ resetFakePeerConnections();
55
+ });
56
+
57
+ // -----------------------------------------------------------------------
58
+ // Construction
59
+ // -----------------------------------------------------------------------
60
+
61
+ it('throws WebRtcError when peerId is missing', async () => {
62
+ const sig = await makeOpenSignaling();
63
+ expect(() => new Peer('', sig, { RTCPeerConnection: FakeRTCPeerConnection }))
64
+ .toThrowError(WebRtcError);
65
+ });
66
+
67
+ it('throws WebRtcError when signaling client is missing', () => {
68
+ expect(() => new Peer('peer_a', null, { RTCPeerConnection: FakeRTCPeerConnection }))
69
+ .toThrowError(WebRtcError);
70
+ });
71
+
72
+ it('throws when no RTCPeerConnection is available', async () => {
73
+ const sig = await makeOpenSignaling();
74
+ // No global RTCPeerConnection in vitest jsdom and no override given.
75
+ const originalRTC = globalThis.RTCPeerConnection;
76
+ try {
77
+ delete globalThis.RTCPeerConnection;
78
+ expect(() => new Peer('peer_a', sig)).toThrowError(WebRtcError);
79
+ } finally {
80
+ if (originalRTC) globalThis.RTCPeerConnection = originalRTC;
81
+ }
82
+ });
83
+
84
+ it('passes iceServers / rtcConfig through to the PC constructor', async () => {
85
+ const sig = await makeOpenSignaling();
86
+ const iceServers = [{ urls: 'stun:stun.example.com' }];
87
+ new Peer('peer_a', sig, {
88
+ RTCPeerConnection: FakeRTCPeerConnection,
89
+ iceServers,
90
+ rtcConfig: { bundlePolicy: 'max-bundle' },
91
+ });
92
+ expect(lastPc().config.iceServers).toBe(iceServers);
93
+ expect(lastPc().config.bundlePolicy).toBe('max-bundle');
94
+ });
95
+
96
+ // -----------------------------------------------------------------------
97
+ // Outbound: negotiationneeded -> offer
98
+ // -----------------------------------------------------------------------
99
+
100
+ it('sends an offer frame on negotiationneeded', async () => {
101
+ const sig = await makeOpenSignaling();
102
+ const peer = new Peer('peer_a', sig, { RTCPeerConnection: FakeRTCPeerConnection });
103
+
104
+ await lastPc().fakeNegotiationNeeded();
105
+
106
+ const offers = sentOfType('offer');
107
+ expect(offers).toHaveLength(1);
108
+ expect(offers[0].to).toBe('peer_a');
109
+ expect(typeof offers[0].sdp).toBe('string');
110
+ expect(offers[0].sdp).toContain('UDP/TLS/RTP/SAVPF');
111
+ peer.close();
112
+ });
113
+
114
+ it('emits SdpError when setLocalDescription throws during offer', async () => {
115
+ const sig = await makeOpenSignaling();
116
+ const peer = new Peer('peer_a', sig, { RTCPeerConnection: FakeRTCPeerConnection });
117
+ const errors = [];
118
+ peer.on('error', (e) => errors.push(e));
119
+
120
+ lastPc().failNextSetLocal = new Error('local boom');
121
+ await lastPc().fakeNegotiationNeeded();
122
+
123
+ expect(errors).toHaveLength(1);
124
+ expect(errors[0]).toBeInstanceOf(SdpError);
125
+ expect(errors[0].code).toBe('ZQ_WEBRTC_SDP_OFFER_FAILED');
126
+ peer.close();
127
+ });
128
+
129
+ // -----------------------------------------------------------------------
130
+ // Outbound: ICE trickle
131
+ // -----------------------------------------------------------------------
132
+
133
+ it('forwards trickled ICE candidates as `ice` frames addressed to peerId', async () => {
134
+ const sig = await makeOpenSignaling();
135
+ const peer = new Peer('peer_a', sig, { RTCPeerConnection: FakeRTCPeerConnection });
136
+
137
+ lastPc().fakeIceCandidate('candidate:1 1 udp 2122260223 192.0.2.1 5000 typ host');
138
+ lastPc().fakeIceCandidate('candidate:2 1 udp 1686052607 198.51.100.1 5001 typ srflx');
139
+ lastPc().fakeIceCandidate(null); // end-of-candidates
140
+
141
+ // Bypass coalesce window by reading the queued frames directly: we know
142
+ // signaling.js batches ICE per 200ms - flush by advancing real time...
143
+ // ... or simpler: assert the queue has populated by reading send calls
144
+ // once the timer flushes. Use synchronous expectation on _iceQueue.
145
+ // The SignalingClient buffers ice frames; we don't want to wait timers
146
+ // here, so just verify a deterministic side-effect: at minimum the
147
+ // queue depth matches the candidate count we trickled.
148
+ expect(sig._iceQueue.length).toBe(3);
149
+ expect(sig._iceQueue[0].to).toBe('peer_a');
150
+ expect(sig._iceQueue[0].candidate).toContain('typ host');
151
+ expect(sig._iceQueue[2].candidate).toBeNull();
152
+ peer.close();
153
+ });
154
+
155
+ it('drops mDNS (`.local`) candidates before sending', async () => {
156
+ const sig = await makeOpenSignaling();
157
+ const peer = new Peer('peer_a', sig, { RTCPeerConnection: FakeRTCPeerConnection });
158
+
159
+ lastPc().fakeIceCandidate('candidate:1 1 udp 2122260223 abcd1234.local 5000 typ host');
160
+ lastPc().fakeIceCandidate('candidate:2 1 udp 1686052607 198.51.100.1 5001 typ srflx');
161
+
162
+ expect(sig._iceQueue.length).toBe(1);
163
+ expect(sig._iceQueue[0].candidate).not.toContain('.local');
164
+ peer.close();
165
+ });
166
+
167
+ it('caps trickled candidates at maxIceCandidates', async () => {
168
+ const sig = await makeOpenSignaling();
169
+ const peer = new Peer('peer_a', sig, {
170
+ RTCPeerConnection: FakeRTCPeerConnection,
171
+ maxIceCandidates: 3,
172
+ });
173
+
174
+ for (let i = 0; i < 10; i++) {
175
+ lastPc().fakeIceCandidate(`candidate:${i} 1 udp 2122260223 192.0.2.${i} 5000 typ host`);
176
+ }
177
+ // EOC marker is always allowed past the cap.
178
+ lastPc().fakeIceCandidate(null);
179
+
180
+ const iceFrames = sig._iceQueue;
181
+ const nonNull = iceFrames.filter((f) => f.candidate !== null);
182
+ expect(nonNull.length).toBe(3);
183
+ expect(iceFrames.some((f) => f.candidate === null)).toBe(true);
184
+ peer.close();
185
+ });
186
+
187
+ // -----------------------------------------------------------------------
188
+ // Inbound: offer / answer / ice
189
+ // -----------------------------------------------------------------------
190
+
191
+ it('answers a remote offer and routes it through signaling', async () => {
192
+ const sig = await makeOpenSignaling();
193
+ const peer = new Peer('peer_a', sig, { RTCPeerConnection: FakeRTCPeerConnection });
194
+
195
+ fakeSockets[0].fakeMessage({ type: 'offer', from: 'peer_a', sdp: 'remote-sdp-blob' });
196
+ // setRemoteDescription + setLocalDescription are awaited inside the
197
+ // handler; yield two microtasks.
198
+ await Promise.resolve(); await Promise.resolve();
199
+
200
+ expect(lastPc().setRemoteCalls).toHaveLength(1);
201
+ expect(lastPc().setRemoteCalls[0]).toEqual({ type: 'offer', sdp: 'remote-sdp-blob' });
202
+ const answers = sentOfType('answer');
203
+ expect(answers).toHaveLength(1);
204
+ expect(answers[0].to).toBe('peer_a');
205
+ expect(typeof answers[0].sdp).toBe('string');
206
+ peer.close();
207
+ });
208
+
209
+ it('applies a remote answer and sends nothing further', async () => {
210
+ const sig = await makeOpenSignaling();
211
+ const peer = new Peer('peer_a', sig, { RTCPeerConnection: FakeRTCPeerConnection });
212
+
213
+ // Pretend we previously offered.
214
+ lastPc().signalingState = 'have-local-offer';
215
+ fakeSockets[0].fakeMessage({ type: 'answer', from: 'peer_a', sdp: 'remote-answer-sdp' });
216
+ await Promise.resolve(); await Promise.resolve();
217
+
218
+ expect(lastPc().setRemoteCalls).toHaveLength(1);
219
+ expect(lastPc().setRemoteCalls[0].type).toBe('answer');
220
+ expect(sentOfType('answer')).toHaveLength(0);
221
+ expect(sentOfType('offer')).toHaveLength(0);
222
+ peer.close();
223
+ });
224
+
225
+ it('adds remote ICE candidates and the EOC marker', async () => {
226
+ const sig = await makeOpenSignaling();
227
+ const peer = new Peer('peer_a', sig, { RTCPeerConnection: FakeRTCPeerConnection });
228
+
229
+ fakeSockets[0].fakeMessage({ type: 'ice', from: 'peer_a', candidate: 'candidate:7 1 udp 1 192.0.2.7 5000 typ host' });
230
+ fakeSockets[0].fakeMessage({ type: 'ice', from: 'peer_a', candidate: null });
231
+ await Promise.resolve(); await Promise.resolve();
232
+
233
+ expect(lastPc().addIceCandidateCalls).toHaveLength(2);
234
+ expect(lastPc().addIceCandidateCalls[0]).toEqual({ candidate: 'candidate:7 1 udp 1 192.0.2.7 5000 typ host' });
235
+ expect(lastPc().addIceCandidateCalls[1]).toBeNull();
236
+ peer.close();
237
+ });
238
+
239
+ it('ignores frames addressed to a different remote peer', async () => {
240
+ const sig = await makeOpenSignaling();
241
+ const peer = new Peer('peer_a', sig, { RTCPeerConnection: FakeRTCPeerConnection });
242
+
243
+ fakeSockets[0].fakeMessage({ type: 'offer', from: 'peer_b', sdp: 'other' });
244
+ fakeSockets[0].fakeMessage({ type: 'ice', from: 'peer_b', candidate: 'foo' });
245
+ await Promise.resolve(); await Promise.resolve();
246
+
247
+ expect(lastPc().setRemoteCalls).toHaveLength(0);
248
+ expect(lastPc().addIceCandidateCalls).toHaveLength(0);
249
+ peer.close();
250
+ });
251
+
252
+ // -----------------------------------------------------------------------
253
+ // Perfect negotiation collision
254
+ // -----------------------------------------------------------------------
255
+
256
+ it('impolite peer ignores a colliding remote offer', async () => {
257
+ const sig = await makeOpenSignaling();
258
+ const peer = new Peer('peer_a', sig, {
259
+ RTCPeerConnection: FakeRTCPeerConnection,
260
+ polite: false,
261
+ });
262
+
263
+ // Simulate an in-flight local offer.
264
+ peer.makingOffer = true;
265
+ fakeSockets[0].fakeMessage({ type: 'offer', from: 'peer_a', sdp: 'collide' });
266
+ await Promise.resolve(); await Promise.resolve();
267
+
268
+ expect(peer.ignoreOffer).toBe(true);
269
+ expect(lastPc().setRemoteCalls).toHaveLength(0);
270
+ expect(sentOfType('answer')).toHaveLength(0);
271
+ peer.close();
272
+ });
273
+
274
+ it('polite peer accepts a colliding remote offer and answers', async () => {
275
+ const sig = await makeOpenSignaling();
276
+ const peer = new Peer('peer_a', sig, {
277
+ RTCPeerConnection: FakeRTCPeerConnection,
278
+ polite: true,
279
+ });
280
+
281
+ peer.makingOffer = true;
282
+ fakeSockets[0].fakeMessage({ type: 'offer', from: 'peer_a', sdp: 'collide' });
283
+ await Promise.resolve(); await Promise.resolve();
284
+
285
+ expect(peer.ignoreOffer).toBe(false);
286
+ expect(lastPc().setRemoteCalls).toHaveLength(1);
287
+ expect(sentOfType('answer')).toHaveLength(1);
288
+ peer.close();
289
+ });
290
+
291
+ it('suppresses addIceCandidate errors while ignoring a glare offer', async () => {
292
+ const sig = await makeOpenSignaling();
293
+ const peer = new Peer('peer_a', sig, {
294
+ RTCPeerConnection: FakeRTCPeerConnection,
295
+ polite: false,
296
+ });
297
+ const errors = [];
298
+ peer.on('error', (e) => errors.push(e));
299
+
300
+ peer.ignoreOffer = true;
301
+ lastPc().failNextAddIce = new Error('stale');
302
+ fakeSockets[0].fakeMessage({ type: 'ice', from: 'peer_a', candidate: 'candidate:foo' });
303
+ await Promise.resolve(); await Promise.resolve();
304
+
305
+ expect(errors).toHaveLength(0);
306
+ peer.close();
307
+ });
308
+
309
+ // -----------------------------------------------------------------------
310
+ // restartIce / lifecycle
311
+ // -----------------------------------------------------------------------
312
+
313
+ it('calls restartIce() when connectionState transitions to failed', async () => {
314
+ const sig = await makeOpenSignaling();
315
+ const peer = new Peer('peer_a', sig, { RTCPeerConnection: FakeRTCPeerConnection });
316
+ const states = [];
317
+ peer.on('connectionstatechange', (s) => states.push(s));
318
+
319
+ lastPc().fakeConnectionStateChange('failed');
320
+
321
+ expect(states).toEqual(['failed']);
322
+ expect(lastPc().restartIceCount).toBe(1);
323
+ peer.close();
324
+ });
325
+
326
+ it('forwards track and datachannel events to listeners', async () => {
327
+ const sig = await makeOpenSignaling();
328
+ const peer = new Peer('peer_a', sig, { RTCPeerConnection: FakeRTCPeerConnection });
329
+ const tracks = []; const dcs = [];
330
+ peer.on('track', (e) => tracks.push(e));
331
+ peer.on('datachannel', (e) => dcs.push(e));
332
+
333
+ lastPc().fakeTrack();
334
+ lastPc().fakeDataChannel({ label: 'chat' });
335
+
336
+ expect(tracks).toHaveLength(1);
337
+ expect(dcs).toHaveLength(1);
338
+ expect(dcs[0].channel.label).toBe('chat');
339
+ peer.close();
340
+ });
341
+
342
+ it('emits IceError when restartIce() throws', async () => {
343
+ const sig = await makeOpenSignaling();
344
+ const peer = new Peer('peer_a', sig, { RTCPeerConnection: FakeRTCPeerConnection });
345
+ const errors = [];
346
+ peer.on('error', (e) => errors.push(e));
347
+
348
+ lastPc().restartIce = () => { throw new Error('cannot restart'); };
349
+ peer.restartIce();
350
+
351
+ expect(errors).toHaveLength(1);
352
+ expect(errors[0]).toBeInstanceOf(IceError);
353
+ expect(errors[0].code).toBe('ZQ_WEBRTC_ICE_RESTART_FAILED');
354
+ peer.close();
355
+ });
356
+
357
+ it('close() detaches signaling listeners and is idempotent', async () => {
358
+ const sig = await makeOpenSignaling();
359
+ const peer = new Peer('peer_a', sig, { RTCPeerConnection: FakeRTCPeerConnection });
360
+ let closeFired = 0;
361
+ peer.on('close', () => closeFired++);
362
+
363
+ peer.close();
364
+ peer.close(); // second call no-op
365
+ expect(closeFired).toBe(1);
366
+ expect(lastPc().closeCalls).toBe(1);
367
+
368
+ // Further inbound frames must not touch the (closed) PC.
369
+ fakeSockets[0].fakeMessage({ type: 'offer', from: 'peer_a', sdp: 'late' });
370
+ await Promise.resolve(); await Promise.resolve();
371
+ expect(lastPc().setRemoteCalls).toHaveLength(0);
372
+ });
373
+ });
@@ -0,0 +1,235 @@
1
+ /**
2
+ * tests/webrtc/reactive.test.js
3
+ *
4
+ * Coverage for the WebRTC composables:
5
+ * - useRoom() wraps an existing Room instance synchronously
6
+ * - useRoom() joins via signaling and resolves to a Room
7
+ * - usePeer() reflects the live PeerInfo and updates on peer-joined / peer-left
8
+ * - useTracks() snapshots current tracks and exposes refresh()
9
+ * - useDataChannel() buffers inbound frames into `messages.value`
10
+ * - useConnectionQuality() classifies stats reports into good/fair/poor
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach } from 'vitest';
14
+ import { Room, join as joinRoom } from '../../src/webrtc/room.js';
15
+ import { useRoom, usePeer, useTracks, useDataChannel, useConnectionQuality } from '../../src/webrtc/reactive.js';
16
+ import { SignalingClient } from '../../src/webrtc/signaling.js';
17
+ import {
18
+ FakeWebSocket, fakeSockets, resetFakeSockets,
19
+ FakeRTCPeerConnection, fakePeerConnections, resetFakePeerConnections,
20
+ } from '../_helpers/webrtcFakes.js';
21
+
22
+
23
+ async function openSignaling(selfId = 'self_z') {
24
+ const client = new SignalingClient('ws://localhost/rtc', {
25
+ WebSocket: FakeWebSocket,
26
+ reconnect: false,
27
+ });
28
+ const p = client.connect();
29
+ fakeSockets[0].fakeOpen();
30
+ fakeSockets[0].fakeMessage({ type: 'hello', peerId: selfId });
31
+ await p;
32
+ return client;
33
+ }
34
+
35
+ async function makeRoom(selfId = 'self_z') {
36
+ const sig = await openSignaling(selfId);
37
+ return new Room({
38
+ id: 'room1',
39
+ self: selfId,
40
+ signaling: sig,
41
+ peerOptions: { RTCPeerConnection: FakeRTCPeerConnection },
42
+ });
43
+ }
44
+
45
+
46
+ describe('useRoom()', () => {
47
+ beforeEach(() => { resetFakeSockets(); resetFakePeerConnections(); });
48
+
49
+ it('returns a Promise that resolves to a passed-in Room', async () => {
50
+ const room = await makeRoom();
51
+ const handle = await useRoom(room);
52
+ expect(handle).toBe(room);
53
+ });
54
+
55
+ it('joins via signaling when given a URL', async () => {
56
+ const p = useRoom('ws://localhost/rtc', {
57
+ room: 'r1',
58
+ WebSocket: FakeWebSocket,
59
+ RTCPeerConnection: FakeRTCPeerConnection,
60
+ reconnect: false,
61
+ });
62
+ await Promise.resolve();
63
+ fakeSockets[0].fakeOpen();
64
+ fakeSockets[0].fakeMessage({ type: 'hello', peerId: 'self_z' });
65
+ fakeSockets[0].fakeMessage({ type: 'joined', room: 'r1', peerId: 'self_z', peers: [] });
66
+ const room = await p;
67
+ expect(room).toBeInstanceOf(Room);
68
+ });
69
+ });
70
+
71
+
72
+ describe('usePeer()', () => {
73
+ beforeEach(() => { resetFakeSockets(); resetFakePeerConnections(); });
74
+
75
+ it('returns null when the peer is absent', async () => {
76
+ const room = await makeRoom();
77
+ const handle = usePeer(room, 'peer_a');
78
+ expect(handle.value).toBeNull();
79
+ handle.dispose();
80
+ });
81
+
82
+ it('reflects the PeerInfo when the peer joins, and null again on leave', async () => {
83
+ const room = await makeRoom();
84
+ const handle = usePeer(room, 'peer_a');
85
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
86
+ expect(handle.value).not.toBeNull();
87
+ expect(handle.value.id).toBe('peer_a');
88
+ fakeSockets[0].fakeMessage({ type: 'peer-left', id: 'peer_a' });
89
+ expect(handle.value).toBeNull();
90
+ handle.dispose();
91
+ });
92
+
93
+ it('subscribe() fires on peer mutations', async () => {
94
+ const room = await makeRoom();
95
+ const handle = usePeer(room, 'peer_a');
96
+ let n = 0;
97
+ const off = handle.subscribe(() => { n++; });
98
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
99
+ fakeSockets[0].fakeMessage({ type: 'peer-left', id: 'peer_a' });
100
+ expect(n).toBeGreaterThanOrEqual(2);
101
+ off();
102
+ handle.dispose();
103
+ });
104
+ });
105
+
106
+
107
+ describe('useTracks()', () => {
108
+ beforeEach(() => { resetFakeSockets(); resetFakePeerConnections(); });
109
+
110
+ it('returns the current tracks from the PeerInfo stream', async () => {
111
+ const tracks = [{ kind: 'audio', id: 'a' }];
112
+ const stream = { getTracks: () => tracks.slice() };
113
+ const handle = useTracks({ stream });
114
+ expect(handle.value).toEqual(tracks);
115
+ handle.dispose();
116
+ });
117
+
118
+ it('refresh() re-samples the underlying stream', async () => {
119
+ const tracks = [{ kind: 'audio', id: 'a' }];
120
+ const stream = { getTracks: () => tracks.slice() };
121
+ const handle = useTracks({ stream });
122
+ expect(handle.value).toHaveLength(1);
123
+ tracks.push({ kind: 'video', id: 'v' });
124
+ handle.refresh();
125
+ expect(handle.value).toHaveLength(2);
126
+ handle.dispose();
127
+ });
128
+
129
+ it('listens to addtrack/removetrack when the stream is an EventTarget', async () => {
130
+ const listeners = {};
131
+ const stream = {
132
+ getTracks: () => [],
133
+ addEventListener: (ev, cb) => { listeners[ev] = cb; },
134
+ removeEventListener: (ev) => { delete listeners[ev]; },
135
+ };
136
+ const handle = useTracks({ stream });
137
+ expect(typeof listeners.addtrack).toBe('function');
138
+ expect(typeof listeners.removetrack).toBe('function');
139
+ handle.dispose();
140
+ expect(listeners.addtrack).toBeUndefined();
141
+ });
142
+ });
143
+
144
+
145
+ describe('useDataChannel()', () => {
146
+ beforeEach(() => { resetFakeSockets(); resetFakePeerConnections(); });
147
+
148
+ it('buffers inbound messages into messages.value', async () => {
149
+ const room = await makeRoom();
150
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
151
+ const dc = useDataChannel(room, 'chat');
152
+ const aDc = room.peers.peek().get('peer_a').pc.dataChannelCalls[0];
153
+ aDc.onmessage({ data: 'hi' });
154
+ aDc.onmessage({ data: 'there' });
155
+ expect(dc.messages.value.map((m) => m.data)).toEqual(['hi', 'there']);
156
+ expect(dc.messages.value[0].from).toBe('peer_a');
157
+ dc.close();
158
+ });
159
+
160
+ it('history option caps the buffer length', async () => {
161
+ const room = await makeRoom();
162
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
163
+ const dc = useDataChannel(room, 'chat', { history: 2 });
164
+ const aDc = room.peers.peek().get('peer_a').pc.dataChannelCalls[0];
165
+ aDc.onmessage({ data: '1' });
166
+ aDc.onmessage({ data: '2' });
167
+ aDc.onmessage({ data: '3' });
168
+ expect(dc.messages.value.map((m) => m.data)).toEqual(['2', '3']);
169
+ dc.close();
170
+ });
171
+
172
+ it('send() forwards to the room wrapper', async () => {
173
+ const room = await makeRoom();
174
+ fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
175
+ const dc = useDataChannel(room, 'chat');
176
+ const aDc = room.peers.peek().get('peer_a').pc.dataChannelCalls[0];
177
+ const sends = [];
178
+ aDc.send = (d) => sends.push(d);
179
+ dc.send('go');
180
+ expect(sends).toEqual(['go']);
181
+ dc.close();
182
+ });
183
+ });
184
+
185
+
186
+ describe('useConnectionQuality()', () => {
187
+ beforeEach(() => { resetFakeSockets(); resetFakePeerConnections(); });
188
+
189
+ function buildReport({ lossPct = 0, rttMs = 0 }) {
190
+ const inbound = { type: 'inbound-rtp', packetsLost: 0, packetsReceived: 100 };
191
+ if (lossPct > 0) {
192
+ inbound.packetsLost = lossPct;
193
+ inbound.packetsReceived = 100 - lossPct;
194
+ }
195
+ const pair = { type: 'candidate-pair', state: 'succeeded', currentRoundTripTime: rttMs / 1000 };
196
+ return new Map([['a', inbound], ['b', pair]]);
197
+ }
198
+
199
+ it('classifies a clean report as good', async () => {
200
+ const peerInfo = { pc: {} };
201
+ const handle = useConnectionQuality(peerInfo, {
202
+ intervalMs: 999_999,
203
+ getStats: async () => buildReport({ lossPct: 0, rttMs: 50 }),
204
+ });
205
+ await new Promise((r) => setTimeout(r, 10));
206
+ expect(handle.value).toBe('good');
207
+ handle.dispose();
208
+ });
209
+
210
+ it('classifies a lossy report as fair', async () => {
211
+ const peerInfo = { pc: {} };
212
+ const handle = useConnectionQuality(peerInfo, {
213
+ intervalMs: 999_999,
214
+ getStats: async () => buildReport({ lossPct: 5, rttMs: 250 }),
215
+ });
216
+ await new Promise((r) => setTimeout(r, 10));
217
+ expect(handle.value).toBe('fair');
218
+ handle.dispose();
219
+ });
220
+
221
+ it('classifies a very lossy report as poor', async () => {
222
+ const peerInfo = { pc: {} };
223
+ const handle = useConnectionQuality(peerInfo, {
224
+ intervalMs: 999_999,
225
+ getStats: async () => buildReport({ lossPct: 25, rttMs: 600 }),
226
+ });
227
+ await new Promise((r) => setTimeout(r, 10));
228
+ expect(handle.value).toBe('poor');
229
+ handle.dispose();
230
+ });
231
+ });
232
+
233
+
234
+ // Suppress unused-import warning - joinRoom is a re-export for callers.
235
+ void joinRoom;