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.
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/cli/args.js +33 -33
- package/cli/commands/build-api.js +443 -442
- package/cli/commands/build.js +254 -247
- package/cli/commands/bundle.js +1228 -1224
- 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 -220
- 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 +661 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +10313 -6614
- package/dist/zquery.min.js +8 -631
- package/index.d.ts +570 -371
- package/index.js +311 -240
- package/package.json +76 -70
- package/src/component.js +1709 -1691
- 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 -255
- package/src/router.js +843 -843
- package/src/ssr.js +418 -418
- package/src/store.js +318 -318
- 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 -1103
- package/tests/compare.test.js +497 -486
- package/tests/component.test.js +3969 -3938
- package/tests/core.test.js +1910 -1910
- package/tests/dev-server.test.js +489 -489
- package/tests/diff.test.js +1416 -1416
- package/tests/docs.test.js +1664 -1650
- package/tests/electron-features.test.js +864 -864
- 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 -146
- package/types/utils.d.ts +245 -245
- package/types/webrtc.d.ts +653 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/webrtc/e2ee.test.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
deriveSFrameKey,
|
|
8
|
+
generateSFrameKey,
|
|
9
|
+
SFrameContext,
|
|
10
|
+
encryptFrame,
|
|
11
|
+
decryptFrame,
|
|
12
|
+
attachE2ee,
|
|
13
|
+
E2eeError,
|
|
14
|
+
} from '../../src/webrtc/index.js';
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
const enc = new TextEncoder();
|
|
18
|
+
const dec = new TextDecoder();
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
describe('deriveSFrameKey', () => {
|
|
22
|
+
it('derives a deterministic key from the same passphrase + salt', async () => {
|
|
23
|
+
const k1 = await deriveSFrameKey('correct horse battery', 'room-a');
|
|
24
|
+
const k2 = await deriveSFrameKey('correct horse battery', 'room-a');
|
|
25
|
+
// CryptoKey identity differs but encrypt/decrypt with one should
|
|
26
|
+
// decrypt with the other (same underlying material).
|
|
27
|
+
const ctx1 = new SFrameContext(); ctx1.setKey(0, k1);
|
|
28
|
+
const ctx2 = new SFrameContext(); ctx2.setKey(0, k2);
|
|
29
|
+
const enc1 = await encryptFrame(ctx1, enc.encode('hello'));
|
|
30
|
+
const out = await decryptFrame(ctx2, enc1);
|
|
31
|
+
expect(dec.decode(out)).toBe('hello');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('different passphrases produce non-interoperable keys', async () => {
|
|
35
|
+
const k1 = await deriveSFrameKey('one', 'room-a');
|
|
36
|
+
const k2 = await deriveSFrameKey('two', 'room-a');
|
|
37
|
+
const ctx1 = new SFrameContext(); ctx1.setKey(0, k1);
|
|
38
|
+
const ctx2 = new SFrameContext(); ctx2.setKey(0, k2);
|
|
39
|
+
const frame = await encryptFrame(ctx1, enc.encode('x'));
|
|
40
|
+
await expect(decryptFrame(ctx2, frame)).rejects.toMatchObject({
|
|
41
|
+
code: 'ZQ_WEBRTC_E2EE_AUTH_FAILED',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('different salts produce non-interoperable keys', async () => {
|
|
46
|
+
const k1 = await deriveSFrameKey('pw', 'room-a');
|
|
47
|
+
const k2 = await deriveSFrameKey('pw', 'room-b');
|
|
48
|
+
const ctx1 = new SFrameContext(); ctx1.setKey(0, k1);
|
|
49
|
+
const ctx2 = new SFrameContext(); ctx2.setKey(0, k2);
|
|
50
|
+
const frame = await encryptFrame(ctx1, enc.encode('x'));
|
|
51
|
+
await expect(decryptFrame(ctx2, frame)).rejects.toMatchObject({
|
|
52
|
+
code: 'ZQ_WEBRTC_E2EE_AUTH_FAILED',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rejects empty passphrase / salt', async () => {
|
|
57
|
+
await expect(deriveSFrameKey('', 's')).rejects.toMatchObject({ code: 'ZQ_WEBRTC_E2EE_BAD_PASSPHRASE' });
|
|
58
|
+
await expect(deriveSFrameKey('p', '')).rejects.toMatchObject({ code: 'ZQ_WEBRTC_E2EE_BAD_SALT' });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
describe('SFrameContext', () => {
|
|
64
|
+
it('rejects invalid epochs', async () => {
|
|
65
|
+
const ctx = new SFrameContext();
|
|
66
|
+
const key = await generateSFrameKey();
|
|
67
|
+
expect(() => ctx.setKey(-1, key)).toThrow(/epoch/);
|
|
68
|
+
expect(() => ctx.setKey(256, key)).toThrow(/epoch/);
|
|
69
|
+
expect(() => ctx.setKey(1.5, key)).toThrow(/epoch/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('rejects setKey without a key', async () => {
|
|
73
|
+
const ctx = new SFrameContext();
|
|
74
|
+
expect(() => ctx.setKey(0, null)).toThrow(/key required/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('tracks current epoch on every setKey', async () => {
|
|
78
|
+
const ctx = new SFrameContext();
|
|
79
|
+
ctx.setKey(0, await generateSFrameKey());
|
|
80
|
+
expect(ctx.currentEpoch).toBe(0);
|
|
81
|
+
ctx.setKey(7, await generateSFrameKey());
|
|
82
|
+
expect(ctx.currentEpoch).toBe(7);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('evicts oldest epoch beyond maxEpochs', async () => {
|
|
86
|
+
const ctx = new SFrameContext({ maxEpochs: 2 });
|
|
87
|
+
ctx.setKey(0, await generateSFrameKey());
|
|
88
|
+
ctx.setKey(1, await generateSFrameKey());
|
|
89
|
+
ctx.setKey(2, await generateSFrameKey());
|
|
90
|
+
expect(ctx.epochCount).toBe(2);
|
|
91
|
+
expect(ctx.getKey(0)).toBeNull();
|
|
92
|
+
expect(ctx.getKey(1)).not.toBeNull();
|
|
93
|
+
expect(ctx.getKey(2)).not.toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('removeEpoch drops a tracked key', async () => {
|
|
97
|
+
const ctx = new SFrameContext();
|
|
98
|
+
ctx.setKey(3, await generateSFrameKey());
|
|
99
|
+
expect(ctx.getKey(3)).not.toBeNull();
|
|
100
|
+
ctx.removeEpoch(3);
|
|
101
|
+
expect(ctx.getKey(3)).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
describe('encryptFrame / decryptFrame', () => {
|
|
107
|
+
it('round-trips an arbitrary payload', async () => {
|
|
108
|
+
const ctx = new SFrameContext();
|
|
109
|
+
ctx.setKey(0, await generateSFrameKey());
|
|
110
|
+
const payload = enc.encode('the quick brown fox');
|
|
111
|
+
const frame = await encryptFrame(ctx, payload);
|
|
112
|
+
|
|
113
|
+
// Header layout: 1-byte epoch + 12-byte IV
|
|
114
|
+
expect(frame[0]).toBe(0);
|
|
115
|
+
expect(frame.byteLength).toBe(13 + payload.byteLength + 16);
|
|
116
|
+
|
|
117
|
+
const plain = await decryptFrame(ctx, frame);
|
|
118
|
+
expect(dec.decode(plain)).toBe('the quick brown fox');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('accepts ArrayBuffer payloads', async () => {
|
|
122
|
+
const ctx = new SFrameContext();
|
|
123
|
+
ctx.setKey(0, await generateSFrameKey());
|
|
124
|
+
const ab = enc.encode('hi').buffer;
|
|
125
|
+
const frame = await encryptFrame(ctx, ab);
|
|
126
|
+
const out = await decryptFrame(ctx, frame.buffer);
|
|
127
|
+
expect(dec.decode(out)).toBe('hi');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('writes the current epoch into the frame header', async () => {
|
|
131
|
+
const ctx = new SFrameContext();
|
|
132
|
+
ctx.setKey(0, await generateSFrameKey());
|
|
133
|
+
ctx.setKey(5, await generateSFrameKey());
|
|
134
|
+
const frame = await encryptFrame(ctx, enc.encode('x'));
|
|
135
|
+
expect(frame[0]).toBe(5);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('encryptFrame fails without a key for the current epoch', async () => {
|
|
139
|
+
const ctx = new SFrameContext();
|
|
140
|
+
await expect(encryptFrame(ctx, enc.encode('x'))).rejects.toMatchObject({
|
|
141
|
+
code: 'ZQ_WEBRTC_E2EE_NO_KEY',
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('decryptFrame fails on a too-short frame', async () => {
|
|
146
|
+
const ctx = new SFrameContext();
|
|
147
|
+
ctx.setKey(0, await generateSFrameKey());
|
|
148
|
+
await expect(decryptFrame(ctx, new Uint8Array(5))).rejects.toMatchObject({
|
|
149
|
+
code: 'ZQ_WEBRTC_E2EE_SHORT_FRAME',
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('decryptFrame fails when the epoch is unknown', async () => {
|
|
154
|
+
const enc1 = new SFrameContext();
|
|
155
|
+
enc1.setKey(2, await generateSFrameKey());
|
|
156
|
+
const frame = await encryptFrame(enc1, enc.encode('x'));
|
|
157
|
+
|
|
158
|
+
const dec1 = new SFrameContext();
|
|
159
|
+
dec1.setKey(7, await generateSFrameKey());
|
|
160
|
+
await expect(decryptFrame(dec1, frame)).rejects.toMatchObject({
|
|
161
|
+
code: 'ZQ_WEBRTC_E2EE_UNKNOWN_EPOCH',
|
|
162
|
+
context: { epoch: 2 },
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('decryptFrame fails when the key is wrong (AES-GCM auth fails)', async () => {
|
|
167
|
+
const ctx1 = new SFrameContext(); ctx1.setKey(0, await generateSFrameKey());
|
|
168
|
+
const ctx2 = new SFrameContext(); ctx2.setKey(0, await generateSFrameKey());
|
|
169
|
+
const frame = await encryptFrame(ctx1, enc.encode('x'));
|
|
170
|
+
await expect(decryptFrame(ctx2, frame)).rejects.toMatchObject({
|
|
171
|
+
code: 'ZQ_WEBRTC_E2EE_AUTH_FAILED',
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('decryptFrame fails when the ciphertext is tampered with', async () => {
|
|
176
|
+
const ctx = new SFrameContext();
|
|
177
|
+
ctx.setKey(0, await generateSFrameKey());
|
|
178
|
+
const frame = await encryptFrame(ctx, enc.encode('original'));
|
|
179
|
+
frame[frame.byteLength - 1] ^= 0x01;
|
|
180
|
+
await expect(decryptFrame(ctx, frame)).rejects.toMatchObject({
|
|
181
|
+
code: 'ZQ_WEBRTC_E2EE_AUTH_FAILED',
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('throws on non-BufferSource payloads', async () => {
|
|
186
|
+
const ctx = new SFrameContext();
|
|
187
|
+
ctx.setKey(0, await generateSFrameKey());
|
|
188
|
+
await expect(encryptFrame(ctx, 'not bytes')).rejects.toMatchObject({
|
|
189
|
+
code: 'ZQ_WEBRTC_E2EE_BAD_INPUT',
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('encrypt/decrypt require an SFrameContext instance', async () => {
|
|
194
|
+
await expect(encryptFrame({}, new Uint8Array(1))).rejects.toMatchObject({ code: 'ZQ_WEBRTC_E2EE_BAD_CTX' });
|
|
195
|
+
await expect(decryptFrame({}, new Uint8Array(20))).rejects.toMatchObject({ code: 'ZQ_WEBRTC_E2EE_BAD_CTX' });
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
describe('epoch rotation', () => {
|
|
201
|
+
it('decryptor with both epochs decodes frames from either', async () => {
|
|
202
|
+
const k0 = await generateSFrameKey();
|
|
203
|
+
const k1 = await generateSFrameKey();
|
|
204
|
+
|
|
205
|
+
const enc0 = new SFrameContext(); enc0.setKey(0, k0);
|
|
206
|
+
const enc1 = new SFrameContext(); enc1.setKey(1, k1);
|
|
207
|
+
|
|
208
|
+
const decoder = new SFrameContext();
|
|
209
|
+
decoder.setKey(0, k0);
|
|
210
|
+
decoder.setKey(1, k1);
|
|
211
|
+
|
|
212
|
+
const f0 = await encryptFrame(enc0, enc.encode('old'));
|
|
213
|
+
const f1 = await encryptFrame(enc1, enc.encode('new'));
|
|
214
|
+
|
|
215
|
+
expect(dec.decode(await decryptFrame(decoder, f0))).toBe('old');
|
|
216
|
+
expect(dec.decode(await decryptFrame(decoder, f1))).toBe('new');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('after rotation, frames from the evicted epoch fail to decrypt', async () => {
|
|
220
|
+
const k0 = await generateSFrameKey();
|
|
221
|
+
const k1 = await generateSFrameKey();
|
|
222
|
+
|
|
223
|
+
const sender = new SFrameContext();
|
|
224
|
+
sender.setKey(0, k0);
|
|
225
|
+
const stale = await encryptFrame(sender, enc.encode('stale'));
|
|
226
|
+
|
|
227
|
+
const receiver = new SFrameContext({ maxEpochs: 1 });
|
|
228
|
+
receiver.setKey(0, k0);
|
|
229
|
+
receiver.setKey(1, k1); // evicts epoch 0
|
|
230
|
+
|
|
231
|
+
await expect(decryptFrame(receiver, stale)).rejects.toMatchObject({
|
|
232
|
+
code: 'ZQ_WEBRTC_E2EE_UNKNOWN_EPOCH',
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
describe('attachE2ee', () => {
|
|
239
|
+
it('rejects something that is not an RTCPeerConnection', () => {
|
|
240
|
+
const ctx = new SFrameContext();
|
|
241
|
+
expect(() => attachE2ee({}, ctx)).toThrow(/RTCPeerConnection/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('rejects a non-SFrameContext', () => {
|
|
245
|
+
const fakePc = { getSenders: () => [], getReceivers: () => [] };
|
|
246
|
+
expect(() => attachE2ee(fakePc, {})).toThrow(/SFrameContext/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('returns a refresh/detach handle and walks senders + receivers', async () => {
|
|
250
|
+
const ctx = new SFrameContext();
|
|
251
|
+
ctx.setKey(0, await generateSFrameKey());
|
|
252
|
+
|
|
253
|
+
const senderCalls = [];
|
|
254
|
+
const receiverCalls = [];
|
|
255
|
+
const fakeSender = { createEncodedStreams: () => { senderCalls.push(1); return null; } };
|
|
256
|
+
const fakeReceiver = { createEncodedStreams: () => { receiverCalls.push(1); return null; } };
|
|
257
|
+
const fakePc = {
|
|
258
|
+
getSenders: () => [fakeSender],
|
|
259
|
+
getReceivers: () => [fakeReceiver],
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const handle = attachE2ee(fakePc, ctx);
|
|
263
|
+
expect(typeof handle.refresh).toBe('function');
|
|
264
|
+
expect(typeof handle.detach).toBe('function');
|
|
265
|
+
expect(senderCalls).toHaveLength(1);
|
|
266
|
+
expect(receiverCalls).toHaveLength(1);
|
|
267
|
+
|
|
268
|
+
// refresh() is idempotent per sender / receiver (WeakSet dedupe).
|
|
269
|
+
handle.refresh();
|
|
270
|
+
expect(senderCalls).toHaveLength(1);
|
|
271
|
+
|
|
272
|
+
handle.detach();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('survives a sender without createEncodedStreams', () => {
|
|
276
|
+
const ctx = new SFrameContext();
|
|
277
|
+
const fakePc = {
|
|
278
|
+
getSenders: () => [{}],
|
|
279
|
+
getReceivers: () => [{}],
|
|
280
|
+
};
|
|
281
|
+
expect(() => attachE2ee(fakePc, ctx)).not.toThrow();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/webrtc/ice.test.js
|
|
3
|
+
*
|
|
4
|
+
* Coverage for `src/webrtc/ice.js`: parse, stringify (round-trip),
|
|
5
|
+
* address classifiers, and `filterCandidates` policy combinations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
parseCandidate, stringifyCandidate,
|
|
11
|
+
isPrivateIp, isLoopbackIp, isLinkLocalIp, isMdnsHostname,
|
|
12
|
+
filterCandidates, CANDIDATE_TYPES,
|
|
13
|
+
} from '../../src/webrtc/ice.js';
|
|
14
|
+
import { IceError } from '../../src/webrtc/errors.js';
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
describe('ice.parseCandidate', () => {
|
|
18
|
+
const HOST = 'candidate:842163049 1 udp 1677729535 192.168.1.5 50000 typ host';
|
|
19
|
+
const SRFLX = 'candidate:1 1 udp 2122194687 1.2.3.4 50001 typ srflx raddr 192.168.1.5 rport 50000';
|
|
20
|
+
const TCP = 'candidate:2 1 tcp 1518280447 10.0.0.1 9 typ host tcptype active';
|
|
21
|
+
|
|
22
|
+
it('parses a simple host candidate', () => {
|
|
23
|
+
const c = parseCandidate(HOST);
|
|
24
|
+
expect(c.foundation).toBe('842163049');
|
|
25
|
+
expect(c.component).toBe(1);
|
|
26
|
+
expect(c.transport).toBe('udp');
|
|
27
|
+
expect(c.priority).toBe(1677729535);
|
|
28
|
+
expect(c.address).toBe('192.168.1.5');
|
|
29
|
+
expect(c.port).toBe(50000);
|
|
30
|
+
expect(c.type).toBe('host');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('lifts raddr/rport onto named fields for srflx', () => {
|
|
34
|
+
const c = parseCandidate(SRFLX);
|
|
35
|
+
expect(c.type).toBe('srflx');
|
|
36
|
+
expect(c.relatedAddress).toBe('192.168.1.5');
|
|
37
|
+
expect(c.relatedPort).toBe(50000);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('lifts tcptype for TCP candidates', () => {
|
|
41
|
+
const c = parseCandidate(TCP);
|
|
42
|
+
expect(c.transport).toBe('tcp');
|
|
43
|
+
expect(c.tcpType).toBe('active');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('accepts the `a=` SDP-attribute prefix', () => {
|
|
47
|
+
const c = parseCandidate(`a=${HOST}`);
|
|
48
|
+
expect(c.address).toBe('192.168.1.5');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('captures unknown key/value pairs as extensions', () => {
|
|
52
|
+
const c = parseCandidate(`${HOST} generation 0 ufrag abcd network-id 1`);
|
|
53
|
+
expect(c.extensions.generation).toBe('0');
|
|
54
|
+
expect(c.extensions.ufrag).toBe('abcd');
|
|
55
|
+
expect(c.extensions['network-id']).toBe('1');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('throws IceError on non-string input', () => {
|
|
59
|
+
expect(() => parseCandidate(null)).toThrowError(IceError);
|
|
60
|
+
expect(() => parseCandidate(123)).toThrowError(IceError);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('throws IceError on missing candidate: prefix', () => {
|
|
64
|
+
expect(() => parseCandidate('842163049 1 udp ...')).toThrowError(IceError);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('throws IceError on bad type keyword or unknown type', () => {
|
|
68
|
+
expect(() => parseCandidate('candidate:1 1 udp 1 1.2.3.4 1 nope host')).toThrowError(IceError);
|
|
69
|
+
expect(() => parseCandidate('candidate:1 1 udp 1 1.2.3.4 1 typ moon')).toThrowError(IceError);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('throws IceError on out-of-range port', () => {
|
|
73
|
+
expect(() => parseCandidate('candidate:1 1 udp 1 1.2.3.4 99999 typ host')).toThrowError(IceError);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
describe('ice.stringifyCandidate', () => {
|
|
79
|
+
it('round-trips parseCandidate output exactly', () => {
|
|
80
|
+
const line = 'candidate:1 1 udp 2122194687 1.2.3.4 50001 typ srflx raddr 10.0.0.1 rport 50000 generation 0';
|
|
81
|
+
const reser = stringifyCandidate(parseCandidate(line));
|
|
82
|
+
// generation lands in extensions; order is preserved.
|
|
83
|
+
expect(reser).toBe(line);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('throws on missing required fields', () => {
|
|
87
|
+
expect(() => stringifyCandidate({ foundation: '1' })).toThrowError(IceError);
|
|
88
|
+
expect(() => stringifyCandidate(null)).toThrowError(IceError);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
describe('ice address classifiers', () => {
|
|
94
|
+
it('isPrivateIp covers RFC 1918 / 6598 / ULA', () => {
|
|
95
|
+
expect(isPrivateIp('10.0.0.1')).toBe(true);
|
|
96
|
+
expect(isPrivateIp('172.16.0.1')).toBe(true);
|
|
97
|
+
expect(isPrivateIp('172.32.0.1')).toBe(false);
|
|
98
|
+
expect(isPrivateIp('192.168.1.1')).toBe(true);
|
|
99
|
+
expect(isPrivateIp('100.64.0.1')).toBe(true);
|
|
100
|
+
expect(isPrivateIp('1.2.3.4')).toBe(false);
|
|
101
|
+
expect(isPrivateIp('fc00::1')).toBe(true);
|
|
102
|
+
expect(isPrivateIp('2001:db8::1')).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('isLoopbackIp covers 127.0.0.0/8 and ::1', () => {
|
|
106
|
+
expect(isLoopbackIp('127.0.0.1')).toBe(true);
|
|
107
|
+
expect(isLoopbackIp('127.1.2.3')).toBe(true);
|
|
108
|
+
expect(isLoopbackIp('::1')).toBe(true);
|
|
109
|
+
expect(isLoopbackIp('1.2.3.4')).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('isLinkLocalIp covers 169.254/16 and fe80::/10', () => {
|
|
113
|
+
expect(isLinkLocalIp('169.254.1.1')).toBe(true);
|
|
114
|
+
expect(isLinkLocalIp('169.255.1.1')).toBe(false);
|
|
115
|
+
expect(isLinkLocalIp('fe80::1')).toBe(true);
|
|
116
|
+
expect(isLinkLocalIp('fec0::1')).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('isMdnsHostname is strict about hostnames vs IPs', () => {
|
|
120
|
+
expect(isMdnsHostname('abcd1234.local')).toBe(true);
|
|
121
|
+
expect(isMdnsHostname('Abcd1234.LOCAL')).toBe(true);
|
|
122
|
+
expect(isMdnsHostname('1.2.3.4')).toBe(false);
|
|
123
|
+
expect(isMdnsHostname('fe80::1')).toBe(false);
|
|
124
|
+
expect(isMdnsHostname('example.com')).toBe(false);
|
|
125
|
+
expect(isMdnsHostname(null)).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
describe('ice.filterCandidates', () => {
|
|
131
|
+
const lines = [
|
|
132
|
+
'candidate:1 1 udp 1 1.2.3.4 5000 typ host', // public
|
|
133
|
+
'candidate:2 1 udp 1 10.0.0.1 5000 typ host', // private
|
|
134
|
+
'candidate:3 1 udp 1 abc123.local 5000 typ host', // mDNS
|
|
135
|
+
'candidate:4 1 udp 1 5.6.7.8 5000 typ srflx', // srflx public
|
|
136
|
+
'candidate:5 1 tcp 1 5.6.7.8 9 typ host tcptype active', // TCP
|
|
137
|
+
'candidate:6 1 udp 1 fe80::1 5000 typ host', // IPv6 link-local
|
|
138
|
+
'garbage', // unparseable
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
it('blockMdns drops .local hostnames only', () => {
|
|
142
|
+
const out = filterCandidates(lines, { blockMdns: true });
|
|
143
|
+
expect(out.find((l) => l.includes('.local'))).toBeUndefined();
|
|
144
|
+
expect(out.find((l) => l.includes('1.2.3.4'))).toBeDefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('blockPrivate drops RFC 1918 addresses', () => {
|
|
148
|
+
const out = filterCandidates(lines, { blockPrivate: true });
|
|
149
|
+
expect(out.find((l) => l.includes('10.0.0.1'))).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('blockTcp drops TCP transports', () => {
|
|
153
|
+
const out = filterCandidates(lines, { blockTcp: true });
|
|
154
|
+
expect(out.find((l) => l.startsWith('candidate:5 '))).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('blockLinkLocal drops fe80:: addresses', () => {
|
|
158
|
+
const out = filterCandidates(lines, { blockLinkLocal: true });
|
|
159
|
+
expect(out.find((l) => l.includes('fe80::1'))).toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('allowedTypes whitelist filters out other types', () => {
|
|
163
|
+
const out = filterCandidates(lines, { allowedTypes: ['srflx'] });
|
|
164
|
+
expect(out).toHaveLength(1);
|
|
165
|
+
expect(out[0]).toContain('typ srflx');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('maxCandidates caps the result', () => {
|
|
169
|
+
const out = filterCandidates(lines, { maxCandidates: 2 });
|
|
170
|
+
expect(out).toHaveLength(2);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('predicate hook can drop individual candidates', () => {
|
|
174
|
+
const out = filterCandidates(lines, { predicate: (c) => c.priority !== 1 || c.address === '1.2.3.4' });
|
|
175
|
+
expect(out.find((l) => l.includes('1.2.3.4'))).toBeDefined();
|
|
176
|
+
expect(out.find((l) => l.includes('10.0.0.1'))).toBeUndefined();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('silently skips unparseable lines', () => {
|
|
180
|
+
const out = filterCandidates(lines);
|
|
181
|
+
expect(out.includes('garbage')).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('returns parsed objects when input is parsed objects', () => {
|
|
185
|
+
const parsed = lines.slice(0, 4).map(parseCandidate);
|
|
186
|
+
const out = filterCandidates(parsed, { blockMdns: true });
|
|
187
|
+
expect(out.every((c) => typeof c === 'object')).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('returns [] when input is not an array', () => {
|
|
191
|
+
expect(filterCandidates(null)).toEqual([]);
|
|
192
|
+
expect(filterCandidates('x')).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
describe('ice module constants', () => {
|
|
198
|
+
it('exports CANDIDATE_TYPES frozen', () => {
|
|
199
|
+
expect(CANDIDATE_TYPES).toEqual(['host', 'srflx', 'prflx', 'relay']);
|
|
200
|
+
expect(Object.isFrozen(CANDIDATE_TYPES)).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/webrtc/joinToken.test.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { decodeJoinToken, isJoinTokenExpired } from '../../src/webrtc/index.js';
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
function b64urlJson(obj) {
|
|
10
|
+
const json = JSON.stringify(obj);
|
|
11
|
+
// base64url-encode in a Node-friendly way
|
|
12
|
+
const b64 = Buffer.from(json, 'utf8').toString('base64');
|
|
13
|
+
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
describe('decodeJoinToken', () => {
|
|
18
|
+
it('decodes a JWT-like 3-segment token', () => {
|
|
19
|
+
const header = b64urlJson({ alg: 'HS256', typ: 'JWT' });
|
|
20
|
+
const payload = b64urlJson({ user: { id: 'u1', name: 'Ada' }, room: 'lobby', exp: 1_700_000_000 });
|
|
21
|
+
const token = `${header}.${payload}.signature`;
|
|
22
|
+
const d = decodeJoinToken(token);
|
|
23
|
+
expect(d.user).toEqual({ id: 'u1', name: 'Ada' });
|
|
24
|
+
expect(d.room).toBe('lobby');
|
|
25
|
+
expect(d.exp).toBe(1_700_000_000);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('decodes a 2-segment token (payload.sig)', () => {
|
|
29
|
+
const token = `${b64urlJson({ user: { id: 'u2' }, room: 'r' })}.sig`;
|
|
30
|
+
expect(decodeJoinToken(token).user).toEqual({ id: 'u2' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('decodes a 1-segment payload-only token', () => {
|
|
34
|
+
const token = b64urlJson({ user: { id: 'u3' }, room: 'r' });
|
|
35
|
+
expect(decodeJoinToken(token).user.id).toBe('u3');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('falls back to `sub` for user id', () => {
|
|
39
|
+
const token = `${b64urlJson({ sub: 'subject-1', room: 'r' })}.sig`;
|
|
40
|
+
expect(decodeJoinToken(token).user).toEqual({ id: 'subject-1' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns null user/room/exp when payload lacks them', () => {
|
|
44
|
+
const d = decodeJoinToken(b64urlJson({ foo: 1 }));
|
|
45
|
+
expect(d.user).toBeNull();
|
|
46
|
+
expect(d.room).toBeNull();
|
|
47
|
+
expect(d.exp).toBeNull();
|
|
48
|
+
expect(d.raw).toEqual({ foo: 1 });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects empty / non-string input', () => {
|
|
52
|
+
expect(() => decodeJoinToken('')).toThrow(/non-empty string/);
|
|
53
|
+
expect(() => decodeJoinToken(null)).toThrow(/non-empty string/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rejects malformed shape', () => {
|
|
57
|
+
expect(() => decodeJoinToken('a.b.c.d')).toMatchObject; // throws below
|
|
58
|
+
try { decodeJoinToken('a.b.c.d'); throw new Error('want throw'); }
|
|
59
|
+
catch (err) { expect(err.code).toBe('ZQ_WEBRTC_TOKEN_BAD_SHAPE'); }
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects bad base64url payload', () => {
|
|
63
|
+
try { decodeJoinToken('not!base64.sig'); throw new Error('want throw'); }
|
|
64
|
+
catch (err) { expect(err.code).toBe('ZQ_WEBRTC_TOKEN_BAD_PAYLOAD'); }
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
describe('isJoinTokenExpired', () => {
|
|
70
|
+
it('returns false when exp is missing', () => {
|
|
71
|
+
expect(isJoinTokenExpired({ exp: null })).toBe(false);
|
|
72
|
+
expect(isJoinTokenExpired({})).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns true when exp is in the past', () => {
|
|
76
|
+
const expSec = Math.floor(Date.now() / 1000) - 60;
|
|
77
|
+
expect(isJoinTokenExpired({ exp: expSec })).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns false when exp is in the future', () => {
|
|
81
|
+
const expSec = Math.floor(Date.now() / 1000) + 60;
|
|
82
|
+
expect(isJoinTokenExpired({ exp: expSec })).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('respects nowMs override', () => {
|
|
86
|
+
expect(isJoinTokenExpired({ exp: 1000 }, { nowMs: 2_000_000 })).toBe(true);
|
|
87
|
+
expect(isJoinTokenExpired({ exp: 1000 }, { nowMs: 0 })).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/webrtc/observe.test.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
6
|
+
import { samplePeerStats, createStatsSampler, classifyStats } from '../../src/webrtc/index.js';
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
function makeReport(entries) {
|
|
10
|
+
const map = new Map();
|
|
11
|
+
for (const e of entries) map.set(e.id || e.type, e);
|
|
12
|
+
return map;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makePc(report) {
|
|
16
|
+
return { getStats: vi.fn(async () => report) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
describe('samplePeerStats', () => {
|
|
21
|
+
it('reduces an iterable getStats report to a flat summary', async () => {
|
|
22
|
+
const pc = makePc(makeReport([
|
|
23
|
+
{ id: 'in1', type: 'inbound-rtp', bytesReceived: 1000, packetsReceived: 100, packetsLost: 2 },
|
|
24
|
+
{ id: 'out1', type: 'outbound-rtp', bytesSent: 5000 },
|
|
25
|
+
{ id: 'cp1', type: 'candidate-pair', state: 'succeeded', nominated: true, currentRoundTripTime: 0.05 },
|
|
26
|
+
]));
|
|
27
|
+
const s = await samplePeerStats(pc);
|
|
28
|
+
expect(s.inboundRtp.length).toBe(1);
|
|
29
|
+
expect(s.outboundRtp.length).toBe(1);
|
|
30
|
+
expect(s.candidatePair).not.toBeNull();
|
|
31
|
+
expect(s.summary.bytesSent).toBe(5000);
|
|
32
|
+
expect(s.summary.bytesReceived).toBe(1000);
|
|
33
|
+
expect(s.summary.rttMs).toBeCloseTo(50);
|
|
34
|
+
expect(s.summary.lossPct).toBeCloseTo((2 / 102) * 100);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('handles a plain-object report', async () => {
|
|
38
|
+
const pc = makePc({
|
|
39
|
+
in1: { type: 'inbound-rtp', bytesReceived: 10, packetsReceived: 1, packetsLost: 0 },
|
|
40
|
+
out1: { type: 'outbound-rtp', bytesSent: 20 },
|
|
41
|
+
});
|
|
42
|
+
const s = await samplePeerStats(pc);
|
|
43
|
+
expect(s.summary.bytesSent).toBe(20);
|
|
44
|
+
expect(s.summary.bytesReceived).toBe(10);
|
|
45
|
+
expect(s.candidatePair).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects a non-RTCPeerConnection', async () => {
|
|
49
|
+
await expect(samplePeerStats({})).rejects.toMatchObject({ code: 'ZQ_WEBRTC_OBSERVE_BAD_PC' });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('wraps getStats() failures', async () => {
|
|
53
|
+
const pc = { getStats: async () => { throw new Error('boom'); } };
|
|
54
|
+
await expect(samplePeerStats(pc)).rejects.toMatchObject({ code: 'ZQ_WEBRTC_OBSERVE_GETSTATS_FAILED' });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
describe('classifyStats', () => {
|
|
60
|
+
it('returns "unknown" for empty samples', () => {
|
|
61
|
+
expect(classifyStats(null)).toBe('unknown');
|
|
62
|
+
expect(classifyStats({ summary: { rttMs: null, lossPct: 0 } })).toBe('unknown');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns "good" for low rtt/loss', () => {
|
|
66
|
+
expect(classifyStats({ summary: { rttMs: 50, lossPct: 0.2 } })).toBe('good');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns "fair" for moderate rtt/loss', () => {
|
|
70
|
+
expect(classifyStats({ summary: { rttMs: 250, lossPct: 0.5 } })).toBe('fair');
|
|
71
|
+
expect(classifyStats({ summary: { rttMs: 50, lossPct: 2 } })).toBe('fair');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns "poor" for high rtt/loss', () => {
|
|
75
|
+
expect(classifyStats({ summary: { rttMs: 500, lossPct: 0 } })).toBe('poor');
|
|
76
|
+
expect(classifyStats({ summary: { rttMs: 50, lossPct: 10 } })).toBe('poor');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
describe('createStatsSampler', () => {
|
|
82
|
+
it('samples immediately and reports via onSample', async () => {
|
|
83
|
+
const pc = makePc(makeReport([
|
|
84
|
+
{ id: 'in1', type: 'inbound-rtp', bytesReceived: 1, packetsReceived: 1, packetsLost: 0 },
|
|
85
|
+
]));
|
|
86
|
+
const samples = [];
|
|
87
|
+
const sampler = createStatsSampler(pc, {
|
|
88
|
+
intervalMs: 10_000,
|
|
89
|
+
onSample: (s) => samples.push(s),
|
|
90
|
+
});
|
|
91
|
+
// Yield so the immediate getStats() resolves.
|
|
92
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
93
|
+
sampler.stop();
|
|
94
|
+
expect(samples.length).toBe(1);
|
|
95
|
+
expect(sampler.getLatest()).toBe(samples[0]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('forwards getStats() failures to onError', async () => {
|
|
99
|
+
const pc = { getStats: async () => { throw new Error('nope'); } };
|
|
100
|
+
const errs = [];
|
|
101
|
+
const sampler = createStatsSampler(pc, { intervalMs: 10_000, onError: (e) => errs.push(e) });
|
|
102
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
103
|
+
sampler.stop();
|
|
104
|
+
expect(errs.length).toBe(1);
|
|
105
|
+
expect(errs[0].code).toBe('ZQ_WEBRTC_OBSERVE_GETSTATS_FAILED');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects a non-RTCPeerConnection', () => {
|
|
109
|
+
expect(() => createStatsSampler({})).toThrow(/RTCPeerConnection required/);
|
|
110
|
+
});
|
|
111
|
+
});
|