@gjsify/webrtc 0.4.0 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +73 -70
  2. package/src/get-user-media.ts +0 -131
  3. package/src/gst-enum-maps.ts +0 -125
  4. package/src/gst-init.ts +0 -49
  5. package/src/gst-stats-parser.ts +0 -137
  6. package/src/gst-utils.ts +0 -41
  7. package/src/index.ts +0 -104
  8. package/src/internal/gst-types.ts +0 -122
  9. package/src/media-device-info.ts +0 -33
  10. package/src/media-devices.ts +0 -191
  11. package/src/media-stream-track.ts +0 -159
  12. package/src/media-stream.ts +0 -96
  13. package/src/register/data-channel.ts +0 -11
  14. package/src/register/error.ts +0 -11
  15. package/src/register/media-devices.ts +0 -10
  16. package/src/register/media.ts +0 -15
  17. package/src/register/peer-connection.ts +0 -20
  18. package/src/register.spec.ts +0 -55
  19. package/src/register.ts +0 -10
  20. package/src/rtc-certificate.ts +0 -110
  21. package/src/rtc-data-channel.ts +0 -283
  22. package/src/rtc-dtls-transport.ts +0 -48
  23. package/src/rtc-dtmf-sender.ts +0 -146
  24. package/src/rtc-error.ts +0 -49
  25. package/src/rtc-events.ts +0 -64
  26. package/src/rtc-ice-candidate.ts +0 -115
  27. package/src/rtc-ice-transport.ts +0 -104
  28. package/src/rtc-peer-connection.ts +0 -1039
  29. package/src/rtc-rtp-receiver.ts +0 -122
  30. package/src/rtc-rtp-sender.ts +0 -471
  31. package/src/rtc-rtp-transceiver.ts +0 -131
  32. package/src/rtc-sctp-transport.ts +0 -48
  33. package/src/rtc-session-description.ts +0 -64
  34. package/src/rtc-stats-report.ts +0 -39
  35. package/src/rtc-track-event.ts +0 -45
  36. package/src/rtp-capabilities.ts +0 -48
  37. package/src/tee-multiplexer.ts +0 -75
  38. package/src/test.mts +0 -11
  39. package/src/webrtc.spec.ts +0 -1186
  40. package/src/wpt-helpers.ts +0 -156
  41. package/src/wpt-media.spec.ts +0 -1154
  42. package/src/wpt.spec.ts +0 -1136
  43. package/tsconfig.json +0 -36
  44. package/tsconfig.tsbuildinfo +0 -1
