@stream-io/video-client 1.56.0 → 1.58.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/CHANGELOG.md +23 -0
- package/dist/e2ee-worker.browser.es.js +1342 -0
- package/dist/e2ee-worker.browser.es.js.map +1 -0
- package/dist/index.browser.es.js +867 -71
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +911 -70
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.es.js +900 -71
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +52 -6
- package/dist/src/coordinator/connection/connection.d.ts +1 -1
- package/dist/src/devices/CameraManager.d.ts +6 -0
- package/dist/src/devices/DeviceManager.d.ts +1 -0
- package/dist/src/devices/MicrophoneManager.d.ts +6 -0
- package/dist/src/devices/SpeakerManager.d.ts +2 -1
- package/dist/src/events/participant.d.ts +10 -3
- package/dist/src/gen/coordinator/index.d.ts +1363 -243
- package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +4 -0
- package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
- package/dist/src/helpers/ensureExhausted.d.ts +1 -1
- package/dist/src/permissions/PermissionsContext.d.ts +2 -2
- package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
- package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
- package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
- package/dist/src/rtc/e2ee/events.d.ts +163 -0
- package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
- package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
- package/dist/src/rtc/index.d.ts +1 -0
- package/dist/src/rtc/mediaEngine.d.ts +23 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/dist/src/store/CallState.d.ts +21 -0
- package/dist/src/types.d.ts +15 -6
- package/index.ts +3 -0
- package/package.json +11 -11
- package/src/Call.ts +160 -26
- package/src/__tests__/Call.test.ts +15 -0
- package/src/coordinator/connection/connection.ts +6 -3
- package/src/devices/AudioDeviceManager.ts +6 -0
- package/src/devices/CameraManager.ts +51 -4
- package/src/devices/DeviceManager.ts +17 -0
- package/src/devices/MicrophoneManager.ts +55 -5
- package/src/devices/ScreenShareManager.ts +3 -0
- package/src/devices/SpeakerManager.ts +42 -12
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -22
- package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
- package/src/devices/__tests__/mocks.ts +36 -3
- package/src/devices/devicePersistence.ts +1 -2
- package/src/events/__tests__/participant.test.ts +134 -1
- package/src/events/callEventHandlers.ts +9 -3
- package/src/events/participant.ts +42 -7
- package/src/gen/coordinator/index.ts +1384 -244
- package/src/gen/video/sfu/models/models.ts +15 -0
- package/src/helpers/MediaPlaybackWatchdog.ts +35 -16
- package/src/helpers/TypedEventEmitter.ts +71 -0
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +137 -8
- package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +1 -2
- package/src/helpers/ensureExhausted.ts +4 -2
- package/src/permissions/PermissionsContext.ts +10 -5
- package/src/reporting/ClientEventReporter.ts +2 -8
- package/src/rtc/BasePeerConnection.ts +12 -1
- package/src/rtc/Publisher.ts +8 -0
- package/src/rtc/Subscriber.ts +5 -0
- package/src/rtc/__tests__/Publisher.test.ts +101 -0
- package/src/rtc/__tests__/Subscriber.test.ts +89 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
- package/src/rtc/e2ee/E2EEManager.ts +12 -0
- package/src/rtc/e2ee/EncryptionManager.ts +336 -0
- package/src/rtc/e2ee/SPEC.md +586 -0
- package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
- package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
- package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
- package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
- package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
- package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
- package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
- package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
- package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
- package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
- package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
- package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
- package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
- package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
- package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
- package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
- package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
- package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
- package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
- package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
- package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
- package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
- package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
- package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
- package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
- package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
- package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
- package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
- package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
- package/src/rtc/e2ee/e2ee-worker.ts +27 -0
- package/src/rtc/e2ee/events.ts +179 -0
- package/src/rtc/e2ee/transformSupport.ts +35 -0
- package/src/rtc/helpers/rtcConfiguration.ts +4 -2
- package/src/rtc/index.ts +1 -0
- package/src/rtc/mediaEngine.ts +44 -0
- package/src/rtc/types.ts +3 -2
- package/src/stats/rtc/types.ts +8 -2
- package/src/store/CallState.ts +34 -3
- package/src/store/__tests__/CallState.test.ts +82 -1
- package/src/types.ts +24 -9
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { IV_PREFIX_LEN } from '../e2ee-worker/constants';
|
|
3
|
+
|
|
4
|
+
// A failed import is reported through notifications.ts, which posts to the
|
|
5
|
+
// host. Stub it so tests run in the default Node environment.
|
|
6
|
+
const postMessage = vi.fn();
|
|
7
|
+
vi.stubGlobal('self', { postMessage });
|
|
8
|
+
|
|
9
|
+
import { keyStore } from '../e2ee-worker/keyStore';
|
|
10
|
+
|
|
11
|
+
const rawKey = (seed = 0xab): ArrayBuffer => {
|
|
12
|
+
const buf = new ArrayBuffer(16);
|
|
13
|
+
const bytes = new Uint8Array(buf);
|
|
14
|
+
for (let i = 0; i < 16; i++) bytes[i] = (seed + i) & 0xff;
|
|
15
|
+
return buf;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
keyStore.clear();
|
|
20
|
+
postMessage.mockClear();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('importKey', () => {
|
|
24
|
+
it('stores the key and generates a random 8-byte IV prefix', async () => {
|
|
25
|
+
await keyStore.importKey('alice', 1, rawKey());
|
|
26
|
+
expect(keyStore.getKey('alice', 1)).toBeDefined();
|
|
27
|
+
|
|
28
|
+
const prefix = keyStore.getLatestKey('alice')?.ivPrefix;
|
|
29
|
+
expect(prefix).toBeDefined();
|
|
30
|
+
expect(prefix!.length).toBe(IV_PREFIX_LEN);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('generates a fresh prefix on each import (even for the same raw key)', async () => {
|
|
34
|
+
await keyStore.importKey('alice', 1, rawKey(0x01));
|
|
35
|
+
const p1 = Array.from(keyStore.getLatestKey('alice')!.ivPrefix);
|
|
36
|
+
|
|
37
|
+
await keyStore.importKey('alice', 1, rawKey(0x01));
|
|
38
|
+
const p2 = Array.from(keyStore.getLatestKey('alice')!.ivPrefix);
|
|
39
|
+
|
|
40
|
+
// 64 bits of randomness — practically impossible for two draws to collide.
|
|
41
|
+
expect(p2).not.toEqual(p1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('getLatestKey returns the most recently imported key', async () => {
|
|
45
|
+
await keyStore.importKey('alice', 1, rawKey(0x01));
|
|
46
|
+
await keyStore.importKey('alice', 5, rawKey(0x02));
|
|
47
|
+
const latest = keyStore.getLatestKey('alice');
|
|
48
|
+
expect(latest!.keyIndex).toBe(5);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('falls back to the shared key when no per-user key is registered', async () => {
|
|
52
|
+
await keyStore.importSharedKey(3, rawKey(0x55));
|
|
53
|
+
const latest = keyStore.getLatestKey('bob');
|
|
54
|
+
expect(latest!.keyIndex).toBe(3);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('accepts 32-byte raw material (AES-256-GCM)', async () => {
|
|
58
|
+
const rawKey32 = new ArrayBuffer(32);
|
|
59
|
+
new Uint8Array(rawKey32).fill(0x42);
|
|
60
|
+
await keyStore.importKey('alice', 1, rawKey32);
|
|
61
|
+
expect(keyStore.getKey('alice', 1)).toBeDefined();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('dumpKeyState', () => {
|
|
66
|
+
it('returns fingerprints (not raw key material)', async () => {
|
|
67
|
+
await keyStore.importKey('alice', 1, rawKey(0x01));
|
|
68
|
+
await keyStore.importSharedKey(0, rawKey(0x02));
|
|
69
|
+
|
|
70
|
+
const dump = keyStore.keyState();
|
|
71
|
+
expect(dump.perUserKeys).toHaveLength(1);
|
|
72
|
+
expect(dump.perUserKeys[0]).toMatchObject({
|
|
73
|
+
userId: 'alice',
|
|
74
|
+
keyIndex: 1,
|
|
75
|
+
});
|
|
76
|
+
// Fingerprint is 8 bytes = 16 hex chars.
|
|
77
|
+
expect(dump.perUserKeys[0].fingerprint).toMatch(/^[0-9a-f]{16}$/);
|
|
78
|
+
expect(dump.sharedKeys[0].fingerprint).toMatch(/^[0-9a-f]{16}$/);
|
|
79
|
+
expect(dump.sharedKeys).toEqual([
|
|
80
|
+
{
|
|
81
|
+
keyIndex: 0,
|
|
82
|
+
fingerprint: dump.sharedKeys[0].fingerprint,
|
|
83
|
+
isActive: true,
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('identifies key material: same key same print, different key different', async () => {
|
|
89
|
+
// What makes the dump useful: two peers can compare prints to confirm they
|
|
90
|
+
// hold the same key, under any user id or key index.
|
|
91
|
+
await keyStore.importKey('alice', 1, rawKey(0xaa));
|
|
92
|
+
const alice = keyStore.keyState().perUserKeys[0].fingerprint;
|
|
93
|
+
|
|
94
|
+
keyStore.clear();
|
|
95
|
+
await keyStore.importKey('bob', 99, rawKey(0xaa));
|
|
96
|
+
await keyStore.importKey('bob', 100, rawKey(0x02));
|
|
97
|
+
const [same, different] = keyStore.keyState().perUserKeys;
|
|
98
|
+
|
|
99
|
+
expect(same.fingerprint).toBe(alice);
|
|
100
|
+
expect(different.fingerprint).not.toBe(alice);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('shared-key rotation', () => {
|
|
105
|
+
it('retains old epochs for decryption and encrypts with the newest', async () => {
|
|
106
|
+
await keyStore.importSharedKey(1, rawKey(0x11));
|
|
107
|
+
const oldKey = keyStore.getKey('alice', 1);
|
|
108
|
+
|
|
109
|
+
await keyStore.importSharedKey(2, rawKey(0x22));
|
|
110
|
+
|
|
111
|
+
expect(keyStore.getKey('alice', 1)).toBe(oldKey);
|
|
112
|
+
expect(keyStore.getKey('alice', 2)).toBeDefined();
|
|
113
|
+
expect(keyStore.getLatestKey('alice')?.keyIndex).toBe(2);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('keeps the active epoch when importing its replacement fails', async () => {
|
|
117
|
+
await keyStore.importSharedKey(1, rawKey(0x11));
|
|
118
|
+
const active = keyStore.getLatestKey('alice');
|
|
119
|
+
|
|
120
|
+
await keyStore.importSharedKey(2, new ArrayBuffer(7));
|
|
121
|
+
|
|
122
|
+
const stillActive = keyStore.getLatestKey('alice');
|
|
123
|
+
expect(stillActive?.keyIndex).toBe(1);
|
|
124
|
+
expect(stillActive?.key).toBe(active?.key);
|
|
125
|
+
expect(stillActive?.ivPrefix).toEqual(active?.ivPrefix);
|
|
126
|
+
expect(keyStore.getKey('alice', 2)).toBeUndefined();
|
|
127
|
+
expect(postMessage).toHaveBeenCalledWith(
|
|
128
|
+
expect.objectContaining({
|
|
129
|
+
type: 'e2ee.error',
|
|
130
|
+
message: expect.stringContaining('Failed to import shared key'),
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('removes only the requested inactive epoch', async () => {
|
|
136
|
+
await keyStore.importSharedKey(1, rawKey(0x11));
|
|
137
|
+
await keyStore.importSharedKey(2, rawKey(0x22));
|
|
138
|
+
|
|
139
|
+
keyStore.removeSharedKey(1);
|
|
140
|
+
|
|
141
|
+
expect(keyStore.getKey('alice', 1)).toBeUndefined();
|
|
142
|
+
expect(keyStore.getKey('alice', 2)).toBeDefined();
|
|
143
|
+
expect(keyStore.getLatestKey('alice')?.keyIndex).toBe(2);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('does not reactivate an old epoch when the active one is removed', async () => {
|
|
147
|
+
await keyStore.importSharedKey(1, rawKey(0x11));
|
|
148
|
+
await keyStore.importSharedKey(2, rawKey(0x22));
|
|
149
|
+
|
|
150
|
+
keyStore.removeSharedKey(2);
|
|
151
|
+
|
|
152
|
+
// Epoch 1 remains available to decrypt delayed frames, but silently
|
|
153
|
+
// resuming encryption with it would undo the caller's rotation policy.
|
|
154
|
+
expect(keyStore.getKey('alice', 1)).toBeDefined();
|
|
155
|
+
expect(keyStore.getKey('alice', 2)).toBeUndefined();
|
|
156
|
+
expect(keyStore.getLatestKey('alice')).toBeNull();
|
|
157
|
+
expect(keyStore.keyState()).toMatchObject({
|
|
158
|
+
sharedKeys: [{ keyIndex: 1, isActive: false }],
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('removeKey', () => {
|
|
164
|
+
it('retires one epoch and leaves the rest decryptable', async () => {
|
|
165
|
+
await keyStore.importKey('alice', 1, rawKey(0x01));
|
|
166
|
+
await keyStore.importKey('alice', 2, rawKey(0x02));
|
|
167
|
+
|
|
168
|
+
keyStore.removeKey('alice', 1);
|
|
169
|
+
|
|
170
|
+
expect(keyStore.getKey('alice', 1)).toBeUndefined();
|
|
171
|
+
expect(keyStore.getKey('alice', 2)).toBeDefined();
|
|
172
|
+
// Epoch 2 is still the latest, so encoding is untouched.
|
|
173
|
+
expect(keyStore.getLatestKey('alice')).toMatchObject({ keyIndex: 2 });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('clears the latest pointer when it removes the latest epoch', async () => {
|
|
177
|
+
await keyStore.importKey('alice', 1, rawKey(0x01));
|
|
178
|
+
await keyStore.importKey('alice', 2, rawKey(0x02));
|
|
179
|
+
await keyStore.importSharedKey(9, rawKey(0x09));
|
|
180
|
+
|
|
181
|
+
keyStore.removeKey('alice', 2);
|
|
182
|
+
|
|
183
|
+
// Leaving the pointer on the removed index would resolve nothing here and
|
|
184
|
+
// silently fall through to the shared epoch below, widening the key scope
|
|
185
|
+
// of every outgoing frame. Epoch 1 must not be promoted either.
|
|
186
|
+
expect(keyStore.getKey('alice', 1)).toBeDefined();
|
|
187
|
+
expect(keyStore.getLatestKey('alice')).toMatchObject({ keyIndex: 9 });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('leaves no per-user key at all when the last epoch goes', async () => {
|
|
191
|
+
await keyStore.importKey('alice', 1, rawKey(0x01));
|
|
192
|
+
|
|
193
|
+
keyStore.removeKey('alice', 1);
|
|
194
|
+
|
|
195
|
+
expect(keyStore.getLatestKey('alice')).toBeNull();
|
|
196
|
+
expect(keyStore.keyState().perUserKeys).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('is a no-op for an epoch or user it does not hold', async () => {
|
|
200
|
+
await keyStore.importKey('alice', 1, rawKey(0x01));
|
|
201
|
+
|
|
202
|
+
keyStore.removeKey('alice', 7);
|
|
203
|
+
keyStore.removeKey('nobody', 1);
|
|
204
|
+
|
|
205
|
+
expect(keyStore.getKey('alice', 1)).toBeDefined();
|
|
206
|
+
expect(keyStore.getLatestKey('alice')).toMatchObject({ keyIndex: 1 });
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('removeAllKeys', () => {
|
|
211
|
+
it('deletes that user key state and leaves the others', async () => {
|
|
212
|
+
await keyStore.importKey('alice', 1, rawKey(0x01));
|
|
213
|
+
await keyStore.importKey('bob', 1, rawKey(0x02));
|
|
214
|
+
|
|
215
|
+
keyStore.removeAllKeys('alice');
|
|
216
|
+
|
|
217
|
+
expect(keyStore.getKey('alice', 1)).toBeUndefined();
|
|
218
|
+
expect(keyStore.getLatestKey('alice')).toBeNull();
|
|
219
|
+
expect(keyStore.getKey('bob', 1)).toBeDefined();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The SPEC section 10 delivery rules, tested directly rather than through the
|
|
5
|
+
* transforms: throttling needs controllable time, and the pipeline tests cannot
|
|
6
|
+
* assert "and nothing more was delivered for another second" without it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const postMessage = vi.fn();
|
|
10
|
+
vi.stubGlobal('self', { postMessage });
|
|
11
|
+
|
|
12
|
+
const { DecodeNotifier, EncodeNotifier, notifyMissingEncodeKey } =
|
|
13
|
+
await import('../e2ee-worker/notifications');
|
|
14
|
+
|
|
15
|
+
const types = () => postMessage.mock.calls.map(([m]) => m.type);
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.useFakeTimers();
|
|
19
|
+
// A non-zero epoch: the throttle compares against a 0 default for an unseen
|
|
20
|
+
// key, so starting at time 0 would suppress the very first notification.
|
|
21
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
|
22
|
+
postMessage.mockClear();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.useRealTimers();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('EncodeNotifier', () => {
|
|
30
|
+
it('latches: one signal per failure run, and time alone does not re-arm it', () => {
|
|
31
|
+
// Latching is not throttling: a permanently dead track reports once, no
|
|
32
|
+
// matter how long it keeps failing.
|
|
33
|
+
const notify = new EncodeNotifier('alice', 'VIDEO');
|
|
34
|
+
notify.failed('first');
|
|
35
|
+
notify.failed('second');
|
|
36
|
+
vi.advanceTimersByTime(60_000);
|
|
37
|
+
notify.failed('still dead');
|
|
38
|
+
|
|
39
|
+
expect(postMessage).toHaveBeenCalledTimes(1);
|
|
40
|
+
expect(postMessage).toHaveBeenCalledWith({
|
|
41
|
+
type: 'e2ee.encryption_failed',
|
|
42
|
+
userId: 'alice',
|
|
43
|
+
trackType: 'VIDEO',
|
|
44
|
+
reason: 'first',
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('re-arms on recovery so a later permanent failure is not hidden', () => {
|
|
49
|
+
const notify = new EncodeNotifier('alice', 'VIDEO');
|
|
50
|
+
notify.failed('transient');
|
|
51
|
+
notify.recovered();
|
|
52
|
+
notify.failed('counter exhausted');
|
|
53
|
+
expect(types()).toEqual([
|
|
54
|
+
'e2ee.encryption_failed',
|
|
55
|
+
'e2ee.encryption_failed',
|
|
56
|
+
]);
|
|
57
|
+
expect(postMessage.mock.calls[1][0].reason).toBe('counter exhausted');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('DecodeNotifier throttling', () => {
|
|
62
|
+
// Levels: they describe a condition that persists, so one per second is
|
|
63
|
+
// enough - the next frame re-raises the same condition.
|
|
64
|
+
it.each([
|
|
65
|
+
['decryption_failed', (n: DecodeNotifier) => n.failed()],
|
|
66
|
+
['unencrypted_frame', (n: DecodeNotifier) => n.unencrypted()],
|
|
67
|
+
['unsupported_version', (n: DecodeNotifier) => n.unsupportedVersion(2)],
|
|
68
|
+
])('delivers at most one %s per second', (type, raise) => {
|
|
69
|
+
const notify = new DecodeNotifier('bob', 'VIDEO');
|
|
70
|
+
raise(notify);
|
|
71
|
+
raise(notify);
|
|
72
|
+
raise(notify);
|
|
73
|
+
expect(types()).toEqual([`e2ee.${type}`]);
|
|
74
|
+
|
|
75
|
+
vi.advanceTimersByTime(1001);
|
|
76
|
+
raise(notify);
|
|
77
|
+
expect(types()).toEqual([`e2ee.${type}`, `e2ee.${type}`]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// The version byte is plaintext, so a relay can rewrite it per frame. Keying
|
|
81
|
+
// the throttle by version would hand it one event per distinct value, 255x
|
|
82
|
+
// the intended rate, for free.
|
|
83
|
+
it('throttles unsupported_version per track, not per version', () => {
|
|
84
|
+
const notify = new DecodeNotifier('bob', 'VIDEO');
|
|
85
|
+
notify.unsupportedVersion(2);
|
|
86
|
+
notify.unsupportedVersion(3);
|
|
87
|
+
notify.unsupportedVersion(99);
|
|
88
|
+
expect(types()).toEqual(['e2ee.unsupported_version']);
|
|
89
|
+
expect(postMessage.mock.calls[0][0].version).toBe(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('throttles missing_key per keyIndex, so a rotation still reports', () => {
|
|
93
|
+
const notify = new DecodeNotifier('bob', 'VIDEO');
|
|
94
|
+
notify.missingKey(1);
|
|
95
|
+
notify.missingKey(1);
|
|
96
|
+
// A different key epoch is a different condition and must not be
|
|
97
|
+
// suppressed by the first one's window.
|
|
98
|
+
notify.missingKey(2);
|
|
99
|
+
expect(postMessage.mock.calls.map(([m]) => m.keyIndex)).toEqual([1, 2]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('does not throttle decryption_stalled: it is already once per failure run', () => {
|
|
103
|
+
const notify = new DecodeNotifier('bob', 'VIDEO');
|
|
104
|
+
notify.stalled(0);
|
|
105
|
+
notify.stalled(1);
|
|
106
|
+
expect(types()).toEqual([
|
|
107
|
+
'e2ee.decryption_stalled',
|
|
108
|
+
'e2ee.decryption_stalled',
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('scopes throttles per notifier, so one track cannot mute another', () => {
|
|
113
|
+
const video = new DecodeNotifier('bob', 'VIDEO');
|
|
114
|
+
const audio = new DecodeNotifier('bob', 'AUDIO');
|
|
115
|
+
video.failed();
|
|
116
|
+
audio.failed();
|
|
117
|
+
expect(postMessage.mock.calls.map(([m]) => m.trackType)).toEqual([
|
|
118
|
+
'VIDEO',
|
|
119
|
+
'AUDIO',
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('DecodeNotifier failure/recovery pairing', () => {
|
|
125
|
+
it('emits exactly one recovery per delivered failure, unthrottled', () => {
|
|
126
|
+
const notify = new DecodeNotifier('bob', 'VIDEO');
|
|
127
|
+
notify.resumed(); // nothing was reported yet, so nothing to clear
|
|
128
|
+
expect(postMessage).not.toHaveBeenCalled();
|
|
129
|
+
|
|
130
|
+
notify.failed();
|
|
131
|
+
notify.resumed(); // inside the failure's own throttle window, still fires:
|
|
132
|
+
notify.resumed(); // an edge must never be delayed or dropped...
|
|
133
|
+
notify.resumed(); // ...but it also does not repeat.
|
|
134
|
+
expect(types()).toEqual([
|
|
135
|
+
'e2ee.decryption_failed',
|
|
136
|
+
'e2ee.decryption_resumed',
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('does not emit a recovery for a failure the throttle swallowed', () => {
|
|
141
|
+
const notify = new DecodeNotifier('bob', 'VIDEO');
|
|
142
|
+
notify.failed(); // delivered
|
|
143
|
+
notify.resumed(); // pairs with it
|
|
144
|
+
postMessage.mockClear();
|
|
145
|
+
notify.failed(); // suppressed: still inside the window
|
|
146
|
+
notify.resumed(); // the host never heard the failure, so nothing to clear
|
|
147
|
+
expect(postMessage).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('notifyMissingEncodeKey', () => {
|
|
152
|
+
it('throttles per user and carries no trackType', () => {
|
|
153
|
+
// The local encoder holding no key stalls every outgoing track at once, so
|
|
154
|
+
// this is reported once for the user rather than per track.
|
|
155
|
+
notifyMissingEncodeKey('alice');
|
|
156
|
+
notifyMissingEncodeKey('alice');
|
|
157
|
+
expect(postMessage).toHaveBeenCalledTimes(1);
|
|
158
|
+
expect(postMessage).toHaveBeenCalledWith({
|
|
159
|
+
type: 'e2ee.missing_key',
|
|
160
|
+
userId: 'alice',
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
notifyMissingEncodeKey('carol');
|
|
164
|
+
expect(postMessage).toHaveBeenCalledTimes(2);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const postMessage = vi.fn();
|
|
4
|
+
vi.stubGlobal('self', { postMessage });
|
|
5
|
+
|
|
6
|
+
const { decodeStats, encodeStats, startPerfReport, stopPerfReport } =
|
|
7
|
+
await import('../e2ee-worker/perf');
|
|
8
|
+
|
|
9
|
+
const encodeTrack = (trackType: string, userId = 'alice') =>
|
|
10
|
+
encodeStats.track(trackType, { userId, trackType, codec: 'vp8' });
|
|
11
|
+
const decodeTrack = (userId: string, trackType = 'VIDEO') =>
|
|
12
|
+
decodeStats.track(`${userId}/${trackType}`, { userId, trackType });
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.useFakeTimers();
|
|
16
|
+
postMessage.mockClear();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
stopPerfReport();
|
|
21
|
+
vi.useRealTimers();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('while reporting is off', () => {
|
|
25
|
+
it('records nothing, so the transforms need no guard of their own', () => {
|
|
26
|
+
const track = encodeTrack('VIDEO');
|
|
27
|
+
track.bump();
|
|
28
|
+
track.endCrypto(track.startCrypto());
|
|
29
|
+
expect(encodeStats.flush(1)).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('counting', () => {
|
|
34
|
+
beforeEach(() => startPerfReport());
|
|
35
|
+
|
|
36
|
+
it('reports each track separately, as a rate over the elapsed window', () => {
|
|
37
|
+
// Two tracks on one sender: a vp8 camera and a vp8 screen share must be
|
|
38
|
+
// reported apart rather than summed.
|
|
39
|
+
const camera = encodeTrack('VIDEO');
|
|
40
|
+
camera.bump();
|
|
41
|
+
camera.bump();
|
|
42
|
+
camera.bump();
|
|
43
|
+
encodeTrack('SCREEN_SHARE').bump();
|
|
44
|
+
|
|
45
|
+
expect(encodeStats.flush(1.5)).toEqual([
|
|
46
|
+
{
|
|
47
|
+
userId: 'alice',
|
|
48
|
+
trackType: 'VIDEO',
|
|
49
|
+
codec: 'vp8',
|
|
50
|
+
fps: 2,
|
|
51
|
+
maxCryptoMs: 0,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
userId: 'alice',
|
|
55
|
+
trackType: 'SCREEN_SHARE',
|
|
56
|
+
codec: 'vp8',
|
|
57
|
+
fps: 1 / 1.5,
|
|
58
|
+
maxCryptoMs: 0,
|
|
59
|
+
},
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('drops an idle track from the next report instead of reporting 0 fps', () => {
|
|
64
|
+
// The accumulator is created on demand and cleared by flush, so a track
|
|
65
|
+
// that stops delivering disappears rather than reporting zeroes forever.
|
|
66
|
+
const track = encodeTrack('VIDEO');
|
|
67
|
+
track.bump();
|
|
68
|
+
expect(encodeStats.flush(1)).toHaveLength(1);
|
|
69
|
+
expect(encodeStats.flush(1)).toHaveLength(0);
|
|
70
|
+
// ...and it comes back as soon as it delivers again.
|
|
71
|
+
track.bump();
|
|
72
|
+
expect(encodeStats.flush(1)).toHaveLength(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('takes the worst crypto time seen in the window', () => {
|
|
76
|
+
const track = encodeTrack('VIDEO');
|
|
77
|
+
const slow = track.startCrypto();
|
|
78
|
+
vi.advanceTimersByTime(7);
|
|
79
|
+
track.endCrypto(slow);
|
|
80
|
+
const fast = track.startCrypto();
|
|
81
|
+
vi.advanceTimersByTime(2);
|
|
82
|
+
track.endCrypto(fast);
|
|
83
|
+
expect(encodeStats.flush(1)[0].maxCryptoMs).toBe(7);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('labels decode samples without a codec', () => {
|
|
87
|
+
// A remote sender's codec is not reliably known locally, so decode rows
|
|
88
|
+
// carry only (userId, trackType).
|
|
89
|
+
decodeTrack('bob').bump();
|
|
90
|
+
expect(decodeStats.flush(1)).toEqual([
|
|
91
|
+
{ userId: 'bob', trackType: 'VIDEO', fps: 1, maxCryptoMs: 0 },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('removeUser drops that user rows and leaves the others', () => {
|
|
96
|
+
decodeTrack('bob').bump();
|
|
97
|
+
decodeTrack('carol').bump();
|
|
98
|
+
decodeStats.removeUser('bob');
|
|
99
|
+
expect(decodeStats.flush(1).map((s) => s.userId)).toEqual(['carol']);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('the reporting interval', () => {
|
|
104
|
+
it('posts one report per second with both directions', () => {
|
|
105
|
+
startPerfReport();
|
|
106
|
+
encodeTrack('VIDEO').bump();
|
|
107
|
+
decodeTrack('bob').bump();
|
|
108
|
+
|
|
109
|
+
vi.advanceTimersByTime(1000);
|
|
110
|
+
|
|
111
|
+
expect(postMessage).toHaveBeenCalledTimes(1);
|
|
112
|
+
const report = postMessage.mock.calls[0][0];
|
|
113
|
+
expect(report.type).toBe('e2ee.perf_report');
|
|
114
|
+
expect(report.encode).toHaveLength(1);
|
|
115
|
+
expect(report.decode).toHaveLength(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('is idempotent: a second start does not add a second interval', () => {
|
|
119
|
+
startPerfReport();
|
|
120
|
+
startPerfReport();
|
|
121
|
+
startPerfReport();
|
|
122
|
+
encodeTrack('VIDEO').bump();
|
|
123
|
+
|
|
124
|
+
vi.advanceTimersByTime(1000);
|
|
125
|
+
|
|
126
|
+
// A leaked interval would post the same window several times over.
|
|
127
|
+
expect(postMessage).toHaveBeenCalledTimes(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('stops the interval and clears the counters on stop', () => {
|
|
131
|
+
startPerfReport();
|
|
132
|
+
encodeTrack('VIDEO').bump();
|
|
133
|
+
stopPerfReport();
|
|
134
|
+
|
|
135
|
+
expect(encodeStats.flush(1)).toEqual([]);
|
|
136
|
+
vi.advanceTimersByTime(5000);
|
|
137
|
+
expect(postMessage).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { enqueue } from '../e2ee-worker/queue';
|
|
3
|
+
|
|
4
|
+
describe('enqueue', () => {
|
|
5
|
+
it('carries the task outcome back to its own caller', async () => {
|
|
6
|
+
await expect(enqueue(async () => 42)).resolves.toBe(42);
|
|
7
|
+
await expect(
|
|
8
|
+
enqueue(async () => {
|
|
9
|
+
throw new Error('boom');
|
|
10
|
+
}),
|
|
11
|
+
).rejects.toThrow('boom');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('preserves task ordering', async () => {
|
|
15
|
+
const order: number[] = [];
|
|
16
|
+
const promises = [];
|
|
17
|
+
for (let i = 0; i < 5; i++) {
|
|
18
|
+
promises.push(
|
|
19
|
+
enqueue(async () => {
|
|
20
|
+
await Promise.resolve();
|
|
21
|
+
order.push(i);
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
await Promise.all(promises);
|
|
26
|
+
expect(order).toEqual([0, 1, 2, 3, 4]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('runs tasks serially, never overlapping a previous task still in flight', async () => {
|
|
30
|
+
// Serialization, not just emission order: each task body yields several
|
|
31
|
+
// microtasks while "active". If two ran concurrently, active would exceed 1.
|
|
32
|
+
let active = 0;
|
|
33
|
+
let maxActive = 0;
|
|
34
|
+
const task = () =>
|
|
35
|
+
enqueue(async () => {
|
|
36
|
+
active++;
|
|
37
|
+
maxActive = Math.max(maxActive, active);
|
|
38
|
+
await Promise.resolve();
|
|
39
|
+
await Promise.resolve();
|
|
40
|
+
active--;
|
|
41
|
+
});
|
|
42
|
+
await Promise.all([task(), task(), task()]);
|
|
43
|
+
expect(maxActive).toBe(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('continues running later tasks after one rejects', async () => {
|
|
47
|
+
const seen: string[] = [];
|
|
48
|
+
const ok1 = enqueue(async () => {
|
|
49
|
+
seen.push('a');
|
|
50
|
+
});
|
|
51
|
+
const bad = enqueue(async () => {
|
|
52
|
+
seen.push('b');
|
|
53
|
+
throw new Error('fail');
|
|
54
|
+
});
|
|
55
|
+
const ok2 = enqueue(async () => {
|
|
56
|
+
seen.push('c');
|
|
57
|
+
});
|
|
58
|
+
await Promise.all([ok1, bad.catch(() => {}), ok2]);
|
|
59
|
+
expect(seen).toEqual(['a', 'b', 'c']);
|
|
60
|
+
});
|
|
61
|
+
});
|