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.
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/cli/args.js +33 -33
- package/cli/commands/build-api.js +443 -0
- package/cli/commands/build.js +254 -216
- package/cli/commands/bundle.js +1228 -1183
- package/cli/commands/create.js +137 -121
- package/cli/commands/dev/devtools/index.js +56 -56
- package/cli/commands/dev/devtools/js/components.js +49 -49
- package/cli/commands/dev/devtools/js/core.js +423 -423
- package/cli/commands/dev/devtools/js/elements.js +421 -421
- package/cli/commands/dev/devtools/js/network.js +166 -166
- package/cli/commands/dev/devtools/js/performance.js +73 -73
- package/cli/commands/dev/devtools/js/router.js +105 -105
- package/cli/commands/dev/devtools/js/source.js +132 -132
- package/cli/commands/dev/devtools/js/stats.js +35 -35
- package/cli/commands/dev/devtools/js/tabs.js +79 -79
- package/cli/commands/dev/devtools/panel.html +95 -95
- package/cli/commands/dev/devtools/styles.css +244 -244
- package/cli/commands/dev/index.js +107 -107
- package/cli/commands/dev/logger.js +75 -75
- package/cli/commands/dev/overlay.js +858 -858
- package/cli/commands/dev/server.js +220 -167
- package/cli/commands/dev/validator.js +94 -94
- package/cli/commands/dev/watcher.js +172 -172
- package/cli/help.js +114 -112
- package/cli/index.js +52 -52
- package/cli/scaffold/default/LICENSE +21 -21
- package/cli/scaffold/default/app/app.js +207 -207
- package/cli/scaffold/default/app/components/about.js +201 -201
- package/cli/scaffold/default/app/components/api-demo.js +143 -143
- package/cli/scaffold/default/app/components/contact-card.js +231 -231
- package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
- package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
- package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
- package/cli/scaffold/default/app/components/counter.js +127 -127
- package/cli/scaffold/default/app/components/home.js +249 -249
- package/cli/scaffold/default/app/components/not-found.js +16 -16
- package/cli/scaffold/default/app/components/playground/playground.css +115 -115
- package/cli/scaffold/default/app/components/playground/playground.html +161 -161
- package/cli/scaffold/default/app/components/playground/playground.js +116 -116
- package/cli/scaffold/default/app/components/todos.js +225 -225
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
- package/cli/scaffold/default/app/routes.js +15 -15
- package/cli/scaffold/default/app/store.js +101 -101
- package/cli/scaffold/default/global.css +552 -552
- package/cli/scaffold/default/index.html +99 -99
- package/cli/scaffold/minimal/app/app.js +85 -85
- package/cli/scaffold/minimal/app/components/about.js +68 -68
- package/cli/scaffold/minimal/app/components/counter.js +122 -122
- package/cli/scaffold/minimal/app/components/home.js +68 -68
- package/cli/scaffold/minimal/app/components/not-found.js +16 -16
- package/cli/scaffold/minimal/app/routes.js +9 -9
- package/cli/scaffold/minimal/app/store.js +36 -36
- package/cli/scaffold/minimal/global.css +300 -300
- package/cli/scaffold/minimal/index.html +44 -44
- package/cli/scaffold/ssr/app/app.js +41 -41
- package/cli/scaffold/ssr/app/components/about.js +55 -55
- package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
- package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
- package/cli/scaffold/ssr/app/components/home.js +37 -37
- package/cli/scaffold/ssr/app/components/not-found.js +15 -15
- package/cli/scaffold/ssr/app/routes.js +8 -8
- package/cli/scaffold/ssr/global.css +228 -228
- package/cli/scaffold/ssr/index.html +37 -37
- package/cli/scaffold/ssr/package.json +8 -8
- package/cli/scaffold/ssr/server/data/posts.js +144 -144
- package/cli/scaffold/ssr/server/index.js +213 -213
- package/cli/scaffold/webrtc/app/app.js +11 -0
- package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
- package/cli/scaffold/webrtc/app/lib/room.js +252 -0
- package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
- package/cli/scaffold/webrtc/global.css +250 -0
- package/cli/scaffold/webrtc/index.html +21 -0
- package/cli/utils.js +305 -287
- package/dist/API.md +7264 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +10313 -6252
- package/dist/zquery.min.js +8 -601
- package/index.d.ts +570 -365
- package/index.js +311 -232
- package/package.json +76 -69
- package/src/component.js +1709 -1454
- package/src/core.js +921 -921
- package/src/diff.js +497 -497
- package/src/errors.js +209 -209
- package/src/expression.js +922 -922
- package/src/http.js +242 -242
- package/src/package.json +1 -1
- package/src/reactive.js +255 -254
- package/src/router.js +843 -773
- package/src/ssr.js +418 -418
- package/src/store.js +318 -272
- package/src/utils.js +515 -515
- package/src/webrtc/e2ee.js +351 -0
- package/src/webrtc/errors.js +116 -0
- package/src/webrtc/ice.js +301 -0
- package/src/webrtc/index.js +131 -0
- package/src/webrtc/joinToken.js +119 -0
- package/src/webrtc/observe.js +172 -0
- package/src/webrtc/peer.js +351 -0
- package/src/webrtc/reactive.js +268 -0
- package/src/webrtc/room.js +625 -0
- package/src/webrtc/sdp.js +302 -0
- package/src/webrtc/sfu/index.js +43 -0
- package/src/webrtc/sfu/livekit.js +131 -0
- package/src/webrtc/sfu/mediasoup.js +150 -0
- package/src/webrtc/signaling.js +373 -0
- package/src/webrtc/turn.js +237 -0
- package/tests/_helpers/webrtcFakes.js +289 -0
- package/tests/audit.test.js +4158 -4158
- package/tests/cli.test.js +1136 -1023
- package/tests/compare.test.js +497 -0
- package/tests/component.test.js +3969 -3938
- package/tests/core.test.js +1910 -1910
- package/tests/dev-server.test.js +489 -0
- package/tests/diff.test.js +1416 -1416
- package/tests/docs.test.js +1664 -0
- package/tests/electron-features.test.js +864 -0
- package/tests/errors.test.js +619 -619
- package/tests/expression.test.js +1056 -1056
- package/tests/http.test.js +648 -648
- package/tests/reactive.test.js +819 -819
- package/tests/router.test.js +2327 -2327
- package/tests/ssr.test.js +870 -870
- package/tests/store.test.js +830 -830
- package/tests/test-minifier.js +153 -153
- package/tests/test-ssr.js +27 -27
- package/tests/utils.test.js +1377 -1377
- package/tests/webrtc/e2ee.test.js +283 -0
- package/tests/webrtc/ice.test.js +202 -0
- package/tests/webrtc/joinToken.test.js +89 -0
- package/tests/webrtc/observe.test.js +111 -0
- package/tests/webrtc/peer.test.js +373 -0
- package/tests/webrtc/reactive.test.js +235 -0
- package/tests/webrtc/room.test.js +406 -0
- package/tests/webrtc/sdp.test.js +151 -0
- package/tests/webrtc/sfu-livekit.test.js +119 -0
- package/tests/webrtc/sfu.test.js +160 -0
- package/tests/webrtc/signaling.test.js +251 -0
- package/tests/webrtc/turn.test.js +256 -0
- package/types/collection.d.ts +383 -383
- package/types/component.d.ts +186 -186
- package/types/errors.d.ts +135 -135
- package/types/http.d.ts +92 -92
- package/types/misc.d.ts +201 -201
- package/types/reactive.d.ts +98 -98
- package/types/router.d.ts +190 -190
- package/types/ssr.d.ts +102 -102
- package/types/store.d.ts +146 -145
- package/types/utils.d.ts +245 -245
- 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;
|