@@ -1,1186 +0,0 @@
1
- // WebRTC API tests — GJS only (requires GStreamer + webrtcbin).
2
- //
3
- // The loopback test is the primary smoke test: two local peers exchange
4
- // offer/answer + ICE candidates in a single process and open a data channel.
5
-
6
- import Gst from 'gi://Gst?version=1.0';
7
- import { describe, it, expect } from '@gjsify/unit';
8
-
9
- import {
10
- RTCPeerConnection,
11
- RTCSessionDescription,
12
- RTCIceCandidate,
13
- RTCDataChannel,
14
- RTCDtlsTransport,
15
- RTCIceTransport,
16
- RTCSctpTransport,
17
- RTCDTMFSender,
18
- RTCDTMFToneChangeEvent,
19
- RTCCertificate,
20
- MediaStreamTrack,
21
- MediaDevices,
22
- getUserMedia,
23
- } from './index.js';
24
-
25
- // Tests that exercise webrtcbin require libnice's GStreamer plugin on the
26
- // host system. If missing, skip those suites with a clear message instead
27
- // of failing — the RTCPeerConnection constructor already throws the full
28
- // install hint via ensureWebrtcbinAvailable().
29
- Gst.init(null);
30
- const webrtcbinReady = Boolean(
31
- Gst.ElementFactory.find('webrtcbin') && Gst.ElementFactory.find('nicesrc'),
32
- );
33
- if (!webrtcbinReady) {
34
- // eslint-disable-next-line no-console
35
- console.log(
36
- ' ⚠ webrtcbin/nicesrc not installed — skipping pipeline tests.\n' +
37
- ' Install: dnf install libnice-gstreamer1 (Fedora) | apt install gstreamer1.0-nice (Ubuntu)',
38
- );
39
- }
40
-
41
- // Phase 1.5 landed: the native @gjsify/webrtc-native bridge marshals
42
- // webrtcbin signals + Gst.Promise callbacks onto the main thread, so the
43
- // async handshake now works on GJS too.
44
- const ASYNC_SIGNALS_WORK = true;
45
-
46
- function waitFor<T = void>(ms: number, promise: Promise<T>, label: string): Promise<T> {
47
- return Promise.race([
48
- promise,
49
- new Promise<T>((_, reject) =>
50
- setTimeout(() => reject(new Error(`Timeout waiting for ${label} (${ms}ms)`)), ms),
51
- ),
52
- ]);
53
- }
54
-
55
- function awaitEvent(target: EventTarget, type: string, timeoutMs = 10000): Promise<Event> {
56
- return waitFor(timeoutMs, new Promise<Event>((resolve) => {
57
- const handler = (ev: Event) => {
58
- target.removeEventListener(type, handler);
59
- resolve(ev);
60
- };
61
- target.addEventListener(type, handler);
62
- }), `${type} event`);
63
- }
64
-
65
- export default async () => {
66
- await describe('@gjsify/webrtc', async () => {
67
-
68
- await describe('RTCPeerConnection construction', async () => {
69
- if (!webrtcbinReady) {
70
- await it('(skipped — webrtcbin/nicesrc missing)', async () => {
71
- expect(webrtcbinReady).toBeFalsy();
72
- });
73
- } else {
74
- await it('constructs without configuration', async () => {
75
- const pc = new RTCPeerConnection();
76
- expect(pc).toBeDefined();
77
- expect(pc.signalingState).toBe('stable');
78
- pc.close();
79
- });
80
-
81
- await it('accepts a STUN server', async () => {
82
- const pc = new RTCPeerConnection({
83
- iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
84
- });
85
- expect(pc).toBeDefined();
86
- pc.close();
87
- });
88
-
89
- await it('rejects an empty urls array', async () => {
90
- expect(() => {
91
- new RTCPeerConnection({ iceServers: [{ urls: [] }] });
92
- }).toThrow();
93
- });
94
- }
95
- });
96
-
97
- await describe('RTCSessionDescription', async () => {
98
- await it('holds type and sdp', async () => {
99
- const desc = new RTCSessionDescription({ type: 'offer', sdp: 'v=0\r\n' });
100
- expect(desc.type).toBe('offer');
101
- expect(desc.sdp).toBe('v=0\r\n');
102
- });
103
-
104
- await it('survives a Gst round-trip for a minimal SDP', async () => {
105
- const sdp = [
106
- 'v=0',
107
- 'o=- 7614219274584779017 2 IN IP4 127.0.0.1',
108
- 's=-',
109
- 't=0 0',
110
- 'a=group:BUNDLE 0',
111
- 'a=msid-semantic: WMS',
112
- 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
113
- 'c=IN IP4 0.0.0.0',
114
- 'a=ice-ufrag:abcd',
115
- 'a=ice-pwd:1234567890123456789012',
116
- 'a=fingerprint:sha-256 00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff',
117
- 'a=setup:actpass',
118
- 'a=mid:0',
119
- 'a=sctp-port:5000',
120
- '',
121
- ].join('\r\n');
122
- const original = new RTCSessionDescription({ type: 'offer', sdp });
123
- const gst = original.toGstDesc();
124
- const roundtripped = RTCSessionDescription.fromGstDesc(gst);
125
- expect(roundtripped.type).toBe('offer');
126
- expect(roundtripped.sdp).toContain('a=mid:0');
127
- expect(roundtripped.sdp).toContain('UDP/DTLS/SCTP');
128
- });
129
- });
130
-
131
- await describe('RTCIceCandidate', async () => {
132
- await it('stores constructor fields', async () => {
133
- const c = new RTCIceCandidate({
134
- candidate: 'candidate:842163049 1 udp 1677729535 1.2.3.4 12345 typ srflx raddr 10.0.0.1 rport 5000',
135
- sdpMid: '0',
136
- sdpMLineIndex: 0,
137
- });
138
- expect(c.sdpMid).toBe('0');
139
- expect(c.sdpMLineIndex).toBe(0);
140
- expect(c.protocol).toBe('udp');
141
- expect(c.type).toBe('srflx');
142
- expect(c.address).toBe('1.2.3.4');
143
- expect(c.port).toBe(12345);
144
- expect(c.component).toBe('rtp');
145
- expect(c.relatedAddress).toBe('10.0.0.1');
146
- expect(c.relatedPort).toBe(5000);
147
- });
148
-
149
- await it('round-trips via toJSON', async () => {
150
- const c = new RTCIceCandidate({
151
- candidate: 'candidate:0 1 udp 1 127.0.0.1 9 typ host',
152
- sdpMid: '0',
153
- sdpMLineIndex: 0,
154
- });
155
- const json = c.toJSON();
156
- const restored = new RTCIceCandidate(json);
157
- expect(restored.candidate).toBe(c.candidate);
158
- expect(restored.sdpMid).toBe(c.sdpMid);
159
- });
160
-
161
- await it('requires sdpMid or sdpMLineIndex', async () => {
162
- expect(() => new RTCIceCandidate({ candidate: 'candidate:...' })).toThrow();
163
- });
164
- });
165
-
166
- await describe('Deferred APIs throw NotSupported', async () => {
167
- if (!webrtcbinReady) {
168
- await it('(skipped — webrtcbin/nicesrc missing)', async () => {
169
- expect(webrtcbinReady).toBeFalsy();
170
- });
171
- return;
172
- }
173
- await it('addTrack throws', async () => {
174
- const pc = new RTCPeerConnection();
175
- expect(() => (pc as any).addTrack({}, {})).toThrow();
176
- pc.close();
177
- });
178
- await it('addTransceiver works', async () => {
179
- const pc = new RTCPeerConnection();
180
- const t = pc.addTransceiver('audio');
181
- expect(t).toBeDefined();
182
- expect(t.direction).toBe('sendrecv');
183
- pc.close();
184
- });
185
- await it('getStats returns a Map or rejects gracefully', async () => {
186
- const pc = new RTCPeerConnection();
187
- try {
188
- const stats = await (pc as any).getStats();
189
- // If GStreamer supports get-stats, we get a Map-like RTCStatsReport
190
- expect(stats instanceof Map || typeof stats.forEach === 'function').toBeTruthy();
191
- } catch (err: any) {
192
- // Older GStreamer versions may reject — that's acceptable
193
- expect(err instanceof Error || err instanceof DOMException).toBeTruthy();
194
- }
195
- pc.close();
196
- });
197
- await it('getSenders / getReceivers / getTransceivers return empty arrays', async () => {
198
- const pc = new RTCPeerConnection();
199
- expect((pc as any).getSenders().length).toBe(0);
200
- expect((pc as any).getReceivers().length).toBe(0);
201
- expect((pc as any).getTransceivers().length).toBe(0);
202
- pc.close();
203
- });
204
- });
205
-
206
- await describe('Loopback data channel', async () => {
207
- if (!webrtcbinReady) {
208
- await it('(skipped — webrtcbin/nicesrc missing)', async () => {
209
- expect(webrtcbinReady).toBeFalsy();
210
- });
211
- return;
212
- }
213
- if (!ASYNC_SIGNALS_WORK) {
214
- await it('(skipped — GJS blocks webrtcbin streaming-thread callbacks; Phase 1.5 needs a native signal bridge)', async () => {
215
- expect(ASYNC_SIGNALS_WORK).toBeFalsy();
216
- });
217
- return;
218
- }
219
- await it('exchanges string and binary payloads over two local peers', async () => {
220
- const pcA = new RTCPeerConnection();
221
- const pcB = new RTCPeerConnection();
222
-
223
- // ICE trickle A↔B
224
- pcA.onicecandidate = (ev) => {
225
- if (ev.candidate) pcB.addIceCandidate(ev.candidate.toJSON());
226
- };
227
- pcB.onicecandidate = (ev) => {
228
- if (ev.candidate) pcA.addIceCandidate(ev.candidate.toJSON());
229
- };
230
-
231
- const channelA = pcA.createDataChannel('chat');
232
-
233
- // Handshake
234
- const offer = await pcA.createOffer();
235
- await pcA.setLocalDescription(offer);
236
- await pcB.setRemoteDescription(offer);
237
- const answer = await pcB.createAnswer();
238
- await pcB.setLocalDescription(answer);
239
- await pcA.setRemoteDescription(answer);
240
-
241
- // Wait for B's incoming data channel, then both opens.
242
- const dcEvent = await awaitEvent(pcB, 'datachannel') as any;
243
- const channelB = dcEvent.channel as RTCDataChannel;
244
-
245
- await Promise.all([
246
- channelA.readyState === 'open' ? Promise.resolve() : awaitEvent(channelA, 'open'),
247
- channelB.readyState === 'open' ? Promise.resolve() : awaitEvent(channelB, 'open'),
248
- ]);
249
-
250
- // String A → B
251
- const recvOnB = awaitEvent(channelB, 'message') as Promise<MessageEvent>;
252
- channelA.send('hello from A');
253
- const msg1 = await recvOnB;
254
- expect(msg1.data).toBe('hello from A');
255
-
256
- // Binary B → A
257
- const recvOnA = awaitEvent(channelA, 'message') as Promise<MessageEvent>;
258
- channelB.send(new Uint8Array([1, 2, 3, 4]).buffer);
259
- const msg2 = await recvOnA;
260
- expect(msg2.data instanceof ArrayBuffer).toBeTruthy();
261
- const arr = new Uint8Array(msg2.data as ArrayBuffer);
262
- expect(arr.length).toBe(4);
263
- expect(arr[0]).toBe(1);
264
- expect(arr[3]).toBe(4);
265
-
266
- channelA.close();
267
- channelB.close();
268
- pcA.close();
269
- pcB.close();
270
- });
271
- });
272
-
273
- await describe('close()', async () => {
274
- if (!webrtcbinReady) {
275
- await it('(skipped — webrtcbin/nicesrc missing)', async () => {
276
- expect(webrtcbinReady).toBeFalsy();
277
- });
278
- return;
279
- }
280
- await it('transitions signalingState to "closed"', async () => {
281
- const pc = new RTCPeerConnection();
282
- pc.close();
283
- expect(pc.signalingState).toBe('closed');
284
- expect(pc.connectionState).toBe('closed');
285
- });
286
- });
287
-
288
- // ── Phase 3: End-to-end audio loopback ─────────────────────────
289
-
290
- await describe('End-to-end audio loopback (Phase 3)', async () => {
291
- if (!webrtcbinReady) {
292
- await it('(skipped — webrtcbin/nicesrc missing)', async () => {
293
- expect(webrtcbinReady).toBeFalsy();
294
- });
295
- return;
296
- }
297
-
298
- await it('addTrack with getUserMedia track creates offer with audio m-line', async () => {
299
- const stream = await getUserMedia({ audio: true });
300
- const audioTrack = stream.getAudioTracks()[0];
301
- const pc = new RTCPeerConnection();
302
- const sender = pc.addTrack(audioTrack);
303
- expect(sender).toBeDefined();
304
- expect(sender.track).toBe(audioTrack);
305
-
306
- const offer = await pc.createOffer();
307
- expect(offer.sdp).toContain('m=audio');
308
-
309
- audioTrack.stop();
310
- pc.close();
311
- });
312
-
313
- await it('addTrack with plain MediaStreamTrack (no GStreamer source) works', async () => {
314
- const track = new MediaStreamTrack({ kind: 'audio' });
315
- const pc = new RTCPeerConnection();
316
- const sender = pc.addTrack(track);
317
- expect(sender).toBeDefined();
318
- expect(sender.track).toBe(track);
319
- // No GStreamer source — pipeline not wired, but API works
320
- expect(pc.getSenders().length).toBe(1);
321
- pc.close();
322
- });
323
-
324
- // ---- Implicit setLocalDescription (perfect negotiation) ----
325
- // Ported from refs/wpt/webrtc/RTCPeerConnection-restartIce.https.html
326
- // (negotiators[1] — "perfect negotiation" path).
327
-
328
- await it('implicit SLD() in stable state creates an offer', async () => {
329
- const pc = new RTCPeerConnection();
330
- pc.addTransceiver('audio');
331
- // Call setLocalDescription with no arguments
332
- await pc.setLocalDescription();
333
- expect(pc.localDescription).toBeDefined();
334
- expect(pc.localDescription!.type).toBe('offer');
335
- expect(pc.localDescription!.sdp.length).toBeGreaterThan(0);
336
- pc.close();
337
- });
338
-
339
- await it('implicit SLD() in have-remote-offer creates an answer', async () => {
340
- const pc1 = new RTCPeerConnection();
341
- const pc2 = new RTCPeerConnection();
342
- pc1.addTransceiver('audio');
343
- const offer = await pc1.createOffer();
344
- await pc1.setLocalDescription(offer);
345
- await pc2.setRemoteDescription(offer);
346
- // pc2 is now in have-remote-offer — implicit SLD should create answer
347
- expect(pc2.signalingState).toBe('have-remote-offer');
348
- await pc2.setLocalDescription();
349
- expect(pc2.localDescription).toBeDefined();
350
- expect(pc2.localDescription!.type).toBe('answer');
351
- pc1.close();
352
- pc2.close();
353
- });
354
-
355
- await it('perfect negotiation handshake (implicit SLD both sides)', async () => {
356
- const pc1 = new RTCPeerConnection();
357
- const pc2 = new RTCPeerConnection();
358
- pc1.onicecandidate = (ev: any) => {
359
- if (ev.candidate) pc2.addIceCandidate(ev.candidate).catch(() => {});
360
- };
361
- pc2.onicecandidate = (ev: any) => {
362
- if (ev.candidate) pc1.addIceCandidate(ev.candidate).catch(() => {});
363
- };
364
- pc1.addTransceiver('audio');
365
- // Offerer: implicit SLD
366
- await pc1.setLocalDescription();
367
- expect(pc1.localDescription!.type).toBe('offer');
368
- await pc2.setRemoteDescription(pc1.localDescription!);
369
- // Answerer: implicit SLD
370
- await pc2.setLocalDescription();
371
- expect(pc2.localDescription!.type).toBe('answer');
372
- await pc1.setRemoteDescription(pc2.localDescription!);
373
- expect(pc1.signalingState).toBe('stable');
374
- expect(pc2.signalingState).toBe('stable');
375
- pc1.close();
376
- pc2.close();
377
- });
378
-
379
- await it('implicit SLD() on closed connection throws', async () => {
380
- const pc = new RTCPeerConnection();
381
- pc.close();
382
- let threw = false;
383
- try {
384
- await pc.setLocalDescription();
385
- } catch (e: any) {
386
- threw = true;
387
- expect(e.message).toContain('closed');
388
- }
389
- expect(threw).toBeTruthy();
390
- });
391
-
392
- await it('should send audio from pcA and receive track event on pcB', async () => {
393
- const stream = await getUserMedia({ audio: true });
394
- const audioTrack = stream.getAudioTracks()[0];
395
-
396
- const pcA = new RTCPeerConnection();
397
- const pcB = new RTCPeerConnection();
398
-
399
- // ICE exchange
400
- pcA.onicecandidate = (ev: any) => {
401
- if (ev.candidate) pcB.addIceCandidate(ev.candidate);
402
- };
403
- pcB.onicecandidate = (ev: any) => {
404
- if (ev.candidate) pcA.addIceCandidate(ev.candidate);
405
- };
406
-
407
- pcA.addTrack(audioTrack);
408
-
409
- // Wait for track event on pcB
410
- const trackPromise = new Promise<any>((resolve, reject) => {
411
- const timeout = setTimeout(() => reject(new Error('track event timeout')), 15000);
412
- pcB.ontrack = (ev: any) => { clearTimeout(timeout); resolve(ev); };
413
- });
414
-
415
- // Offer/answer exchange
416
- const offer = await pcA.createOffer();
417
- await pcA.setLocalDescription(offer);
418
- await pcB.setRemoteDescription(offer);
419
- const answer = await pcB.createAnswer();
420
- await pcB.setLocalDescription(answer);
421
- await pcA.setRemoteDescription(answer);
422
-
423
- // Verify track event fires
424
- const ev = await trackPromise;
425
- expect(ev.track).toBeDefined();
426
- expect(ev.track.kind).toBe('audio');
427
- expect(ev.receiver).toBeDefined();
428
- expect(ev.transceiver).toBeDefined();
429
-
430
- audioTrack.stop();
431
- pcA.close();
432
- pcB.close();
433
- });
434
- });
435
-
436
- // ---- getStats() (Phase 4.2) ----------------------------------------
437
- // Ported from refs/wpt/webrtc/RTCPeerConnection-getStats.https.html
438
-
439
- await describe('getStats()', async () => {
440
- if (!webrtcbinReady || !ASYNC_SIGNALS_WORK) {
441
- await it('(skipped — webrtcbin/nicesrc missing)', async () => {
442
- expect(webrtcbinReady).toBeFalsy();
443
- });
444
- } else {
445
- await it('getStats() returns an RTCStatsReport', async () => {
446
- const pc = new RTCPeerConnection();
447
- pc.createDataChannel('test');
448
- const offer = await pc.createOffer();
449
- await pc.setLocalDescription(offer);
450
- const report = await pc.getStats();
451
- expect(report).toBeDefined();
452
- expect(typeof report.size).toBe('number');
453
- pc.close();
454
- });
455
-
456
- await it('getStats(null) returns all stats', async () => {
457
- const pc = new RTCPeerConnection();
458
- pc.createDataChannel('test');
459
- const offer = await pc.createOffer();
460
- await pc.setLocalDescription(offer);
461
- const report = await pc.getStats(null);
462
- expect(report).toBeDefined();
463
- expect(typeof report.size).toBe('number');
464
- pc.close();
465
- });
466
-
467
- await it('getStats() report entries have type, id, timestamp', async () => {
468
- const pc = new RTCPeerConnection();
469
- pc.createDataChannel('test');
470
- const offer = await pc.createOffer();
471
- await pc.setLocalDescription(offer);
472
- const report = await pc.getStats();
473
- for (const [id, stats] of report) {
474
- expect(typeof id).toBe('string');
475
- expect(id.length).toBeGreaterThan(0);
476
- expect(typeof stats.type).toBe('string');
477
- expect(typeof stats.id).toBe('string');
478
- expect(typeof stats.timestamp).toBe('number');
479
- }
480
- pc.close();
481
- });
482
-
483
- await it('getStats() is iterable with forEach', async () => {
484
- const pc = new RTCPeerConnection();
485
- pc.createDataChannel('test');
486
- const offer = await pc.createOffer();
487
- await pc.setLocalDescription(offer);
488
- const report = await pc.getStats();
489
- let count = 0;
490
- report.forEach(() => { count++; });
491
- expect(count).toBe(report.size);
492
- pc.close();
493
- });
494
-
495
- await it('getStats(unknownTrack) rejects with InvalidAccessError', async () => {
496
- const pc = new RTCPeerConnection();
497
- pc.createDataChannel('test');
498
- const unknownTrack = new MediaStreamTrack({ kind: 'audio' });
499
- let threw = false;
500
- try {
501
- await pc.getStats(unknownTrack);
502
- } catch (e: any) {
503
- threw = true;
504
- expect(e.message).toContain('not associated');
505
- }
506
- expect(threw).toBeTruthy();
507
- pc.close();
508
- });
509
-
510
- await it('getStats() on closed connection rejects', async () => {
511
- const pc = new RTCPeerConnection();
512
- pc.close();
513
- let threw = false;
514
- try {
515
- await pc.getStats();
516
- } catch (e: any) {
517
- threw = true;
518
- expect(e.message).toContain('closed');
519
- }
520
- expect(threw).toBeTruthy();
521
- });
522
-
523
- await it('sender.getStats() returns a report', async () => {
524
- const pc = new RTCPeerConnection();
525
- const transceiver = pc.addTransceiver('audio');
526
- const report = await transceiver.sender.getStats();
527
- expect(report).toBeDefined();
528
- expect(typeof report.size).toBe('number');
529
- pc.close();
530
- });
531
-
532
- await it('receiver.getStats() returns a report', async () => {
533
- const pc = new RTCPeerConnection();
534
- const transceiver = pc.addTransceiver('audio');
535
- const report = await transceiver.receiver.getStats();
536
- expect(report).toBeDefined();
537
- expect(typeof report.size).toBe('number');
538
- pc.close();
539
- });
540
- }
541
- });
542
-
543
- // ---- restartIce() + setConfiguration() (Phase 4.4) -----------------
544
- // Ported from refs/wpt/webrtc/RTCPeerConnection-restartIce.https.html
545
- // and refs/wpt/webrtc/RTCPeerConnection-restartIce-onnegotiationneeded.https.html
546
-
547
- await describe('restartIce()', async () => {
548
- if (!webrtcbinReady || !ASYNC_SIGNALS_WORK) {
549
- await it('(skipped — webrtcbin/nicesrc missing)', async () => {
550
- expect(webrtcbinReady).toBeFalsy();
551
- });
552
- } else {
553
- await it('restartIce() has no effect on a closed connection', async () => {
554
- const pc = new RTCPeerConnection();
555
- pc.close();
556
- // Should not throw
557
- pc.restartIce();
558
- expect(pc.signalingState).toBe('closed');
559
- });
560
-
561
- await it('restartIce() before initial negotiation does not crash', async () => {
562
- const pc = new RTCPeerConnection();
563
- // restartIce before any negotiation should not throw.
564
- // Whether it fires negotiationneeded is GStreamer-version-dependent:
565
- // older webrtcbin does not fire, newer versions may.
566
- pc.restartIce();
567
- await new Promise(r => setTimeout(r, 50));
568
- expect(pc.signalingState).toBe('stable');
569
- pc.close();
570
- });
571
-
572
- await it('restartIce() fires negotiationneeded after initial negotiation', async () => {
573
- const pc1 = new RTCPeerConnection();
574
- const pc2 = new RTCPeerConnection();
575
- pc1.onicecandidate = (ev: any) => {
576
- if (ev.candidate) pc2.addIceCandidate(ev.candidate).catch(() => {});
577
- };
578
- pc2.onicecandidate = (ev: any) => {
579
- if (ev.candidate) pc1.addIceCandidate(ev.candidate).catch(() => {});
580
- };
581
- pc1.addTransceiver('audio');
582
- // Initial negotiation
583
- const offer = await pc1.createOffer();
584
- await pc1.setLocalDescription(offer);
585
- await pc2.setRemoteDescription(offer);
586
- const answer = await pc2.createAnswer();
587
- await pc2.setLocalDescription(answer);
588
- await pc1.setRemoteDescription(answer);
589
-
590
- // Now restartIce should fire negotiationneeded
591
- const nnPromise = awaitEvent(pc1, 'negotiationneeded', 3000);
592
- pc1.restartIce();
593
- await nnPromise;
594
- pc1.close();
595
- pc2.close();
596
- });
597
-
598
- await it('restartIce() causes fresh ICE credentials in the next offer', async () => {
599
- const pc1 = new RTCPeerConnection();
600
- const pc2 = new RTCPeerConnection();
601
- pc1.onicecandidate = (ev: any) => {
602
- if (ev.candidate) pc2.addIceCandidate(ev.candidate).catch(() => {});
603
- };
604
- pc2.onicecandidate = (ev: any) => {
605
- if (ev.candidate) pc1.addIceCandidate(ev.candidate).catch(() => {});
606
- };
607
- pc1.addTransceiver('audio');
608
-
609
- // Initial negotiation
610
- let offer = await pc1.createOffer();
611
- await pc1.setLocalDescription(offer);
612
- await pc2.setRemoteDescription(offer);
613
- let answer = await pc2.createAnswer();
614
- await pc2.setLocalDescription(answer);
615
- await pc1.setRemoteDescription(answer);
616
-
617
- const getUfrags = (sdp: string) =>
618
- sdp.split('\r\n').filter(l => l.startsWith('a=ice-ufrag:'));
619
- const oldSdp = pc1.localDescription!.sdp;
620
- const oldUfrags = getUfrags(oldSdp);
621
-
622
- // Restart and re-negotiate
623
- pc1.restartIce();
624
- offer = await pc1.createOffer();
625
- await pc1.setLocalDescription(offer);
626
- await pc2.setRemoteDescription(offer);
627
- answer = await pc2.createAnswer();
628
- await pc2.setLocalDescription(answer);
629
- await pc1.setRemoteDescription(answer);
630
-
631
- const newSdp = pc1.localDescription!.sdp;
632
- const newUfrags = getUfrags(newSdp);
633
-
634
- if (oldUfrags.length > 0 && newUfrags.length > 0) {
635
- // Standard case: both SDPs have ice-ufrag — they must differ
636
- expect(newUfrags[0]).not.toBe(oldUfrags[0]);
637
- } else {
638
- // Some GStreamer versions embed ICE credentials differently;
639
- // at minimum the SDP should have changed after restart
640
- expect(newSdp).not.toBe(oldSdp);
641
- }
642
-
643
- pc1.close();
644
- pc2.close();
645
- });
646
- }
647
- });
648
-
649
- await describe('setConfiguration()', async () => {
650
- if (!webrtcbinReady) {
651
- await it('(skipped — webrtcbin missing)', async () => {
652
- expect(webrtcbinReady).toBeFalsy();
653
- });
654
- } else {
655
- await it('setConfiguration() updates iceServers', async () => {
656
- const pc = new RTCPeerConnection();
657
- pc.setConfiguration({
658
- iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
659
- });
660
- const config = pc.getConfiguration();
661
- expect(config.iceServers).toBeDefined();
662
- expect(config.iceServers!.length).toBe(1);
663
- pc.close();
664
- });
665
-
666
- await it('setConfiguration() throws on bundlePolicy change', async () => {
667
- const pc = new RTCPeerConnection({ bundlePolicy: 'max-bundle' });
668
- let threw = false;
669
- try {
670
- pc.setConfiguration({ bundlePolicy: 'balanced' });
671
- } catch (e: any) {
672
- threw = true;
673
- expect(e.message).toContain('bundlePolicy');
674
- }
675
- expect(threw).toBeTruthy();
676
- pc.close();
677
- });
678
-
679
- await it('setConfiguration() on closed connection throws', async () => {
680
- const pc = new RTCPeerConnection();
681
- pc.close();
682
- let threw = false;
683
- try {
684
- pc.setConfiguration({});
685
- } catch (e: any) {
686
- threw = true;
687
- expect(e.message).toContain('closed');
688
- }
689
- expect(threw).toBeTruthy();
690
- });
691
- }
692
- });
693
-
694
- // ---- enumerateDevices / getSupportedConstraints (Phase 4.3) --------
695
- // Ported from refs/wpt/mediacapture-streams/MediaDevices-enumerateDevices.https.html
696
- // and refs/wpt/mediacapture-streams/MediaDevices-getSupportedConstraints.https.html
697
-
698
- await describe('MediaDevices', async () => {
699
- await it('enumerateDevices() returns an array', async () => {
700
- const md = new MediaDevices();
701
- const devices = await md.enumerateDevices();
702
- expect(Array.isArray(devices)).toBeTruthy();
703
- });
704
-
705
- await it('enumerateDevices() returns devices with valid kind', async () => {
706
- const md = new MediaDevices();
707
- const devices = await md.enumerateDevices();
708
- const validKinds = ['audioinput', 'audiooutput', 'videoinput'];
709
- for (const device of devices) {
710
- expect(validKinds).toContain(device.kind);
711
- }
712
- });
713
-
714
- await it('enumerateDevices() devices have toJSON()', async () => {
715
- const md = new MediaDevices();
716
- const devices = await md.enumerateDevices();
717
- for (const device of devices) {
718
- const json = device.toJSON() as any;
719
- expect(typeof json.kind).toBe('string');
720
- expect(typeof json.deviceId).toBe('string');
721
- expect(typeof json.label).toBe('string');
722
- expect(typeof json.groupId).toBe('string');
723
- }
724
- });
725
-
726
- await it('enumerateDevices() sorted: audioinput, videoinput, audiooutput', async () => {
727
- const md = new MediaDevices();
728
- const devices = await md.enumerateDevices();
729
- const order: Record<string, number> = { audioinput: 0, videoinput: 1, audiooutput: 2 };
730
- for (let i = 1; i < devices.length; i++) {
731
- expect(order[devices[i].kind]).not.toBeLessThan(order[devices[i - 1].kind]);
732
- }
733
- });
734
-
735
- await it('getSupportedConstraints() returns an object with boolean values', async () => {
736
- const md = new MediaDevices();
737
- const supported = md.getSupportedConstraints();
738
- expect(typeof supported).toBe('object');
739
- // At least deviceId, width, height should be supported
740
- expect(supported.deviceId).toBeTruthy();
741
- expect(supported.width).toBeTruthy();
742
- expect(supported.height).toBeTruthy();
743
- expect(supported.frameRate).toBeTruthy();
744
- expect(supported.sampleRate).toBeTruthy();
745
- expect(supported.channelCount).toBeTruthy();
746
- });
747
-
748
- await it('getSupportedConstraints() has boolean-only values', async () => {
749
- const md = new MediaDevices();
750
- const supported = md.getSupportedConstraints();
751
- for (const value of Object.values(supported)) {
752
- expect(typeof value).toBe('boolean');
753
- }
754
- });
755
- });
756
-
757
- // ---- Transport objects (Phase 4.5) ---------------------------------
758
- // Ported from refs/wpt/webrtc/RTCDtlsTransport-state.html,
759
- // RTCSctpTransport-constructor.html, RTCIceTransport.html
760
-
761
- await describe('Transport objects', async () => {
762
- if (!webrtcbinReady || !ASYNC_SIGNALS_WORK) {
763
- await it('(skipped — webrtcbin/nicesrc missing)', async () => {
764
- expect(webrtcbinReady).toBeFalsy();
765
- });
766
- } else {
767
- await it('pc.sctp is null before data channel negotiation', async () => {
768
- const pc = new RTCPeerConnection();
769
- expect(pc.sctp).toBeNull();
770
- pc.close();
771
- });
772
-
773
- await it('pc.sctp is non-null after createDataChannel', async () => {
774
- const pc = new RTCPeerConnection();
775
- pc.createDataChannel('test');
776
- expect(pc.sctp).toBeDefined();
777
- expect(pc.sctp).not.toBeNull();
778
- pc.close();
779
- });
780
-
781
- await it('sctp.transport is an RTCDtlsTransport', async () => {
782
- const pc = new RTCPeerConnection();
783
- pc.createDataChannel('test');
784
- const sctp = pc.sctp!;
785
- expect(sctp.transport).toBeDefined();
786
- expect(sctp.transport instanceof RTCDtlsTransport).toBeTruthy();
787
- pc.close();
788
- });
789
-
790
- await it('sctp.transport.iceTransport is an RTCIceTransport', async () => {
791
- const pc = new RTCPeerConnection();
792
- pc.createDataChannel('test');
793
- const sctp = pc.sctp!;
794
- expect(sctp.transport.iceTransport).toBeDefined();
795
- expect(sctp.transport.iceTransport instanceof RTCIceTransport).toBeTruthy();
796
- pc.close();
797
- });
798
-
799
- await it('sctp.state starts as connecting', async () => {
800
- const pc = new RTCPeerConnection();
801
- pc.createDataChannel('test');
802
- expect(pc.sctp!.state).toBe('connecting');
803
- pc.close();
804
- });
805
-
806
- await it('sctp.maxMessageSize is a number', async () => {
807
- const pc = new RTCPeerConnection();
808
- pc.createDataChannel('test');
809
- expect(typeof pc.sctp!.maxMessageSize).toBe('number');
810
- expect(pc.sctp!.maxMessageSize).toBeGreaterThan(0);
811
- pc.close();
812
- });
813
-
814
- await it('sender.transport is the same as receiver.transport', async () => {
815
- const pc = new RTCPeerConnection();
816
- const tc = pc.addTransceiver('audio');
817
- expect(tc.sender.transport).toBeDefined();
818
- expect(tc.sender.transport).toBe(tc.receiver.transport);
819
- pc.close();
820
- });
821
-
822
- await it('sender.transport is an RTCDtlsTransport', async () => {
823
- const pc = new RTCPeerConnection();
824
- const tc = pc.addTransceiver('audio');
825
- expect(tc.sender.transport instanceof RTCDtlsTransport).toBeTruthy();
826
- pc.close();
827
- });
828
-
829
- await it('DTLS transport starts in new state', async () => {
830
- const pc = new RTCPeerConnection();
831
- const tc = pc.addTransceiver('audio');
832
- expect(tc.sender.transport!.state).toBe('new');
833
- pc.close();
834
- });
835
-
836
- await it('ICE transport starts in new state', async () => {
837
- const pc = new RTCPeerConnection();
838
- const tc = pc.addTransceiver('audio');
839
- expect(tc.sender.transport!.iceTransport.state).toBe('new');
840
- expect(tc.sender.transport!.iceTransport.gatheringState).toBe('new');
841
- pc.close();
842
- });
843
-
844
- await it('DTLS transport goes to closed on pc.close()', async () => {
845
- const pc = new RTCPeerConnection();
846
- const tc = pc.addTransceiver('audio');
847
- const dtls = tc.sender.transport!;
848
- pc.close();
849
- // close() is async via idle_add — wait a tick
850
- await new Promise(r => setTimeout(r, 100));
851
- expect(dtls.state).toBe('closed');
852
- });
853
-
854
- await it('all transceivers share the same DTLS transport (max-bundle)', async () => {
855
- const pc = new RTCPeerConnection();
856
- const tc1 = pc.addTransceiver('audio');
857
- const tc2 = pc.addTransceiver('video');
858
- expect(tc1.sender.transport).toBe(tc2.sender.transport);
859
- expect(tc1.receiver.transport).toBe(tc2.receiver.transport);
860
- pc.close();
861
- });
862
- }
863
- });
864
-
865
- // ---- RTCDTMFSender (Phase 4.6) -------------------------------------
866
- // Ported from refs/wpt/webrtc/RTCDTMFSender-insertDTMF.https.html
867
-
868
- await describe('RTCDTMFSender', async () => {
869
- if (!webrtcbinReady) {
870
- await it('(skipped — webrtcbin missing)', async () => {
871
- expect(webrtcbinReady).toBeFalsy();
872
- });
873
- } else {
874
- await it('audio sender.dtmf is an RTCDTMFSender', async () => {
875
- const pc = new RTCPeerConnection();
876
- const tc = pc.addTransceiver('audio');
877
- expect(tc.sender.dtmf).toBeDefined();
878
- expect(tc.sender.dtmf).not.toBeNull();
879
- expect(tc.sender.dtmf instanceof RTCDTMFSender).toBeTruthy();
880
- pc.close();
881
- });
882
-
883
- await it('video sender.dtmf is null', async () => {
884
- const pc = new RTCPeerConnection();
885
- const tc = pc.addTransceiver('video');
886
- expect(tc.sender.dtmf).toBeNull();
887
- pc.close();
888
- });
889
-
890
- await it('insertDTMF accepts valid DTMF characters', async () => {
891
- const pc = new RTCPeerConnection();
892
- const tc = pc.addTransceiver('audio');
893
- const dtmf = tc.sender.dtmf!;
894
- dtmf.insertDTMF('');
895
- dtmf.insertDTMF('0123456789');
896
- dtmf.insertDTMF('ABCD');
897
- dtmf.insertDTMF('abcd');
898
- dtmf.insertDTMF('#*');
899
- dtmf.insertDTMF(',');
900
- dtmf.insertDTMF('0123456789ABCDabcd#*,');
901
- pc.close();
902
- });
903
-
904
- await it('insertDTMF normalizes a-d to A-D', async () => {
905
- const pc = new RTCPeerConnection();
906
- const tc = pc.addTransceiver('audio');
907
- const dtmf = tc.sender.dtmf!;
908
- dtmf.insertDTMF('abcd');
909
- expect(dtmf.toneBuffer).toBe('ABCD');
910
- pc.close();
911
- });
912
-
913
- await it('insertDTMF throws InvalidCharacterError for invalid characters', async () => {
914
- const pc = new RTCPeerConnection();
915
- const tc = pc.addTransceiver('audio');
916
- const dtmf = tc.sender.dtmf!;
917
- let threw = false;
918
- try {
919
- dtmf.insertDTMF('123FFABC');
920
- } catch (e: any) {
921
- threw = true;
922
- expect(e.name).toBe('InvalidCharacterError');
923
- }
924
- expect(threw).toBeTruthy();
925
-
926
- threw = false;
927
- try {
928
- dtmf.insertDTMF('# *');
929
- } catch (e: any) {
930
- threw = true;
931
- expect(e.name).toBe('InvalidCharacterError');
932
- }
933
- expect(threw).toBeTruthy();
934
- pc.close();
935
- });
936
-
937
- await it('insertDTMF throws InvalidStateError if transceiver is stopped', async () => {
938
- const pc = new RTCPeerConnection();
939
- const tc = pc.addTransceiver('audio');
940
- const dtmf = tc.sender.dtmf!;
941
- tc.stop();
942
- let threw = false;
943
- try {
944
- dtmf.insertDTMF('');
945
- } catch (e: any) {
946
- threw = true;
947
- expect(e.name).toBe('InvalidStateError');
948
- }
949
- expect(threw).toBeTruthy();
950
- pc.close();
951
- });
952
-
953
- await it('toneBuffer updates as tones are played', async () => {
954
- const pc = new RTCPeerConnection();
955
- const tc = pc.addTransceiver('audio');
956
- const dtmf = tc.sender.dtmf!;
957
- dtmf.insertDTMF('123', 40, 30);
958
- expect(dtmf.toneBuffer).toBe('123');
959
-
960
- // Wait for first tone to fire
961
- await new Promise<void>((resolve) => {
962
- dtmf.addEventListener('tonechange', function handler(ev: Event) {
963
- const toneEv = ev as RTCDTMFToneChangeEvent;
964
- if (toneEv.tone === '1') {
965
- expect(dtmf.toneBuffer).toBe('23');
966
- dtmf.removeEventListener('tonechange', handler);
967
- resolve();
968
- }
969
- });
970
- });
971
- pc.close();
972
- });
973
-
974
- await it('canInsertDTMF is true for audio sender', async () => {
975
- const pc = new RTCPeerConnection();
976
- const tc = pc.addTransceiver('audio');
977
- expect(tc.sender.dtmf!.canInsertDTMF).toBeTruthy();
978
- pc.close();
979
- });
980
- }
981
- });
982
-
983
- // ---- RTCCertificate (Phase 4.7) ------------------------------------
984
- // Ported from refs/wpt/webrtc/RTCPeerConnection-generateCertificate.html
985
- // and refs/wpt/webrtc/RTCCertificate.html
986
-
987
- await describe('RTCCertificate', async () => {
988
- await it('generateCertificate with ECDSA P-256 succeeds', async () => {
989
- const cert = await RTCPeerConnection.generateCertificate({
990
- name: 'ECDSA',
991
- namedCurve: 'P-256',
992
- });
993
- expect(cert instanceof RTCCertificate).toBeTruthy();
994
- expect(cert.expires).toBeGreaterThan(Date.now());
995
- });
996
-
997
- await it('generateCertificate with RSASSA-PKCS1-v1_5 succeeds', async () => {
998
- const cert = await RTCPeerConnection.generateCertificate({
999
- name: 'RSASSA-PKCS1-v1_5',
1000
- modulusLength: 2048,
1001
- publicExponent: new Uint8Array([1, 0, 1]),
1002
- hash: 'SHA-256',
1003
- });
1004
- expect(cert instanceof RTCCertificate).toBeTruthy();
1005
- expect(cert.expires).toBeGreaterThan(Date.now());
1006
- });
1007
-
1008
- await it('generateCertificate with invalid algorithm rejects', async () => {
1009
- let threw = false;
1010
- try {
1011
- await RTCPeerConnection.generateCertificate('invalid-algo');
1012
- } catch (e: any) {
1013
- threw = true;
1014
- expect(e.name).toBe('NotSupportedError');
1015
- }
1016
- expect(threw).toBeTruthy();
1017
- });
1018
-
1019
- await it('getFingerprints() returns sha-256 fingerprint', async () => {
1020
- const cert = await RTCPeerConnection.generateCertificate({
1021
- name: 'ECDSA',
1022
- namedCurve: 'P-256',
1023
- });
1024
- const fps = cert.getFingerprints();
1025
- expect(Array.isArray(fps)).toBeTruthy();
1026
- expect(fps.length).toBeGreaterThan(0);
1027
- expect(fps[0].algorithm).toBe('sha-256');
1028
- expect(typeof fps[0].value).toBe('string');
1029
- // Fingerprint format: colon-separated hex pairs
1030
- expect(fps[0].value).toMatch(/^([0-9A-F]{2}:)+[0-9A-F]{2}$/);
1031
- });
1032
-
1033
- if (webrtcbinReady) {
1034
- await it('certificate can be passed to RTCPeerConnection', async () => {
1035
- const cert = await RTCPeerConnection.generateCertificate({
1036
- name: 'ECDSA',
1037
- namedCurve: 'P-256',
1038
- });
1039
- const pc = new RTCPeerConnection({ certificates: [cert] as any });
1040
- expect(pc).toBeDefined();
1041
- pc.close();
1042
- });
1043
- }
1044
- });
1045
-
1046
- // ---- Multi-PC fan-out / TeeMultiplexer (Phase 4.8) -----------------
1047
-
1048
- await describe('Multi-PC fan-out', async () => {
1049
- if (!webrtcbinReady || !ASYNC_SIGNALS_WORK) {
1050
- await it('(skipped — webrtcbin/nicesrc missing)', async () => {
1051
- expect(webrtcbinReady).toBeFalsy();
1052
- });
1053
- } else {
1054
- await it('same track can be added to two PeerConnections', async () => {
1055
- const stream = await getUserMedia({ audio: true });
1056
- const track = stream.getAudioTracks()[0];
1057
-
1058
- const pc1 = new RTCPeerConnection();
1059
- const pc2 = new RTCPeerConnection();
1060
-
1061
- const sender1 = pc1.addTrack(track);
1062
- expect(sender1).toBeDefined();
1063
- expect(sender1.track).toBe(track);
1064
-
1065
- const sender2 = pc2.addTrack(track);
1066
- expect(sender2).toBeDefined();
1067
- expect(sender2.track).toBe(track);
1068
-
1069
- // Both senders should have the same track
1070
- expect(pc1.getSenders().length).toBe(1);
1071
- expect(pc2.getSenders().length).toBe(1);
1072
-
1073
- track.stop();
1074
- pc1.close();
1075
- pc2.close();
1076
- });
1077
-
1078
- await it('closing one PC does not affect the other', async () => {
1079
- const stream = await getUserMedia({ audio: true });
1080
- const track = stream.getAudioTracks()[0];
1081
-
1082
- const pc1 = new RTCPeerConnection();
1083
- const pc2 = new RTCPeerConnection();
1084
-
1085
- pc1.addTrack(track);
1086
- pc2.addTrack(track);
1087
-
1088
- // Close pc1 — pc2's sender should still have the track
1089
- pc1.close();
1090
- expect(pc2.getSenders()[0].track).toBe(track);
1091
-
1092
- track.stop();
1093
- pc2.close();
1094
- });
1095
-
1096
- await it('track gets a tee multiplexer after second addTrack', async () => {
1097
- const stream = await getUserMedia({ audio: true });
1098
- const track = stream.getAudioTracks()[0];
1099
-
1100
- const pc1 = new RTCPeerConnection();
1101
- pc1.addTrack(track);
1102
-
1103
- // After first addTrack, no tee yet
1104
- expect((track as any)._teeMultiplexer).toBeNull();
1105
-
1106
- const pc2 = new RTCPeerConnection();
1107
- pc2.addTrack(track);
1108
-
1109
- // After second addTrack, tee should be created
1110
- expect((track as any)._teeMultiplexer).toBeDefined();
1111
- expect((track as any)._teeMultiplexer).not.toBeNull();
1112
-
1113
- track.stop();
1114
- pc1.close();
1115
- pc2.close();
1116
- });
1117
- }
1118
- });
1119
- });
1120
-
1121
- // ── onnegotiationneeded event tests ──────────────────────────────────
1122
- // Ported from refs/wpt/webrtc/RTCPeerConnection-onnegotiationneeded.html
1123
-
1124
- await describe('negotiationneeded event', async () => {
1125
- if (!webrtcbinReady || !ASYNC_SIGNALS_WORK) {
1126
- await it('(skipped — webrtcbin/nicesrc missing)', async () => {
1127
- expect(webrtcbinReady).toBeFalsy();
1128
- });
1129
- } else {
1130
- await it('addTransceiver triggers negotiationneeded', async () => {
1131
- const pc = new RTCPeerConnection();
1132
- const nnPromise = awaitEvent(pc, 'negotiationneeded', 3000);
1133
- pc.addTransceiver('audio');
1134
- await nnPromise;
1135
- pc.close();
1136
- });
1137
-
1138
- await it('createDataChannel triggers negotiationneeded', async () => {
1139
- const pc = new RTCPeerConnection();
1140
- const nnPromise = awaitEvent(pc, 'negotiationneeded', 3000);
1141
- pc.createDataChannel('test');
1142
- await nnPromise;
1143
- pc.close();
1144
- });
1145
-
1146
- await it('negotiationneeded does not fire on closed connection', async () => {
1147
- const pc = new RTCPeerConnection();
1148
- pc.close();
1149
- let fired = false;
1150
- pc.onnegotiationneeded = () => { fired = true; };
1151
- // addTransceiver on closed connection should throw
1152
- try { pc.addTransceiver('audio'); } catch { /* expected */ }
1153
- await new Promise(r => setTimeout(r, 100));
1154
- expect(fired).toBeFalsy();
1155
- });
1156
-
1157
- await it('completing offer/answer allows negotiationneeded to fire again', async () => {
1158
- const pc1 = new RTCPeerConnection();
1159
- const pc2 = new RTCPeerConnection();
1160
- pc1.onicecandidate = (ev: any) => {
1161
- if (ev.candidate) pc2.addIceCandidate(ev.candidate).catch(() => {});
1162
- };
1163
- pc2.onicecandidate = (ev: any) => {
1164
- if (ev.candidate) pc1.addIceCandidate(ev.candidate).catch(() => {});
1165
- };
1166
-
1167
- // First negotiation
1168
- pc1.addTransceiver('audio');
1169
- const offer1 = await pc1.createOffer();
1170
- await pc1.setLocalDescription(offer1);
1171
- await pc2.setRemoteDescription(offer1);
1172
- const answer1 = await pc2.createAnswer();
1173
- await pc2.setLocalDescription(answer1);
1174
- await pc1.setRemoteDescription(answer1);
1175
-
1176
- // After completing, adding another transceiver should fire again
1177
- const nnPromise = awaitEvent(pc1, 'negotiationneeded', 3000);
1178
- pc1.addTransceiver('video');
1179
- await nnPromise;
1180
-
1181
- pc1.close();
1182
- pc2.close();
1183
- });
1184
- }
1185
- });
1186
- };