diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /testing/web-platform/tests/webrtc | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
274 files changed, 39069 insertions, 0 deletions
diff --git a/testing/web-platform/tests/webrtc-encoded-transform/META.yml b/testing/web-platform/tests/webrtc-encoded-transform/META.yml new file mode 100644 index 0000000000..6365c8d16a --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/META.yml @@ -0,0 +1 @@ +spec: https://w3c.github.io/webrtc-encoded-transform/ diff --git a/testing/web-platform/tests/webrtc-encoded-transform/RTCEncodedAudioFrame-clone.https.html b/testing/web-platform/tests/webrtc-encoded-transform/RTCEncodedAudioFrame-clone.https.html new file mode 100644 index 0000000000..ec99338772 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/RTCEncodedAudioFrame-clone.https.html @@ -0,0 +1,50 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>RTCEncodedAudioFrame can be cloned and distributed</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="../service-workers/service-worker/resources/test-helpers.sub.js"></script> + +<script> +"use strict"; +promise_test(async t => { + const caller1 = new RTCPeerConnection(); + t.add_cleanup(() => caller1.close()); + const callee1 = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => callee1.close()); + await setMediaPermission("granted", ["microphone"]); + const inputStream = await navigator.mediaDevices.getUserMedia({audio:true}); + const inputTrack = inputStream.getAudioTracks()[0]; + t.add_cleanup(() => inputTrack.stop()); + caller1.addTrack(inputTrack) + exchangeIceCandidates(caller1, callee1); + + const caller2 = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller2.close()); + const sender2 = caller2.addTransceiver("audio").sender; + const writer2 = sender2.createEncodedStreams().writable.getWriter(); + sender2.replaceTrack(new MediaStreamTrackGenerator({ kind: 'audio' })); + + const framesReceivedCorrectly = new Promise((resolve, reject) => { + callee1.ontrack = async e => { + const receiverStreams = e.receiver.createEncodedStreams(); + const receiverReader = receiverStreams.readable.getReader(); + const result = await receiverReader.read(); + const original = result.value; + let clone = original.clone(); + assert_equals(original.timestamp, clone.timestamp); + assert_array_equals(Array.from(original.data), Array.from(clone.data)); + await writer2.write(clone); + resolve(); + } + }); + + await exchangeOfferAnswer(caller1, callee1); + + return framesReceivedCorrectly; +}, "Cloning before sending works"); +</script> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/RTCEncodedAudioFrame-serviceworker-failure.https.html b/testing/web-platform/tests/webrtc-encoded-transform/RTCEncodedAudioFrame-serviceworker-failure.https.html new file mode 100644 index 0000000000..d6d8578dbd --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/RTCEncodedAudioFrame-serviceworker-failure.https.html @@ -0,0 +1,60 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<!-- Based on similar tests in html/infrastructure/safe-passing-of-structured-data/shared-array-buffers/ --> +<title>RTCEncodedVideoFrame cannot cross agent clusters, service worker edition</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="../service-workers/service-worker/resources/test-helpers.sub.js"></script> + +<script> +"use strict"; +promise_test(async t => { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + await setMediaPermission("granted", ["microphone"]); + const stream = await navigator.mediaDevices.getUserMedia({audio:true}); + const track = stream.getTracks()[0]; + t.add_cleanup(() => track.stop()); + + const sender = caller.addTrack(track) + const streams = sender.createEncodedStreams(); + const reader = streams.readable.getReader(); + const writer = streams.writable.getWriter(); + + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + + const result = await reader.read(); + const scope = "resources/blank.html"; + let reg = await service_worker_unregister_and_register(t, "resources/serviceworker-failure.js", scope); + t.add_cleanup(() => service_worker_unregister(t, scope)); + await wait_for_state(t, reg.installing, "activated"); + let iframe = await with_iframe(scope); + t.add_cleanup(() => iframe.remove()); + const sw = iframe.contentWindow.navigator.serviceWorker; + let state = "start in window"; + return new Promise(resolve => { + sw.onmessage = t.step_func(e => { + if (e.data === "start in worker") { + assert_equals(state, "start in window"); + sw.controller.postMessage(result.value); + state = "we are expecting confirmation of an onmessageerror in the worker"; + } else if (e.data === "onmessageerror was received in worker") { + assert_equals(state, "we are expecting confirmation of an onmessageerror in the worker"); + resolve(); + } else { + assert_unreached("Got an unexpected message from the service worker: " + e.data); + } + }); + + sw.controller.postMessage(state); + }); +}); +</script> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/RTCEncodedVideoFrame-clone.https.html b/testing/web-platform/tests/webrtc-encoded-transform/RTCEncodedVideoFrame-clone.https.html new file mode 100644 index 0000000000..29330729dc --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/RTCEncodedVideoFrame-clone.https.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>RTCEncodedVideoFrame can be cloned and distributed</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="../service-workers/service-worker/resources/test-helpers.sub.js"></script> + +<script> +"use strict"; +promise_test(async t => { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => callee.close()); + + await setMediaPermission("granted", ["camera"]); + const stream = await navigator.mediaDevices.getUserMedia({video:true}); + const videoTrack = stream.getVideoTracks()[0]; + t.add_cleanup(() => videoTrack.stop()); + + const videoSender = caller.addTrack(videoTrack) + const senderStreams = videoSender.createEncodedStreams(); + const senderReader = senderStreams.readable.getReader(); + const senderWriter = senderStreams.writable.getWriter(); + + exchangeIceCandidates(caller, callee); + + // Send 10 frames and stop + const numFramesToSend = 10; + + const framesReceivedCorrectly = new Promise((resolve, reject) => { + callee.ontrack = async e => { + const receiverStreams = e.receiver.createEncodedStreams(); + const receiverReader = receiverStreams.readable.getReader(); + const receiverWriter = receiverStreams.writable.getWriter(); + + // This should all be able to happen in 5 seconds. + // For fast failures, uncomment this line. + // t.step_timeout(reject, 5000); + for (let i = 0; i < numFramesToSend; i++) { + const result = await receiverReader.read(); + // Write upstream, purely to avoid "no frame received" error messages + receiverWriter.write(result.value); + } + resolve(); + } + }); + + await exchangeOfferAnswer(caller, callee); + + for (let i = 0; i < numFramesToSend; i++) { + const result = await senderReader.read(); + senderWriter.write(result.value.clone()); + } + return framesReceivedCorrectly; +}, "Cloning before sending works"); +</script> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/RTCEncodedVideoFrame-serviceworker-failure.https.html b/testing/web-platform/tests/webrtc-encoded-transform/RTCEncodedVideoFrame-serviceworker-failure.https.html new file mode 100644 index 0000000000..b95c673f41 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/RTCEncodedVideoFrame-serviceworker-failure.https.html @@ -0,0 +1,60 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<!-- Based on similar tests in html/infrastructure/safe-passing-of-structured-data/shared-array-buffers/ --> +<title>RTCEncodedVideoFrame cannot cross agent clusters, service worker edition</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="../service-workers/service-worker/resources/test-helpers.sub.js"></script> + +<script> +"use strict"; +promise_test(async t => { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + await setMediaPermission("granted", ["camera"]); + const stream = await navigator.mediaDevices.getUserMedia({video:true}); + const videoTrack = stream.getVideoTracks()[0]; + t.add_cleanup(() => videoTrack.stop()); + + const videoSender = caller.addTrack(videoTrack) + const senderStreams = videoSender.createEncodedStreams(); + const senderReader = senderStreams.readable.getReader(); + const senderWriter = senderStreams.writable.getWriter(); + + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + + const result = await senderReader.read(); + const scope = "resources/blank.html"; + const reg = await service_worker_unregister_and_register(t, "resources/serviceworker-failure.js", scope) + t.add_cleanup(() => service_worker_unregister(t, scope)); + await wait_for_state(t, reg.installing, "activated"); + const iframe = await with_iframe(scope); + t.add_cleanup(() => iframe.remove()); + const sw = iframe.contentWindow.navigator.serviceWorker; + let state = "start in window"; + return new Promise(resolve => { + sw.onmessage = t.step_func(e => { + if (e.data === "start in worker") { + assert_equals(state, "start in window"); + sw.controller.postMessage(result.value); + state = "we are expecting confirmation of an onmessageerror in the worker"; + } else if (e.data === "onmessageerror was received in worker") { + assert_equals(state, "we are expecting confirmation of an onmessageerror in the worker"); + resolve(); + } else { + assert_unreached("Got an unexpected message from the service worker: " + e.data); + } + }); + + sw.controller.postMessage(state); + }); +}); +</script> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-audio.https.html b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-audio.https.html new file mode 100644 index 0000000000..d4b6b72a32 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-audio.https.html @@ -0,0 +1,212 @@ +<!DOCTYPE html> +<html> +<head> +<title>RTCPeerConnection Insertable Streams Audio</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="./RTCPeerConnection-insertable-streams.js"></script> +</head> +<body> +<script> +async function testAudioFlow(t, negotiationFunction) { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => callee.close()); + + await setMediaPermission("granted", ["microphone"]); + const stream = await navigator.mediaDevices.getUserMedia({audio:true}); + const audioTrack = stream.getAudioTracks()[0]; + t.add_cleanup(() => audioTrack.stop()); + + const audioSender = caller.addTrack(audioTrack) + const senderStreams = audioSender.createEncodedStreams(); + const senderReader = senderStreams.readable.getReader(); + const senderWriter = senderStreams.writable.getWriter(); + + const frameInfos = []; + const numFramesPassthrough = 5; + const numFramesReplaceData = 5; + const numFramesModifyData = 5; + const numFramesToSend = numFramesPassthrough + numFramesReplaceData + numFramesModifyData; + + const ontrackPromise = new Promise(resolve => { + callee.ontrack = t.step_func(() => { + const audioReceiver = callee.getReceivers().find(r => r.track.kind === 'audio'); + assert_not_equals(audioReceiver, undefined); + + const receiverStreams = + audioReceiver.createEncodedStreams(); + const receiverReader = receiverStreams.readable.getReader(); + const receiverWriter = receiverStreams.writable.getWriter(); + + const maxFramesToReceive = numFramesToSend; + let numVerifiedFrames = 0; + for (let i = 0; i < maxFramesToReceive; i++) { + receiverReader.read().then(t.step_func(result => { + if (frameInfos[numVerifiedFrames] && + areFrameInfosEqual(result.value, frameInfos[numVerifiedFrames])) { + numVerifiedFrames++; + } else { + // Receiving unexpected frames is an indication that + // frames are not passed correctly between sender and receiver. + assert_unreached("Incorrect frame received"); + } + assert_not_equals(result.value.getMetadata().sequenceNumber, undefined); + + if (numVerifiedFrames == numFramesToSend) + resolve(); + })); + } + }); + }); + + exchangeIceCandidates(caller, callee); + await negotiationFunction(caller, callee); + + // Pass frames as they come from the encoder. + for (let i = 0; i < numFramesPassthrough; i++) { + const result = await senderReader.read() + frameInfos.push({ + data: result.value.data, + timestamp: result.value.timestamp, + type: result.value.type, + metadata: result.value.getMetadata(), + getMetadata() { return this.metadata; } + }); + senderWriter.write(result.value); + } + + // Replace frame data with arbitrary buffers. + for (let i = 0; i < numFramesReplaceData; i++) { + const result = await senderReader.read() + + const buffer = new ArrayBuffer(100); + const int8View = new Int8Array(buffer); + int8View.fill(i); + + result.value.data = buffer; + frameInfos.push({ + data: result.value.data, + timestamp: result.value.timestamp, + type: result.value.type, + metadata: result.value.getMetadata(), + getMetadata() { return this.metadata; } + }); + senderWriter.write(result.value); + } + + // Modify frame data. + for (let i = 0; i < numFramesReplaceData; i++) { + const result = await senderReader.read() + const int8View = new Int8Array(result.value.data); + int8View.fill(i); + + frameInfos.push({ + data: result.value.data, + timestamp: result.value.timestamp, + type: result.value.type, + metadata: result.value.getMetadata(), + getMetadata() { return this.metadata; } + }); + senderWriter.write(result.value); + } + + return ontrackPromise; +} + +promise_test(async t => { + return testAudioFlow(t, exchangeOfferAnswer); +}, 'Frames flow correctly using insertable streams'); + +promise_test(async t => { + return testAudioFlow(t, exchangeOfferAnswerReverse); +}, 'Frames flow correctly using insertable streams when receiver starts negotiation'); + +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await navigator.mediaDevices.getUserMedia({audio:true}); + const audioTrack = stream.getAudioTracks()[0]; + t.add_cleanup(() => audioTrack.stop()); + + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + + const audioSender = caller.addTrack(audioTrack); + assert_throws_dom("InvalidStateError", () => audioSender.createEncodedStreams()); +}, 'RTCRtpSender.createEncodedStream() throws if not requested in PC configuration'); + +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await navigator.mediaDevices.getUserMedia({audio:true}); + const audioTrack = stream.getAudioTracks()[0]; + t.add_cleanup(() => audioTrack.stop()); + + const audioSender = caller.addTrack(audioTrack); + const ontrackPromise = new Promise(resolve => { + callee.ontrack = t.step_func(() => { + const audioReceiver = callee.getReceivers().find(r => r.track.kind === 'audio'); + assert_not_equals(audioReceiver, undefined); + assert_throws_dom("InvalidStateError", () => audioReceiver.createEncodedStreams()); + resolve(); + }); + }); + + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + return ontrackPromise; +}, 'RTCRtpReceiver.createEncodedStream() throws if not requested in PC configuration'); + +promise_test(async t => { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream = await navigator.mediaDevices.getUserMedia({audio:true}); + const track = stream.getTracks()[0]; + t.add_cleanup(() => track.stop()); + + const sender = caller.addTrack(track) + const streams = sender.createEncodedStreams(); + const transformer = new TransformStream({ + transform(frame, controller) { + // Inserting the same frame twice will result in failure since the frame + // will be neutered after the first insertion is processed. + controller.enqueue(frame); + controller.enqueue(frame); + } + }); + + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + + await promise_rejects_dom( + t, 'OperationError', + streams.readable.pipeThrough(transformer).pipeTo(streams.writable)); +}, 'Enqueuing the same frame twice fails'); + +promise_test(async t => { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const stream = await navigator.mediaDevices.getUserMedia({audio:true}); + const track = stream.getTracks()[0]; + t.add_cleanup(() => track.stop()); + const sender = caller.addTrack(track) + sender.createEncodedStreams(); + assert_throws_dom("InvalidStateError", () => sender.createEncodedStreams()); +}, 'Creating streams twice throws'); + +</script> +</body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-errors.https.html b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-errors.https.html new file mode 100644 index 0000000000..56ba3ee972 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-errors.https.html @@ -0,0 +1,87 @@ +<!DOCTYPE html> +<html> +<head> +<title>RTCPeerConnection Insertable Streams - Errors</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="./RTCPeerConnection-insertable-streams.js"></script> +</head> +<body> +<script> +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + await setMediaPermission("granted", ["camera"]); + const stream = await navigator.mediaDevices.getUserMedia({video:true}); + const videoTrack = stream.getVideoTracks()[0]; + t.add_cleanup(() => videoTrack.stop()); + + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + + const videoSender = caller.addTrack(videoTrack); + assert_throws_dom("InvalidStateError", () => videoSender.createEncodedStreams()); +}, 'RTCRtpSender.createEncodedStream() throws if not requested in PC configuration'); + +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await navigator.mediaDevices.getUserMedia({video:true}); + const videoTrack = stream.getVideoTracks()[0]; + t.add_cleanup(() => videoTrack.stop()); + + const videoSender = caller.addTrack(videoTrack); + const ontrackPromise = new Promise(resolve => { + callee.ontrack = t.step_func(() => { + const videoReceiver = callee.getReceivers().find(r => r.track.kind === 'video'); + assert_not_equals(videoReceiver, undefined); + assert_throws_dom("InvalidStateError", () => videoReceiver.createEncodedStreams()); + resolve(); + }); + }); + + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + return ontrackPromise; +}, 'RTCRtpReceiver.createEncodedStream() throws if not requested in PC configuration'); + +promise_test(async t => { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream = await navigator.mediaDevices.getUserMedia({video:true}); + const track = stream.getTracks()[0]; + t.add_cleanup(() => track.stop()); + + const sender = caller.addTrack(track) + const streams = sender.createEncodedStreams(); + const transformer = new TransformStream({ + transform(frame, controller) { + // Inserting the same frame twice will result in failure since the frame + // will be neutered after the first insertion is processed. + controller.enqueue(frame); + controller.enqueue(frame); + } + }); + + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + + await promise_rejects_dom( + t, 'OperationError', + streams.readable.pipeThrough(transformer).pipeTo(streams.writable)); +}, 'Enqueuing the same frame twice fails'); + +</script> +</body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-simulcast.https.html b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-simulcast.https.html new file mode 100644 index 0000000000..cb33e458d1 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-simulcast.https.html @@ -0,0 +1,89 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Insertable Streams Simulcast</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="../webrtc/third_party/sdp/sdp.js"></script> +<script src="../webrtc/simulcast/simulcast.js"></script> +<script> +// Test based on wpt/webrtc/simulcast/basic.https.html +promise_test(async t => { + const rids = [0, 1, 2]; + const pc1 = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => pc2.close()); + + exchangeIceCandidates(pc1, pc2); + + const metadataToBeLoaded = []; + let receiverSSRCs = [] + pc2.ontrack = t.step_func(e => { + const receiverTransformer = new TransformStream({ + async transform(encodedFrame, controller) { + let ssrc = encodedFrame.getMetadata().synchronizationSource; + if (receiverSSRCs.indexOf(ssrc) == -1) + receiverSSRCs.push(ssrc); + controller.enqueue(encodedFrame); + } + }); + const receiverStreams = e.receiver.createEncodedStreams(); + receiverStreams.readable + .pipeThrough(receiverTransformer) + .pipeTo(receiverStreams.writable); + + const stream = e.streams[0]; + const v = document.createElement('video'); + v.autoplay = true; + v.srcObject = stream; + v.id = stream.id + metadataToBeLoaded.push(new Promise((resolve) => { + v.addEventListener('loadedmetadata', () => { + resolve(); + }); + })); + }); + + await setMediaPermission("granted", ["camera"]); + const stream = await navigator.mediaDevices.getUserMedia({video: {width: 1280, height: 720}}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const transceiver = pc1.addTransceiver(stream.getVideoTracks()[0], { + streams: [stream], + sendEncodings: rids.map(rid => {rid}), + }); + const senderStreams = transceiver.sender.createEncodedStreams(); + let senderSSRCs = []; + const senderTransformer = new TransformStream({ + async transform(encodedFrame, controller) { + if (senderSSRCs.indexOf(encodedFrame.getMetadata().synchronizationSource) == -1) + senderSSRCs.push(encodedFrame.getMetadata().synchronizationSource); + controller.enqueue(encodedFrame); + } + }); + senderStreams.readable + .pipeThrough(senderTransformer) + .pipeTo(senderStreams.writable); + + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer), + await pc2.setRemoteDescription({ + type: 'offer', + sdp: swapRidAndMidExtensionsInSimulcastOffer(offer, rids), + }); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription({ + type: 'answer', + sdp: swapRidAndMidExtensionsInSimulcastAnswer(answer, pc1.localDescription, rids), + }); + assert_equals(metadataToBeLoaded.length, 3); + await Promise.all(metadataToBeLoaded); + // Ensure that frames from the 3 simulcast layers are exposed. + assert_equals(senderSSRCs.length, 3); + assert_equals(receiverSSRCs.length, 3); +}, 'Basic simulcast setup with three spatial layers'); +</script> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-video-frames.https.html b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-video-frames.https.html new file mode 100644 index 0000000000..d7fb088846 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-video-frames.https.html @@ -0,0 +1,80 @@ +<!DOCTYPE html> +<html> +<head> +<title>RTCPeerConnection Insertable Streams - Video Frames</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="./RTCPeerConnection-insertable-streams.js"></script> +</head> +<body> +<script> +promise_test(async t => { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => callee.close()); + + await setMediaPermission("granted", ["camera"]); + const stream = await navigator.mediaDevices.getUserMedia({video:true}); + const track = stream.getTracks()[0]; + t.add_cleanup(() => track.stop()); + + const sender = caller.addTrack(track) + const senderStreams = sender.createEncodedStreams(); + const senderReader = senderStreams.readable.getReader(); + const senderWriter = senderStreams.writable.getWriter(); + const numFramesToSend = 20; + + const ontrackPromise = new Promise((resolve, reject) => { + callee.ontrack = async e => { + const receiverStreams = e.receiver.createEncodedStreams(); + const receiverReader = receiverStreams.readable.getReader(); + + let numReceivedKeyFrames = 0; + let numReceivedDeltaFrames = 0; + for (let i = 0; i < numFramesToSend; i++) { + const result = await receiverReader.read(); + if (result.value.type == 'key') + numReceivedKeyFrames++; + else if (result.value.type == 'delta') + numReceivedDeltaFrames++; + + if (numReceivedKeyFrames > 0 && numReceivedDeltaFrames > 0) + resolve(); + else if (numReceivedKeyFrames + numReceivedDeltaFrames >= numFramesToSend) + reject(); + } + } + }); + + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + + let numSentKeyFrames = 0; + let numSentDeltaFrames = 0; + // Pass frames as they come from the encoder. + for (let i = 0; i < numFramesToSend; i++) { + const result = await senderReader.read(); + verifyNonstandardAdditionalDataIfPresent(result.value); + if (result.value.type == 'key') { + numSentKeyFrames++; + } else { + numSentDeltaFrames++; + } + + senderWriter.write(result.value); + } + + assert_greater_than(numSentKeyFrames, 0); + assert_greater_than(numSentDeltaFrames, 0); + + return ontrackPromise; +}, 'Key and Delta frames are sent and received'); + +</script> +</body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-video.https.html b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-video.https.html new file mode 100644 index 0000000000..378520c693 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-video.https.html @@ -0,0 +1,149 @@ +<!DOCTYPE html> +<html> +<head> +<title>RTCPeerConnection Insertable Streams - Video</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="./RTCPeerConnection-insertable-streams.js"></script> +</head> +<body> +<script> +async function testVideoFlow(t, negotiationFunction) { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => callee.close()); + + await setMediaPermission("granted", ["camera"]); + const stream = await navigator.mediaDevices.getUserMedia({video:true}); + const videoTrack = stream.getVideoTracks()[0]; + t.add_cleanup(() => videoTrack.stop()); + + const videoSender = caller.addTrack(videoTrack) + const senderStreams = videoSender.createEncodedStreams(); + const senderReader = senderStreams.readable.getReader(); + const senderWriter = senderStreams.writable.getWriter(); + + const frameInfos = []; + const numFramesPassthrough = 5; + const numFramesReplaceData = 5; + const numFramesModifyData = 5; + const numFramesToSend = numFramesPassthrough + numFramesReplaceData + numFramesModifyData; + + const ontrackPromise = new Promise(resolve => { + callee.ontrack = t.step_func(() => { + const videoReceiver = callee.getReceivers().find(r => r.track.kind === 'video'); + assert_not_equals(videoReceiver, undefined); + + const receiverStreams = + videoReceiver.createEncodedStreams(); + const receiverReader = receiverStreams.readable.getReader(); + const receiverWriter = receiverStreams.writable.getWriter(); + + const maxFramesToReceive = numFramesToSend; + let numVerifiedFrames = 0; + for (let i = 0; i < maxFramesToReceive; i++) { + receiverReader.read().then(t.step_func(result => { + verifyNonstandardAdditionalDataIfPresent(result.value); + if (frameInfos[numVerifiedFrames] && + areFrameInfosEqual(result.value, frameInfos[numVerifiedFrames])) { + numVerifiedFrames++; + } else { + // Receiving unexpected frames is an indication that + // frames are not passed correctly between sender and receiver. + assert_unreached("Incorrect frame received"); + } + + if (numVerifiedFrames == numFramesToSend) + resolve(); + })); + } + }); + }); + + exchangeIceCandidates(caller, callee); + await negotiationFunction(caller, callee); + + // Pass frames as they come from the encoder. + for (let i = 0; i < numFramesPassthrough; i++) { + const result = await senderReader.read(); + const metadata = result.value.getMetadata(); + assert_true(containsVideoMetadata(metadata)); + verifyNonstandardAdditionalDataIfPresent(result.value); + frameInfos.push({ + timestamp: result.value.timestamp, + type: result.value.type, + data: result.value.data, + metadata: metadata, + getMetadata() { return this.metadata; } + }); + senderWriter.write(result.value); + } + + // Replace frame data with arbitrary buffers. + for (let i = 0; i < numFramesReplaceData; i++) { + const result = await senderReader.read(); + const metadata = result.value.getMetadata(); + assert_true(containsVideoMetadata(metadata)); + const buffer = new ArrayBuffer(100); + const int8View = new Int8Array(buffer); + int8View.fill(i); + + result.value.data = buffer; + frameInfos.push({ + timestamp: result.value.timestamp, + type: result.value.type, + data: result.value.data, + metadata: metadata, + getMetadata() { return this.metadata; } + }); + senderWriter.write(result.value); + } + + // Modify frame data. + for (let i = 0; i < numFramesReplaceData; i++) { + const result = await senderReader.read(); + const metadata = result.value.getMetadata(); + assert_true(containsVideoMetadata(metadata)); + const int8View = new Int8Array(result.value.data); + int8View.fill(i); + + frameInfos.push({ + timestamp: result.value.timestamp, + type: result.value.type, + data: result.value.data, + metadata: metadata, + getMetadata() { return this.metadata; } + }); + senderWriter.write(result.value); + } + + return ontrackPromise; +} + +promise_test(async t => { + return testVideoFlow(t, exchangeOfferAnswer); +}, 'Frames flow correctly using insertable streams'); + +promise_test(async t => { + return testVideoFlow(t, exchangeOfferAnswerReverse); +}, 'Frames flow correctly using insertable streams when receiver starts negotiation'); + +promise_test(async t => { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const stream = await navigator.mediaDevices.getUserMedia({video:true}); + const track = stream.getTracks()[0]; + t.add_cleanup(() => track.stop()); + const sender = caller.addTrack(track) + sender.createEncodedStreams(); + assert_throws_dom("InvalidStateError", () => sender.createEncodedStreams()); +}, 'Creating streams twice throws'); + +</script> +</body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-worker.https.html b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-worker.https.html new file mode 100644 index 0000000000..cb31057cac --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams-worker.https.html @@ -0,0 +1,196 @@ +<!DOCTYPE html> +<html> +<head> +<title>RTCPeerConnection Insertable Streams - Worker</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="./RTCPeerConnection-insertable-streams.js"></script> +</head> +<body> +<script> +promise_test(async t => { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + // Video is used in a later test, so we ask for both permissions + await setMediaPermission(); + const stream = await navigator.mediaDevices.getUserMedia({audio:true}); + const track = stream.getTracks()[0]; + t.add_cleanup(() => track.stop()); + + const sender = caller.addTrack(track) + const senderStreams = sender.createEncodedStreams(); + + const senderWorker = new Worker('RTCPeerConnection-sender-worker-single-frame.js') + t.add_cleanup(() => senderWorker.terminate()); + senderWorker.postMessage( + {readableStream: senderStreams.readable}, + [senderStreams.readable]); + + let expectedFrameData = null; + let verifiedFrameData = false; + let numVerifiedFrames = 0; + const onmessagePromise = new Promise(resolve => { + senderWorker.onmessage = t.step_func(message => { + if (!(message.data instanceof RTCEncodedAudioFrame)) { + // This is the first message sent from the Worker to the test. + // It contains an object (not an RTCEncodedAudioFrame) with the same + // fields as the RTCEncodedAudioFrame to be sent in follow-up messages. + // These serve as expected values to validate that the + // RTCEncodedAudioFrame is sent correctly back to the test in the next + // message. + expectedFrameData = message.data; + } else { + // This is the frame sent by the Worker after reading it from the + // readable stream. The Worker sends it twice after sending the + // verification message. + assert_equals(message.data.type, expectedFrameData.type); + assert_equals(message.data.timestamp, expectedFrameData.timestamp); + assert_true(areArrayBuffersEqual(message.data.data, expectedFrameData.data)); + if (++numVerifiedFrames == 2) + resolve(); + } + }); + }); + + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + + return onmessagePromise; +}, 'RTCRtpSender readable stream transferred to a Worker and the Worker sends an RTCEncodedAudioFrame back'); + +promise_test(async t => { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream = await navigator.mediaDevices.getUserMedia({video:true}); + const videoTrack = stream.getVideoTracks()[0]; + t.add_cleanup(() => videoTrack.stop()); + + const videoSender = caller.addTrack(videoTrack) + const senderStreams = videoSender.createEncodedStreams(); + + const senderWorker = new Worker('RTCPeerConnection-sender-worker-single-frame.js') + t.add_cleanup(() => senderWorker.terminate()); + senderWorker.postMessage( + {readableStream: senderStreams.readable}, + [senderStreams.readable]); + + let expectedFrameData = null; + let verifiedFrameData = false; + let numVerifiedFrames = 0; + const onmessagePromise = new Promise(resolve => { + senderWorker.onmessage = t.step_func(message => { + if (!(message.data instanceof RTCEncodedVideoFrame)) { + // This is the first message sent from the Worker to the test. + // It contains an object (not an RTCEncodedVideoFrame) with the same + // fields as the RTCEncodedVideoFrame to be sent in follow-up messages. + // These serve as expected values to validate that the + // RTCEncodedVideoFrame is sent correctly back to the test in the next + // message. + expectedFrameData = message.data; + } else { + // This is the frame sent by the Worker after reading it from the + // readable stream. The Worker sends it twice after sending the + // verification message. + assert_equals(message.data.type, expectedFrameData.type); + assert_equals(message.data.timestamp, expectedFrameData.timestamp); + assert_true(areArrayBuffersEqual(message.data.data, expectedFrameData.data)); + assert_equals(message.data.getMetadata().synchronizationSource, expectedFrameData.metadata.synchronizationSource); + if (++numVerifiedFrames == 2) + resolve(); + } + }); + }); + + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + + return onmessagePromise; +}, 'RTCRtpSender readable stream transferred to a Worker and the Worker sends an RTCEncodedVideoFrame back'); + +promise_test(async t => { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream = await navigator.mediaDevices.getUserMedia({video:true}); + const videoTrack = stream.getVideoTracks()[0]; + t.add_cleanup(() => videoTrack.stop()); + + const videoSender = caller.addTrack(videoTrack) + const senderStreams = videoSender.createEncodedStreams(); + + const senderWorker = new Worker('RTCPeerConnection-worker-transform.js') + t.add_cleanup(() => senderWorker.terminate()); + senderWorker.postMessage( + { + readableStream: senderStreams.readable, + writableStream: senderStreams.writable, + insertError: true + }, + [senderStreams.readable, senderStreams.writable]); + + const onmessagePromise = new Promise(resolve => { + senderWorker.onmessage = t.step_func(message => { + assert_false(message.data.success); + assert_true(message.data.error instanceof TypeError); + resolve(); + }); + }); + + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + + return onmessagePromise; +}, 'Video RTCRtpSender insertable streams transferred to a worker, which tries to write an invalid frame'); + +promise_test(async t => { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream = await navigator.mediaDevices.getUserMedia({audio:true}); + const audioTrack = stream.getAudioTracks()[0]; + t.add_cleanup(() => audioTrack.stop()); + + const audioSender = caller.addTrack(audioTrack) + const senderStreams = audioSender.createEncodedStreams(); + + const senderWorker = new Worker('RTCPeerConnection-worker-transform.js') + t.add_cleanup(() => senderWorker.terminate()); + senderWorker.postMessage( + { + readableStream: senderStreams.readable, + writableStream: senderStreams.writable, + insertError: true + }, + [senderStreams.readable, senderStreams.writable]); + + const onmessagePromise = new Promise(resolve => { + senderWorker.onmessage = t.step_func(message => { + assert_false(message.data.success); + assert_true(message.data.error instanceof TypeError); + resolve(); + }); + }); + + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + + return onmessagePromise; +}, 'Audio RTCRtpSender insertable streams transferred to a worker, which tries to write an invalid frame'); + +</script> +</body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams.js b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams.js new file mode 100644 index 0000000000..f1b872294b --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-insertable-streams.js @@ -0,0 +1,242 @@ +function areArrayBuffersEqual(buffer1, buffer2) +{ + if (buffer1.byteLength !== buffer2.byteLength) { + return false; + } + let array1 = new Int8Array(buffer1); + var array2 = new Int8Array(buffer2); + for (let i = 0 ; i < buffer1.byteLength ; ++i) { + if (array1[i] !== array2[i]) { + return false; + } + } + return true; +} + +function areArraysEqual(a1, a2) { + if (a1 === a1) + return true; + if (a1.length != a2.length) + return false; + for (let i = 0; i < a1.length; i++) { + if (a1[i] != a2[i]) + return false; + } + return true; +} + +function areMetadataEqual(metadata1, metadata2, type) { + return metadata1.synchronizationSource === metadata2.synchronizationSource && + metadata1.payloadType == metadata2.payloadType && + areArraysEqual(metadata1.contributingSources, metadata2.contributingSources) && + metadata1.frameId === metadata2.frameId && + areArraysEqual(metadata1.dependencies, metadata2.dependencies) && + metadata1.spatialIndex === metadata2.spatialIndex && + metadata1.temporalIndex === metadata2.temporalIndex && + // Width and height are reported only for key frames on the receiver side. + type == "key" + ? metadata1.width === metadata2.width && metadata1.height === metadata2.height + : true; +} + +function areFrameInfosEqual(frame1, frame2) { + return frame1.timestamp === frame2.timestamp && + frame1.type === frame2.type && + areMetadataEqual(frame1.getMetadata(), frame2.getMetadata(), frame1.type) && + areArrayBuffersEqual(frame1.data, frame2.data); +} + +function containsVideoMetadata(metadata) { + return metadata.synchronizationSource !== undefined && + metadata.width !== undefined && + metadata.height !== undefined && + metadata.spatialIndex !== undefined && + metadata.temporalIndex !== undefined && + metadata.dependencies !== undefined; +} + +function enableGFD(sdp) { + const GFD_V00_EXTENSION = + 'http://www.webrtc.org/experiments/rtp-hdrext/generic-frame-descriptor-00'; + if (sdp.indexOf(GFD_V00_EXTENSION) !== -1) + return sdp; + + const extensionIds = sdp.trim().split('\n') + .map(line => line.trim()) + .filter(line => line.startsWith('a=extmap:')) + .map(line => line.split(' ')[0].substr(9)) + .map(id => parseInt(id, 10)) + .sort((a, b) => a - b); + for (let newId = 1; newId <= 14; newId++) { + if (!extensionIds.includes(newId)) { + return sdp += 'a=extmap:' + newId + ' ' + GFD_V00_EXTENSION + '\r\n'; + } + } + if (sdp.indexOf('a=extmap-allow-mixed') !== -1) { // Pick the next highest one. + const newId = extensionIds[extensionIds.length - 1] + 1; + return sdp += 'a=extmap:' + newId + ' ' + GFD_V00_EXTENSION + '\r\n'; + } + throw 'Could not find free extension id to use for ' + GFD_V00_EXTENSION; +} + +async function exchangeOfferAnswer(pc1, pc2) { + const offer = await pc1.createOffer(); + // Munge the SDP to enable the GFD extension in order to get correct metadata. + const sdpGFD = enableGFD(offer.sdp); + await pc1.setLocalDescription({type: offer.type, sdp: sdpGFD}); + // Munge the SDP to disable bandwidth probing via RTX. + // TODO(crbug.com/1066819): remove this hack when we do not receive duplicates from RTX + // anymore. + const sdpRTX = sdpGFD.replace(new RegExp('rtx', 'g'), 'invalid'); + await pc2.setRemoteDescription({type: 'offer', sdp: sdpRTX}); + + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); +} + +async function exchangeOfferAnswerReverse(pc1, pc2) { + const offer = await pc2.createOffer({offerToReceiveAudio: true, offerToReceiveVideo: true}); + // Munge the SDP to enable the GFD extension in order to get correct metadata. + const sdpGFD = enableGFD(offer.sdp); + // Munge the SDP to disable bandwidth probing via RTX. + // TODO(crbug.com/1066819): remove this hack when we do not receive duplicates from RTX + // anymore. + const sdpRTX = sdpGFD.replace(new RegExp('rtx', 'g'), 'invalid'); + await pc1.setRemoteDescription({type: 'offer', sdp: sdpRTX}); + await pc2.setLocalDescription({type: 'offer', sdp: sdpGFD}); + + const answer = await pc1.createAnswer(); + await pc2.setRemoteDescription(answer); + await pc1.setLocalDescription(answer); +} + +function createFrameDescriptor(videoFrame) { + const kMaxSpatialLayers = 8; + const kMaxTemporalLayers = 8; + const kMaxNumFrameDependencies = 8; + + const metadata = videoFrame.getMetadata(); + let frameDescriptor = { + beginningOfSubFrame: true, + endOfSubframe: false, + frameId: metadata.frameId & 0xFFFF, + spatialLayers: 1 << metadata.spatialIndex, + temporalLayer: metadata.temporalLayer, + frameDependenciesDiffs: [], + width: 0, + height: 0 + }; + + for (const dependency of metadata.dependencies) { + frameDescriptor.frameDependenciesDiffs.push(metadata.frameId - dependency); + } + if (metadata.dependencies.length == 0) { + frameDescriptor.width = metadata.width; + frameDescriptor.height = metadata.height; + } + return frameDescriptor; +} + +function additionalDataSize(descriptor) { + if (!descriptor.beginningOfSubFrame) { + return 1; + } + + let size = 4; + for (const fdiff of descriptor.frameDependenciesDiffs) { + size += (fdiff >= (1 << 6)) ? 2 : 1; + } + if (descriptor.beginningOfSubFrame && + descriptor.frameDependenciesDiffs.length == 0 && + descriptor.width > 0 && + descriptor.height > 0) { + size += 4; + } + + return size; +} + +// Compute the buffer reported in the additionalData field using the metadata +// provided by a video frame. +// Based on the webrtc::RtpDescriptorAuthentication() C++ function at +// https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/modules/rtp_rtcp/source/rtp_descriptor_authentication.cc +function computeAdditionalData(videoFrame) { + const kMaxSpatialLayers = 8; + const kMaxTemporalLayers = 8; + const kMaxNumFrameDependencies = 8; + + const metadata = videoFrame.getMetadata(); + if (metadata.spatialIndex < 0 || + metadata.temporalIndex < 0 || + metadata.spatialIndex >= kMaxSpatialLayers || + metadata.temporalIndex >= kMaxTemporalLayers || + metadata.dependencies.length > kMaxNumFrameDependencies) { + return new ArrayBuffer(0); + } + + const descriptor = createFrameDescriptor(videoFrame); + const size = additionalDataSize(descriptor); + const additionalData = new ArrayBuffer(size); + const data = new Uint8Array(additionalData); + + const kFlagBeginOfSubframe = 0x80; + const kFlagEndOfSubframe = 0x40; + const kFlagFirstSubframeV00 = 0x20; + const kFlagLastSubframeV00 = 0x10; + + const kFlagDependencies = 0x08; + const kFlagMoreDependencies = 0x01; + const kFlageXtendedOffset = 0x02; + + let baseHeader = + (descriptor.beginningOfSubFrame ? kFlagBeginOfSubframe : 0) | + (descriptor.endOfSubFrame ? kFlagEndOfSubframe : 0); + baseHeader |= kFlagFirstSubframeV00; + baseHeader |= kFlagLastSubframeV00; + + if (!descriptor.beginningOfSubFrame) { + data[0] = baseHeader; + return additionalData; + } + + data[0] = + baseHeader | + (descriptor.frameDependenciesDiffs.length == 0 ? 0 : kFlagDependencies) | + descriptor.temporalLayer; + data[1] = descriptor.spatialLayers; + data[2] = descriptor.frameId & 0xFF; + data[3] = descriptor.frameId >> 8; + + const fdiffs = descriptor.frameDependenciesDiffs; + let offset = 4; + if (descriptor.beginningOfSubFrame && + fdiffs.length == 0 && + descriptor.width > 0 && + descriptor.height > 0) { + data[offset++] = (descriptor.width >> 8); + data[offset++] = (descriptor.width & 0xFF); + data[offset++] = (descriptor.height >> 8); + data[offset++] = (descriptor.height & 0xFF); + } + for (let i = 0; i < fdiffs.length; i++) { + const extended = fdiffs[i] >= (1 << 6); + const more = i < fdiffs.length - 1; + data[offset++] = ((fdiffs[i] & 0x3f) << 2) | + (extended ? kFlageXtendedOffset : 0) | + (more ? kFlagMoreDependencies : 0); + if (extended) { + data[offset++] = fdiffs[i] >> 6; + } + } + return additionalData; +} + +function verifyNonstandardAdditionalDataIfPresent(videoFrame) { + if (videoFrame.additionalData === undefined) + return; + + const computedData = computeAdditionalData(videoFrame); + assert_true(areArrayBuffersEqual(videoFrame.additionalData, computedData)); +} + diff --git a/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-sender-worker-single-frame.js b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-sender-worker-single-frame.js new file mode 100644 index 0000000000..c943dafe5b --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-sender-worker-single-frame.js @@ -0,0 +1,19 @@ +onmessage = async (event) => { + const readableStream = event.data.readableStream; + const reader = readableStream.getReader(); + const result = await reader.read(); + + // Post an object with individual fields so that the test side has + // values to verify the serialization of the RTCEncodedVideoFrame. + postMessage({ + type: result.value.type, + timestamp: result.value.timestamp, + data: result.value.data, + metadata: result.value.getMetadata(), + }); + + // Send the frame twice to verify that the frame does not change after the + // first serialization. + postMessage(result.value); + postMessage(result.value); +} diff --git a/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-worker-transform.js b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-worker-transform.js new file mode 100644 index 0000000000..36e3949e4d --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/RTCPeerConnection-worker-transform.js @@ -0,0 +1,22 @@ +onmessage = async (event) => { + const readableStream = event.data.readableStream; + const writableStream = event.data.writableStream; + const insertError = event.data.insertError; + + try { + await readableStream.pipeThrough(new TransformStream({ + transform: (chunk, controller) => { + if (insertError) { + controller.enqueue("This is not a valid frame"); + } else { + controller.enqueue(chunk); + } + } + })).pipeTo(writableStream); + + postMessage({success:true}); + } catch(e) { + postMessage({success:false, error: e}); + } + +} diff --git a/testing/web-platform/tests/webrtc-encoded-transform/codec-specific-metadata.https.html b/testing/web-platform/tests/webrtc-encoded-transform/codec-specific-metadata.https.html new file mode 100644 index 0000000000..bef61b39f3 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/codec-specific-metadata.https.html @@ -0,0 +1,45 @@ +<!DOCTYPE html> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="../service-workers/service-worker/resources/test-helpers.sub.js"></script> +<script src='./helper.js'></script> +<script> +"use strict"; + +promise_test(async t => { + const senderReader = await setupLoopbackWithCodecAndGetReader(t, 'VP8'); + const result = await senderReader.read(); + const metadata = result.value.getMetadata(); + // RTCEncodedVideoFrameAdditionalMetadata-only fields. + assert_true(Array.isArray(metadata.decodeTargetIndications), + 'decodeTargetIndication is an array'); + assert_equals(typeof metadata.isLastFrameInPicture, 'boolean', + 'isLastFrameInPicture is a boolean'); + assert_equals(typeof metadata.simulcastIdx, 'number', + 'simulcastIdx is a number'); + assert_equals(metadata.codec, 'vp8'); + assert_equals(typeof metadata.codecSpecifics, 'object', + 'codecSpecifics is an object'); + // VP8-only + assert_equals(typeof metadata.codecSpecifics.nonReference, 'boolean', + 'codecSpecifics.nonReference is a boolean'); + assert_equals(typeof metadata.codecSpecifics.pictureId, 'number', + 'codecSpecifics.pictureId is a number'); + assert_equals(typeof metadata.codecSpecifics.tl0PicIdx, 'number', + 'codecSpecifics.tl0PicIdx is a number'); + assert_equals(typeof metadata.codecSpecifics.temporalIdx, 'number', + 'codecSpecifics.temporalIdx is a number'); + assert_equals(typeof metadata.codecSpecifics.layerSync, 'boolean', + 'codecSpecifics.layerSync is a boolean'); + assert_equals(typeof metadata.codecSpecifics.keyIdx, 'number', + 'codecSpecifics.keyIdx is a number'); + assert_equals(typeof metadata.codecSpecifics.partitionId, 'number', + 'codecSpecifics.partitionId is a number'); + assert_equals(typeof metadata.codecSpecifics.beginningOfPartition, 'boolean', + 'codecSpecifics.beginningOfPartition is a boolean'); +}, "[VP8] getMetadata() supports the expected codec specifics"); +</script> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/helper.js b/testing/web-platform/tests/webrtc-encoded-transform/helper.js new file mode 100644 index 0000000000..d4cec39ffc --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/helper.js @@ -0,0 +1,26 @@ +"use strict"; + +async function setupLoopbackWithCodecAndGetReader(t, codec) { + const caller = new RTCPeerConnection({encodedInsertableStreams:true}); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + await setMediaPermission("granted", ["camera"]); + const stream = await navigator.mediaDevices.getUserMedia({video:true}); + const videoTrack = stream.getVideoTracks()[0]; + t.add_cleanup(() => videoTrack.stop()); + + const transceiver = caller.addTransceiver(videoTrack); + const codecCapability = + RTCRtpSender.getCapabilities('video').codecs.find(capability => { + return capability.mimeType.includes(codec); + }); + assert_not_equals(codecCapability, undefined); + transceiver.setCodecPreferences([codecCapability]); + + const senderStreams = transceiver.sender.createEncodedStreams(); + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + return senderStreams.readable.getReader(); +} diff --git a/testing/web-platform/tests/webrtc-encoded-transform/idlharness.https.window.js b/testing/web-platform/tests/webrtc-encoded-transform/idlharness.https.window.js new file mode 100644 index 0000000000..2c6ef19ca8 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/idlharness.https.window.js @@ -0,0 +1,18 @@ +// META: script=/resources/WebIDLParser.js +// META: script=/resources/idlharness.js +// META: script=./RTCPeerConnection-helper.js + +'use strict'; + +idl_test( + ['webrtc-encoded-transform'], + ['webrtc', 'streams', 'html', 'dom'], + async idlArray => { + idlArray.add_objects({ + // TODO: RTCEncodedVideoFrame + // TODO: RTCEncodedAudioFrame + RTCRtpSender: [`new RTCPeerConnection().addTransceiver('audio').sender`], + RTCRtpReceiver: [`new RTCPeerConnection().addTransceiver('audio').receiver`], + }); + } +); diff --git a/testing/web-platform/tests/webrtc-encoded-transform/resources/blank.html b/testing/web-platform/tests/webrtc-encoded-transform/resources/blank.html new file mode 100644 index 0000000000..a3c3a4689a --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/resources/blank.html @@ -0,0 +1,2 @@ +<!DOCTYPE html> +<title>Empty doc</title> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/resources/serviceworker-failure.js b/testing/web-platform/tests/webrtc-encoded-transform/resources/serviceworker-failure.js new file mode 100644 index 0000000000..e7aa8e11be --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/resources/serviceworker-failure.js @@ -0,0 +1,30 @@ +// Based on similar tests in html/infrastructure/safe-passing-of-structured-data/shared-array-buffers/. +"use strict"; +self.importScripts("/resources/testharness.js"); + +let state = "start in worker"; + +self.onmessage = e => { + if (e.data === "start in window") { + assert_equals(state, "start in worker"); + e.source.postMessage(state); + state = "we are expecting a messageerror due to the window sending us an RTCEncodedVideoFrame or RTCEncodedAudioFrame"; + } else { + e.source.postMessage(`worker onmessage was reached when in state "${state}" and data ${e.data}`); + } +}; + +self.onmessageerror = e => { + if (state === "we are expecting a messageerror due to the window sending us an RTCEncodedVideoFrame or RTCEncodedAudioFrame") { + assert_equals(e.constructor.name, "ExtendableMessageEvent", "type"); + assert_equals(e.data, null, "data"); + assert_equals(e.origin, self.origin, "origin"); + assert_not_equals(e.source, null, "source"); + assert_equals(e.ports.length, 0, "ports length"); + + state = "onmessageerror was received in worker"; + e.source.postMessage(state); + } else { + e.source.postMessage(`worker onmessageerror was reached when in state "${state}" and data ${e.data}`); + } +}; diff --git a/testing/web-platform/tests/webrtc-encoded-transform/routines.js b/testing/web-platform/tests/webrtc-encoded-transform/routines.js new file mode 100644 index 0000000000..4db7f39621 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/routines.js @@ -0,0 +1,32 @@ +async function createConnections(test, setupLocalConnection, setupRemoteConnection, doNotCloseAutmoatically) { + const localConnection = new RTCPeerConnection(); + const remoteConnection = new RTCPeerConnection(); + + remoteConnection.onicecandidate = (event) => { localConnection.addIceCandidate(event.candidate); }; + localConnection.onicecandidate = (event) => { remoteConnection.addIceCandidate(event.candidate); }; + + await setupLocalConnection(localConnection); + await setupRemoteConnection(remoteConnection); + + const offer = await localConnection.createOffer(); + await localConnection.setLocalDescription(offer); + await remoteConnection.setRemoteDescription(offer); + + const answer = await remoteConnection.createAnswer(); + await remoteConnection.setLocalDescription(answer); + await localConnection.setRemoteDescription(answer); + + if (!doNotCloseAutmoatically) { + test.add_cleanup(() => { + localConnection.close(); + remoteConnection.close(); + }); + } + + return [localConnection, remoteConnection]; +} + +function waitFor(test, duration) +{ + return new Promise((resolve) => test.step_timeout(resolve, duration)); +} diff --git a/testing/web-platform/tests/webrtc-encoded-transform/script-audio-transform-worker.js b/testing/web-platform/tests/webrtc-encoded-transform/script-audio-transform-worker.js new file mode 100644 index 0000000000..7cb43713d3 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/script-audio-transform-worker.js @@ -0,0 +1,30 @@ +class MockRTCRtpTransformer { + constructor(transformer) { + this.context = transformer; + this.start(); + } + start() + { + this.reader = this.context.readable.getReader(); + this.writer = this.context.writable.getWriter(); + this.process(); + this.context.options.port.postMessage("started " + this.context.options.mediaType + " " + this.context.options.side); + } + + process() + { + this.reader.read().then(chunk => { + if (chunk.done) + return; + + this.writer.write(chunk.value); + this.process(); + }); + } +}; + +onrtctransform = (event) => { + new MockRTCRtpTransformer(event.transformer); +}; + +self.postMessage("registered"); diff --git a/testing/web-platform/tests/webrtc-encoded-transform/script-audio-transform.https.html b/testing/web-platform/tests/webrtc-encoded-transform/script-audio-transform.https.html new file mode 100644 index 0000000000..0d7f401582 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/script-audio-transform.https.html @@ -0,0 +1,69 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> +<script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> + </head> + <body> + <video id="video" autoplay playsInline></video> + <script src="routines.js"></script> + <script> +function waitForMessage(test, port, data) +{ + let gotMessage; + const promise = new Promise((resolve, reject) => { + gotMessage = resolve; + test.step_timeout(() => { reject("did not get " + data) }, 5000); + }); + port.onmessage = event => { + if (event.data === data) + gotMessage(); + }; + return promise; +} + +promise_test(async (test) => { + worker = new Worker("script-audio-transform-worker.js"); + const data = await new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + assert_equals(data, "registered"); + + await setMediaPermission("granted", ["microphone"]); + const localStream = await navigator.mediaDevices.getUserMedia({audio: true}); + + const senderChannel = new MessageChannel; + const receiverChannel = new MessageChannel; + const senderTransform = new RTCRtpScriptTransform(worker, {name:'MockRTCRtpTransform', mediaType:'audio', side:'sender', port:senderChannel.port2}, [senderChannel.port2]); + const receiverTransform = new RTCRtpScriptTransform(worker, {name:'MockRTCRtpTransform', mediaType:'audio', side:'receiver', port:receiverChannel.port2}, [receiverChannel.port2]); + senderTransform.port = senderChannel.port1; + receiverTransform.port = receiverChannel.port1; + + promise1 = waitForMessage(test, senderTransform.port, "started audio sender"); + promise2 = waitForMessage(test, receiverTransform.port, "started audio receiver"); + + const stream = await new Promise((resolve, reject) => { + createConnections(test, (firstConnection) => { + sender = firstConnection.addTrack(localStream.getAudioTracks()[0], localStream); + sender.transform = senderTransform; + }, (secondConnection) => { + secondConnection.ontrack = (trackEvent) => { + receiver = trackEvent.receiver; + receiver.transform = receiverTransform; + resolve(trackEvent.streams[0]); + }; + }); + test.step_timeout(() => reject("Test timed out"), 5000); + }); + + await promise1; + await promise2; + + video.srcObject = stream; + return video.play(); +}); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/script-change-transform-worker.js b/testing/web-platform/tests/webrtc-encoded-transform/script-change-transform-worker.js new file mode 100644 index 0000000000..84a7aaac18 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/script-change-transform-worker.js @@ -0,0 +1,39 @@ +function appendToBuffer(buffer, value) { + const result = new ArrayBuffer(buffer.byteLength + 1); + const byteResult = new Uint8Array(result); + byteResult.set(new Uint8Array(buffer), 0); + byteResult[buffer.byteLength] = value; + return result; +} + +onrtctransform = (event) => { + const transformer = event.transformer; + + transformer.reader = transformer.readable.getReader(); + transformer.writer = transformer.writable.getWriter(); + + function process(transformer) + { + transformer.reader.read().then(chunk => { + if (chunk.done) + return; + if (transformer.options.name === 'sender1') + chunk.value.data = appendToBuffer(chunk.value.data, 1); + else if (transformer.options.name === 'sender2') + chunk.value.data = appendToBuffer(chunk.value.data, 2); + else { + const value = new Uint8Array(chunk.value.data)[chunk.value.data.byteLength - 1]; + if (value !== 1 && value !== 2) + self.postMessage("unexpected value: " + value); + else if (value === 2) + self.postMessage("got value 2"); + chunk.value.data = chunk.value.data.slice(0, chunk.value.data.byteLength - 1); + } + transformer.writer.write(chunk.value); + process(transformer); + }); + } + + process(transformer); +}; +self.postMessage("registered"); diff --git a/testing/web-platform/tests/webrtc-encoded-transform/script-change-transform.https.html b/testing/web-platform/tests/webrtc-encoded-transform/script-change-transform.https.html new file mode 100644 index 0000000000..9ec82a9484 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/script-change-transform.https.html @@ -0,0 +1,61 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> +<script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> + </head> + <body> + <video id="video1" autoplay controls playsinline></video> + <script src ="routines.js"></script> + <script> +async function waitForMessage(worker, data) +{ + while (true) { + const received = await new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + if (data === received) + return; + } +} + +promise_test(async (test) => { + worker = new Worker('script-change-transform-worker.js'); + const data = await new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + assert_equals(data, "registered"); + + await setMediaPermission("granted", ["camera"]); + const localStream = await navigator.mediaDevices.getUserMedia({video: true}); + + let sender, receiver; + const senderTransform1 = new RTCRtpScriptTransform(worker, {name:'sender1'}); + const senderTransform2 = new RTCRtpScriptTransform(worker, {name:'sender2'}); + const receiverTransform = new RTCRtpScriptTransform(worker, {name:'receiver'}); + + const stream = await new Promise((resolve, reject) => { + createConnections(test, (firstConnection) => { + sender = firstConnection.addTrack(localStream.getVideoTracks()[0], localStream); + firstConnection.getTransceivers()[0].setCodecPreferences([{mimeType: "video/VP8", clockRate: 90000}]); + sender.transform = senderTransform1; + }, (secondConnection) => { + secondConnection.ontrack = (trackEvent) => { + receiver = trackEvent.receiver; + receiver.transform = receiverTransform; + resolve(trackEvent.streams[0]); + }; + }); + test.step_timeout(() => reject("Test timed out"), 5000); + }); + + video1.srcObject = stream; + await video1.play(); + + const updatePromise = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + sender.transform = senderTransform2; + assert_equals(await updatePromise, "got value 2"); +}, "change sender transform"); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/script-late-transform.https.html b/testing/web-platform/tests/webrtc-encoded-transform/script-late-transform.https.html new file mode 100644 index 0000000000..726852bad9 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/script-late-transform.https.html @@ -0,0 +1,94 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> +<script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> + </head> + <body> + <video controls id="video" autoplay></video> + <canvas id="canvas" width="640" height="480"></canvas> + <script src ="routines.js"></script> + <script> +function grabFrameData(x, y, w, h) +{ + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + canvas.getContext('2d').drawImage(video, x, y, w, h, x, y, w, h); + return canvas.getContext('2d').getImageData(x, y, w, h).data; +} + +function getCircleImageData() +{ + return grabFrameData(450, 100, 150, 100); +} + +async function checkVideoIsUpdated(test, shouldBeUpdated, count, referenceData) +{ + if (count === undefined) + count = 0; + else if (count >= 20) + return Promise.reject("checkVideoIsUpdated timed out :" + shouldBeUpdated + " " + count); + + if (referenceData === undefined) + referenceData = getCircleImageData(); + + await waitFor(test, 200); + const newData = getCircleImageData(); + + if (shouldBeUpdated === (JSON.stringify(referenceData) !== JSON.stringify(newData))) + return; + + await checkVideoIsUpdated(test, shouldBeUpdated, ++count, newData); +} + +promise_test(async (test) => { + await setMediaPermission("granted", ["camera"]); + const localStream = await navigator.mediaDevices.getUserMedia({video: true}); + const senderTransform = new SFrameTransform({ compatibilityMode: "H264" }); + const receiverTransform = new SFrameTransform({ compatibilityMode: "H264" }); + await crypto.subtle.importKey("raw", new Uint8Array([143, 77, 43, 10, 72, 19, 37, 67, 236, 219, 24, 93, 26, 165, 91, 178]), "HKDF", false, ["deriveBits", "deriveKey"]).then(key => { + senderTransform.setEncryptionKey(key); + receiverTransform.setEncryptionKey(key); + }); + + let sender, receiver; + const stream = await new Promise((resolve, reject) => { + createConnections(test, (firstConnection) => { + pc1 = firstConnection; + sender = firstConnection.addTrack(localStream.getVideoTracks()[0], localStream); + sender.transform = senderTransform; + }, (secondConnection) => { + pc2 = secondConnection; + secondConnection.ontrack = (trackEvent) => { + receiver = trackEvent.receiver; + // we do not set the receiver transform here; + resolve(trackEvent.streams[0]); + }; + }, { + observeOffer : (offer) => { + const lines = offer.sdp.split('\r\n'); + const h264Lines = lines.filter(line => line.indexOf("a=fmtp") === 0 && line.indexOf("42e01f") !== -1); + const baselineNumber = h264Lines[0].substring(6).split(' ')[0]; + offer.sdp = lines.filter(line => { + return (line.indexOf('a=fmtp') === -1 && line.indexOf('a=rtcp-fb') === -1 && line.indexOf('a=rtpmap') === -1) || line.indexOf(baselineNumber) !== -1; + }).join('\r\n'); + } + }); + test.step_timeout(() => reject("Test timed out"), 5000); + }); + + video.srcObject = stream; + video.play(); + + // We set the receiver transform here so that the decoder probably tried to decode sframe content. + test.step_timeout(() => receiver.transform = receiverTransform, 50); + await checkVideoIsUpdated(test, true); +}, "video exchange with late receiver transform"); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/script-metadata-transform-worker.js b/testing/web-platform/tests/webrtc-encoded-transform/script-metadata-transform-worker.js new file mode 100644 index 0000000000..03ba1f4ee6 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/script-metadata-transform-worker.js @@ -0,0 +1,24 @@ +onrtctransform = (event) => { + const transformer = event.transformer; + + transformer.reader = transformer.readable.getReader(); + transformer.writer = transformer.writable.getWriter(); + + let isFirstFrame = true; + function process(transformer) + { + transformer.reader.read().then(chunk => { + if (chunk.done) + return; + + if (isFirstFrame) { + isFirstFrame = false; + self.postMessage({ name: transformer.options.name, timestamp: chunk.value.timestamp, metadata: chunk.value.getMetadata() }); + } + transformer.writer.write(chunk.value); + process(transformer); + }); + } + process(transformer); +}; +self.postMessage("registered"); diff --git a/testing/web-platform/tests/webrtc-encoded-transform/script-metadata-transform.https.html b/testing/web-platform/tests/webrtc-encoded-transform/script-metadata-transform.https.html new file mode 100644 index 0000000000..c565caba7d --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/script-metadata-transform.https.html @@ -0,0 +1,91 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> +<script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> + </head> + <body> + <video id="video1" autoplay></video> + <script src ="routines.js"></script> + <script> +async function waitForMessage(worker, data) +{ + while (true) { + const received = await new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + if (data === received) + return; + } +} + +async function gatherMetadata(test, audio) +{ + worker = new Worker('script-metadata-transform-worker.js'); + const data = await new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + assert_equals(data, "registered"); + + // Both audio and vido are needed at one time or another + // so asking for both permissions + await setMediaPermission(); + const localStream = await navigator.mediaDevices.getUserMedia({audio: audio, video: !audio}); + + let sender, receiver; + const senderTransform = new RTCRtpScriptTransform(worker, {name:'sender'}); + const receiverTransform = new RTCRtpScriptTransform(worker, {name:'receiver'}); + + await new Promise((resolve, reject) => { + createConnections(test, (firstConnection) => { + pc1 = firstConnection; + sender = firstConnection.addTrack(localStream.getTracks()[0], localStream); + sender.transform = senderTransform; + }, (secondConnection) => { + pc2 = secondConnection; + secondConnection.ontrack = (trackEvent) => { + receiver = trackEvent.receiver; + receiver.transform = receiverTransform; + resolve(trackEvent.streams[0]); + }; + }); + test.step_timeout(() => reject("Test timed out"), 5000); + }); + + return new Promise((resolve, reject) => { + let senderMetadata, senderTimestamp; + worker.onmessage = (event) => { + if (event.data.name === 'sender') { + senderMetadata = event.data.metadata; + senderTimestamp = event.data.timestamp; + } else if (event.data.name === 'receiver') + resolve([senderMetadata, senderTimestamp, event.data.metadata, event.data.timestamp]); + }; + test.step_timeout(() => reject("Metadata test timed out"), 5000); + }); +} + +promise_test(async (test) => { + const [senderMetadata, senderTimestamp, receiverMetadata, receiverTimestamp] = await gatherMetadata(test, true); + + assert_equals(senderTimestamp, receiverTimestamp, "timestamp"); + assert_true(!!senderMetadata.synchronizationSource, "ssrc"); + assert_equals(senderMetadata.synchronizationSource, receiverMetadata.synchronizationSource, "ssrc"); + assert_array_equals(senderMetadata.contributingSources, receiverMetadata.contributingSources, "csrc"); +}, "audio exchange with transform"); + +promise_test(async (test) => { + const [senderMetadata, senderTimestamp, receiverMetadata, receiverTimestamp] = await gatherMetadata(test, false); + + assert_equals(senderTimestamp, receiverTimestamp, "timestamp"); + assert_true(!!senderMetadata.synchronizationSource, "ssrc"); + assert_equals(senderMetadata.synchronizationSource, receiverMetadata.synchronizationSource, "ssrc"); + assert_array_equals(senderMetadata.contributingSources, receiverMetadata.contributingSources, "csrc"); + assert_equals(senderMetadata.height, receiverMetadata.height, "height"); + assert_equals(senderMetadata.width, receiverMetadata.width, "width"); + assert_equals(senderMetadata.spatialIndex, receiverMetadata.spatialIndex, "spatialIndex"); + assert_equals(senderMetadata.temporalIndex, receiverMetadata.temporalIndex, "temporalIndex"); +}, "video exchange with transform"); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/script-transform-worker.js b/testing/web-platform/tests/webrtc-encoded-transform/script-transform-worker.js new file mode 100644 index 0000000000..5ea99cd2bf --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/script-transform-worker.js @@ -0,0 +1,25 @@ +onrtctransform = (event) => { + const transformer = event.transformer; + transformer.options.port.onmessage = (event) => transformer.options.port.postMessage(event.data); + + self.postMessage("started"); + transformer.reader = transformer.readable.getReader(); + transformer.writer = transformer.writable.getWriter(); + + function process(transformer) + { + transformer.reader.read().then(chunk => { + if (chunk.done) + return; + if (chunk.value instanceof RTCEncodedVideoFrame) + self.postMessage("video chunk"); + else if (chunk.value instanceof RTCEncodedAudioFrame) + self.postMessage("audio chunk"); + transformer.writer.write(chunk.value); + process(transformer); + }); + } + + process(transformer); +}; +self.postMessage("registered"); diff --git a/testing/web-platform/tests/webrtc-encoded-transform/script-transform.https.html b/testing/web-platform/tests/webrtc-encoded-transform/script-transform.https.html new file mode 100644 index 0000000000..e02982f470 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/script-transform.https.html @@ -0,0 +1,153 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> +<script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> + </head> + <body> + <video id="video1" autoplay></video> + <video id="video2" autoplay></video> + <script src ="routines.js"></script> + <script> +async function waitForMessage(worker, data) +{ + while (true) { + const received = await new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + if (data === received) + return; + } +} + +promise_test(async (test) => { + worker = new Worker('script-transform-worker.js'); + const data = await new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + assert_equals(data, "registered"); + + const channel = new MessageChannel; + const transform = new RTCRtpScriptTransform(worker, {name:'MockRTCRtpTransform', port: channel.port2}, [channel.port2]); + transform.port = channel.port1; + const promise = new Promise(resolve => transform.port.onmessage = (event) => resolve(event.data)); + transform.port.postMessage("test"); + assert_equals(await promise, "test"); +}, "transform messaging"); + +promise_test(async (test) => { + worker = new Worker('script-transform-worker.js'); + const data = await new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + assert_equals(data, "registered"); + + const pc = new RTCPeerConnection(); + + const senderChannel = new MessageChannel; + const receiverChannel = new MessageChannel; + const senderTransform = new RTCRtpScriptTransform(worker, {name:'MockRTCRtpTransform', port: senderChannel.port2}, [senderChannel.port2]); + const receiverTransform = new RTCRtpScriptTransform(worker, {name:'MockRTCRtpTransform', port: receiverChannel.port2}, [receiverChannel.port2]); + senderTransform.port = senderChannel.port1; + receiverTransform.port = receiverChannel.port1; + + const sender1 = pc.addTransceiver('audio').sender; + const sender2 = pc.addTransceiver('video').sender; + const receiver1 = pc.getReceivers()[0]; + const receiver2 = pc.getReceivers()[1]; + + sender1.transform = senderTransform; + receiver1.transform = receiverTransform; + assert_throws_dom("InvalidStateError", () => sender2.transform = senderTransform); + assert_throws_dom("InvalidStateError", () => receiver2.transform = receiverTransform); + + sender1.transform = senderTransform; + receiver1.transform = receiverTransform; + + sender1.transform = null; + receiver1.transform = null; +}, "Cannot reuse attached transforms"); + +promise_test(async (test) => { + worker = new Worker('script-transform-worker.js'); + const data = await new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + assert_equals(data, "registered"); + // Video is needed in a later test, so we ask for both permissions + await setMediaPermission(); + const localStream = await navigator.mediaDevices.getUserMedia({audio: true}); + + const senderChannel = new MessageChannel; + const receiverChannel = new MessageChannel; + let sender, receiver; + const senderTransform = new RTCRtpScriptTransform(worker, {name:'MockRTCRtpTransform', port: senderChannel.port2}, [senderChannel.port2]); + const receiverTransform = new RTCRtpScriptTransform(worker, {name:'MockRTCRtpTransform', port: receiverChannel.port2}, [receiverChannel.port2]); + senderTransform.port = senderChannel.port1; + receiverTransform.port = receiverChannel.port1; + + const startedPromise = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + + const stream = await new Promise((resolve, reject) => { + createConnections(test, (firstConnection) => { + pc1 = firstConnection; + sender = firstConnection.addTrack(localStream.getAudioTracks()[0], localStream); + sender.transform = senderTransform; + }, (secondConnection) => { + pc2 = secondConnection; + secondConnection.ontrack = (trackEvent) => { + receiver = trackEvent.receiver; + receiver.transform = receiverTransform; + resolve(trackEvent.streams[0]); + }; + }); + test.step_timeout(() => reject("Test timed out"), 5000); + }); + + assert_equals(await startedPromise, "started"); + + await waitForMessage(worker, "audio chunk"); + + video1.srcObject = stream; + await video1.play(); +}, "audio exchange with transform"); + +promise_test(async (test) => { + worker = new Worker('script-transform-worker.js'); + const data = await new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + assert_equals(data, "registered"); + + const localStream = await navigator.mediaDevices.getUserMedia({video: true}); + + const senderChannel = new MessageChannel; + const receiverChannel = new MessageChannel; + let sender, receiver; + const senderTransform = new RTCRtpScriptTransform(worker, {name:'MockRTCRtpTransform', port: senderChannel.port2}, [senderChannel.port2]); + const receiverTransform = new RTCRtpScriptTransform(worker, {name:'MockRTCRtpTransform', port: receiverChannel.port2}, [receiverChannel.port2]); + senderTransform.port = senderChannel.port1; + receiverTransform.port = receiverChannel.port1; + + const startedPromise = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + + const stream = await new Promise((resolve, reject) => { + createConnections(test, (firstConnection) => { + pc1 = firstConnection; + sender = firstConnection.addTrack(localStream.getVideoTracks()[0], localStream); + sender.transform = senderTransform; + }, (secondConnection) => { + pc2 = secondConnection; + secondConnection.ontrack = (trackEvent) => { + receiver = trackEvent.receiver; + receiver.transform = receiverTransform; + resolve(trackEvent.streams[0]); + }; + }); + test.step_timeout(() => reject("Test timed out"), 5000); + }); + + assert_equals(await startedPromise, "started"); + + await waitForMessage(worker, "video chunk"); + + video1.srcObject = stream; + await video1.play(); +}, "video exchange with transform"); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/script-write-twice-transform-worker.js b/testing/web-platform/tests/webrtc-encoded-transform/script-write-twice-transform-worker.js new file mode 100644 index 0000000000..5d428c81b3 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/script-write-twice-transform-worker.js @@ -0,0 +1,22 @@ +onrtctransform = (event) => { + const transformer = event.transformer; + + self.postMessage("started"); + + transformer.reader = transformer.readable.getReader(); + transformer.writer = transformer.writable.getWriter(); + function process(transformer) + { + transformer.reader.read().then(chunk => { + if (chunk.done) + return; + + transformer.writer.write(chunk.value); + transformer.writer.write(chunk.value); + process(transformer); + }); + } + + process(transformer); +}; +self.postMessage("registered"); diff --git a/testing/web-platform/tests/webrtc-encoded-transform/script-write-twice-transform.https.html b/testing/web-platform/tests/webrtc-encoded-transform/script-write-twice-transform.https.html new file mode 100644 index 0000000000..c4a49af7ac --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/script-write-twice-transform.https.html @@ -0,0 +1,62 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> +<script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> + </head> + <body> + <video id="video1" autoplay></video> + <video id="video2" autoplay></video> + <script src ="routines.js"></script> + <script> +async function waitForMessage(worker, data) +{ + while (true) { + const received = await new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + if (data === received) + return; + } +} + +promise_test(async (test) => { + worker = new Worker('script-write-twice-transform-worker.js'); + const data = await new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + assert_equals(data, "registered"); + + await setMediaPermission("granted", ["camera"]); + const localStream = await navigator.mediaDevices.getUserMedia({video: true}); + + let sender, receiver; + const senderTransform = new RTCRtpScriptTransform(worker, {name:'MockRTCRtpTransform', side:'sender', role:'encrypt'}); + const receiverTransform = new RTCRtpScriptTransform(worker, {name:'MockRTCRtpTransform', side:'receiver', role:'decrypt'}); + + const startedPromise = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + + const stream = await new Promise((resolve, reject) => { + createConnections(test, (firstConnection) => { + pc1 = firstConnection; + sender = firstConnection.addTrack(localStream.getVideoTracks()[0], localStream); + sender.transform = senderTransform; + }, (secondConnection) => { + pc2 = secondConnection; + secondConnection.ontrack = (trackEvent) => { + receiver = trackEvent.receiver; + receiver.transform = receiverTransform; + resolve(trackEvent.streams[0]); + }; + }); + test.step_timeout(() => reject("Test timed out"), 5000); + }); + + assert_equals(await startedPromise, "started"); + + video1.srcObject = stream; + await video1.play(); +}, "video exchange with write twice transform"); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/set-metadata.https.html b/testing/web-platform/tests/webrtc-encoded-transform/set-metadata.https.html new file mode 100644 index 0000000000..712971a626 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/set-metadata.https.html @@ -0,0 +1,83 @@ +<!DOCTYPE html> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="../service-workers/service-worker/resources/test-helpers.sub.js"></script> +<script src='./helper.js'></script> +<script> +"use strict"; + +promise_test(async t => { + const senderReader = await setupLoopbackWithCodecAndGetReader(t, 'VP8'); + const result = await senderReader.read(); + const metadata = result.value.getMetadata(); + // TODO(https://crbug.com/webrtc/14709): When RTCEncodedVideoFrame has a + // constructor, create a new frame from scratch instead of cloning it to + // ensure that none of the metadata was carried over via clone(). This would + // allow us to be confident that setMetadata() is doing all the work. + // + // At that point, we can refactor the clone() implementation to be the same as + // constructor() + set data + setMetadata() to ensure that clone() cannot do + // things that are not already exposed in JavaScript (no secret steps!). + const clone = result.value.clone(); + clone.setMetadata(metadata); + const cloneMetadata = clone.getMetadata(); + // Encoding related metadata. + assert_equals(cloneMetadata.frameId, metadata.frameId, 'frameId'); + assert_array_equals(cloneMetadata.dependencies, metadata.dependencies, + 'dependencies'); + assert_equals(cloneMetadata.width, metadata.width, 'width'); + assert_equals(cloneMetadata.height, metadata.height, 'height'); + assert_equals(cloneMetadata.spatialIndex, metadata.spatialIndex, + 'spatialIndex'); + assert_equals(cloneMetadata.temporalIndex, metadata.temporalIndex, + 'temporalIndex'); + assert_equals(cloneMetadata.frameType, metadata.frameType, + 'frameType'); + // RTCEncodedVideoFrameAdditionalMetadata-only fields. + assert_array_equals(cloneMetadata.decodeTargetIndications, + metadata.decodeTargetIndications, + 'decodeTargetIndications'); + assert_equals(cloneMetadata.isLastFrameInPicture, + metadata.isLastFrameInPicture, 'isLastFrameInPicture'); + assert_equals(cloneMetadata.simulcastIdx, metadata.simulcastIdx, + 'simulcastIdx'); + assert_equals(cloneMetadata.codec, metadata.codec, 'codec'); + // VP8-specifics. + assert_equals(cloneMetadata.codecSpecifics.nonReference, + metadata.codecSpecifics.nonReference, + 'codecSpecifics.nonReference'); + assert_equals(cloneMetadata.codecSpecifics.pictureId, + metadata.codecSpecifics.pictureId, 'codecSpecifics.pictureId'); + assert_equals(cloneMetadata.codecSpecifics.tl0PicIdx, + metadata.codecSpecifics.tl0PicIdx, 'codecSpecifics.tl0PicIdx'); + assert_equals(cloneMetadata.codecSpecifics.temporalIdx, + metadata.codecSpecifics.temporalIdx, + 'codecSpecifics.temporalIdx'); + assert_equals(cloneMetadata.codecSpecifics.layerSync, + metadata.codecSpecifics.layerSync, 'codecSpecifics.layerSync'); + assert_equals(cloneMetadata.codecSpecifics.keyIdx, + metadata.codecSpecifics.keyIdx, 'codecSpecifics.keyIdx.'); + assert_equals(cloneMetadata.codecSpecifics.partitionId, + metadata.codecSpecifics.partitionId, + 'codecSpecifics.partitionId'); + assert_equals(cloneMetadata.codecSpecifics.beginningOfPartition, + metadata.codecSpecifics.beginningOfPartition, + 'codecSpecifics.beginningOfPartition'); + // RTP related metadata. + // TODO(https://crbug.com/webrtc/14709): This information also needs to be + // settable but isn't - the assertions only pass because clone() copies them + // for us. It would be great if different layers didn't consider different + // subset of the struct as "the metadata". Can we consolidate into a single + // webrtc struct for all metadata? + assert_equals(cloneMetadata.synchronizationSource, + metadata.synchronizationSource, 'synchronizationSource'); + assert_array_equals(cloneMetadata.contributingSources, + metadata.contributingSources, 'contributingSources'); + assert_equals(cloneMetadata.payloadType, metadata.payloadType, 'payloadType'); +}, "[VP8] setMetadata() carries over codec-specific properties"); + +</script> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/sframe-keys.https.html b/testing/web-platform/tests/webrtc-encoded-transform/sframe-keys.https.html new file mode 100644 index 0000000000..c87ac12e29 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/sframe-keys.https.html @@ -0,0 +1,69 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> +<script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> + </head> + <body> + <video id="audio" autoplay playsInline></video> + <script src ="routines.js"></script> + <script> +let sender, receiver; +let key1, key2, key3, key4; + +promise_test(async (test) => { + const key = await crypto.subtle.importKey("raw", new Uint8Array([143, 77, 43, 10, 72, 19, 37, 67, 236, 219, 24, 93, 26, 165, 91, 178]), "HKDF", false, ["deriveBits", "deriveKey"]); + const transform = new SFrameTransform; + + await transform.setEncryptionKey(key); + await transform.setEncryptionKey(key, 1); + + await transform.setEncryptionKey(key, BigInt('18446744073709551613')); + await transform.setEncryptionKey(key, BigInt('18446744073709551614')); + await transform.setEncryptionKey(key, BigInt('18446744073709551615')); + await transform.setEncryptionKey(key, BigInt('18446744073709551616')).then(assert_unreached, (e) => { + assert_true(e instanceof RangeError); + assert_equals(e.message, "Not a 64 bits integer"); + }); +}, "Passing various key IDs"); + +promise_test(async (test) => { + key1 = await crypto.subtle.importKey("raw", new Uint8Array([143, 77, 43, 10, 72, 19, 37, 67, 236, 219, 24, 93, 26, 165, 91, 178]), "HKDF", false, ["deriveBits", "deriveKey"]); + key2 = await crypto.subtle.importKey("raw", new Uint8Array([144, 77, 43, 10, 72, 19, 37, 67, 236, 219, 24, 93, 26, 165, 91, 178]), "HKDF", false, ["deriveBits", "deriveKey"]); + key3 = await crypto.subtle.importKey("raw", new Uint8Array([145, 77, 43, 10, 72, 19, 37, 67, 236, 219, 24, 93, 26, 165, 91, 178]), "HKDF", false, ["deriveBits", "deriveKey"]); + key4 = await crypto.subtle.importKey("raw", new Uint8Array([146, 77, 43, 10, 72, 19, 37, 67, 236, 219, 24, 93, 26, 165, 91, 178]), "HKDF", false, ["deriveBits", "deriveKey"]); + + await setMediaPermission("granted", ["microphone"]); + const localStream = await navigator.mediaDevices.getUserMedia({audio: true}); + const stream = await new Promise((resolve, reject) => { + const connections = createConnections(test, (firstConnection) => { + sender = firstConnection.addTrack(localStream.getAudioTracks()[0], localStream); + let transform = new SFrameTransform; + transform.setEncryptionKey(key1); + sender.transform = transform; + }, (secondConnection) => { + secondConnection.ontrack = (trackEvent) => { + let transform = new SFrameTransform; + transform.setEncryptionKey(key1); + transform.setEncryptionKey(key2); + transform.setEncryptionKey(key3, 1000); + transform.setEncryptionKey(key4, BigInt('18446744073709551615')); + receiver = trackEvent.receiver; + receiver.transform = transform; + resolve(trackEvent.streams[0]); + }; + }); + + test.step_timeout(() => reject("Test timed out"), 5000); + }); + + audio.srcObject = stream; + await audio.play(); +}, "Audio exchange with SFrame setup"); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform-buffer-source.html b/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform-buffer-source.html new file mode 100644 index 0000000000..99b45f22c9 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform-buffer-source.html @@ -0,0 +1,50 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + </head> + <body> + <script> + +async function getEncryptedData(transform) +{ + const chunk = await transform.readable.getReader().read(); + const value = new Uint8Array(chunk.value); + return [...value]; +} + +promise_test(async (test) => { + const key = await crypto.subtle.importKey("raw", new Uint8Array([143, 77, 43, 10, 72, 19, 37, 67, 236, 219, 24, 93, 26, 165, 91, 178]), "HKDF", false, ["deriveBits", "deriveKey"]); + const transform1 = new SFrameTransform; + const transform2 = new SFrameTransform; + const transform3 = new SFrameTransform; + + await transform1.setEncryptionKey(key); + await transform2.setEncryptionKey(key); + await transform3.setEncryptionKey(key); + + const buffer1 = new ArrayBuffer(10); + const buffer2 = new ArrayBuffer(11); + const view1 = new Uint8Array(buffer1); + const view2 = new Uint8Array(buffer2, 1); + for (let i = 0 ; i < buffer1.byteLength; ++i) { + view1[i] = i; + view2[i] = i; + } + + transform1.writable.getWriter().write(buffer1); + transform2.writable.getWriter().write(view1); + transform3.writable.getWriter().write(view2); + + const result1 = await getEncryptedData(transform1); + const result2 = await getEncryptedData(transform2); + const result3 = await getEncryptedData(transform3); + + assert_array_equals(result1, result2, "result2"); + assert_array_equals(result1, result3, "result3"); +}, "Uint8Array as input to SFrameTransform"); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform-in-worker.https.html b/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform-in-worker.https.html new file mode 100644 index 0000000000..f5d7b5a930 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform-in-worker.https.html @@ -0,0 +1,61 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> +<script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> + </head> + <body> + <video id="video1" controls autoplay></video> + <script src ="routines.js"></script> + <script> +async function waitForMessage(worker, data) +{ + while (true) { + const received = await new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + if (data === received) + return; + } +} + +promise_test(async (test) => { + worker = new Worker('sframe-transform-worker.js'); + const data = await new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + assert_equals(data, "registered"); + await setMediaPermission("granted", ["camera"]); + const localStream = await navigator.mediaDevices.getUserMedia({ video: true }); + + let sender, receiver; + const senderTransform = new SFrameTransform({ compatibilityMode: "H264" }); + const receiverTransform = new RTCRtpScriptTransform(worker, "SFrameRTCRtpTransform"); + + const key = await crypto.subtle.importKey("raw", new Uint8Array([143, 77, 43, 10, 72, 19, 37, 67, 236, 219, 24, 93, 26, 165, 91, 178]), "HKDF", false, ["deriveBits", "deriveKey"]); + senderTransform.setEncryptionKey(key); + + const startedPromise = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + + const stream = await new Promise((resolve, reject) => { + createConnections(test, (firstConnection) => { + pc1 = firstConnection; + sender = firstConnection.addTrack(localStream.getTracks()[0], localStream); + sender.transform = senderTransform; + }, (secondConnection) => { + pc2 = secondConnection; + secondConnection.ontrack = (trackEvent) => { + receiver = trackEvent.receiver; + receiver.transform = receiverTransform; + resolve(trackEvent.streams[0]); + }; + }); + test.step_timeout(() => reject("Test timed out"), 5000); + }); + + video1.srcObject = stream; + await video1.play(); +}, "video exchange with SFrame transform in worker"); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform-readable.html b/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform-readable.html new file mode 100644 index 0000000000..2afce1b271 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform-readable.html @@ -0,0 +1,19 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + </head> + <body> + <iframe src="." id="frame"></iframe> + <script> +promise_test(async (test) => { + const frameDOMException = frame.contentWindow.DOMException; + const transform = new frame.contentWindow.SFrameTransform; + frame.remove(); + assert_throws_dom("InvalidStateError", frameDOMException, () => transform.readable); +}); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform-worker.js b/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform-worker.js new file mode 100644 index 0000000000..617cf0a38a --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform-worker.js @@ -0,0 +1,7 @@ +onrtctransform = (event) => { + const sframeTransform = new SFrameTransform({ role : "decrypt", authenticationSize: "10", compatibilityMode: "H264" }); + crypto.subtle.importKey("raw", new Uint8Array([143, 77, 43, 10, 72, 19, 37, 67, 236, 219, 24, 93, 26, 165, 91, 178]), "HKDF", false, ["deriveBits", "deriveKey"]).then(key => sframeTransform.setEncryptionKey(key)); + const transformer = event.transformer; + transformer.readable.pipeThrough(sframeTransform).pipeTo(transformer.writable); +} +self.postMessage("registered"); diff --git a/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform.html b/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform.html new file mode 100644 index 0000000000..2e40135b04 --- /dev/null +++ b/testing/web-platform/tests/webrtc-encoded-transform/sframe-transform.html @@ -0,0 +1,141 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + </head> + <body> + <script> + +promise_test(async (test) => { + const pc = new RTCPeerConnection(); + const senderTransform = new SFrameTransform(); + const receiverTransform = new SFrameTransform(); + const sender1 = pc.addTransceiver('audio').sender; + const sender2 = pc.addTransceiver('video').sender; + const receiver1 = pc.getReceivers()[0]; + const receiver2 = pc.getReceivers()[1]; + + sender1.transform = senderTransform; + receiver1.transform = receiverTransform; + assert_throws_dom("InvalidStateError", () => sender2.transform = senderTransform); + assert_throws_dom("InvalidStateError", () => receiver2.transform = receiverTransform); + + sender1.transform = senderTransform; + receiver1.transform = receiverTransform; + + sender1.transform = null; + receiver1.transform = null; +}, "Cannot reuse attached transforms"); + +test(() => { + const senderTransform = new SFrameTransform(); + + assert_true(senderTransform.readable instanceof ReadableStream); + assert_true(senderTransform.writable instanceof WritableStream); +}, "SFrameTransform exposes readable and writable"); + +promise_test(async (test) => { + const pc = new RTCPeerConnection(); + const senderTransform = new SFrameTransform(); + const receiverTransform = new SFrameTransform(); + const sender1 = pc.addTransceiver('audio').sender; + const sender2 = pc.addTransceiver('video').sender; + const receiver1 = pc.getReceivers()[0]; + const receiver2 = pc.getReceivers()[1]; + + assert_false(senderTransform.readable.locked, "sender readable before"); + assert_false(senderTransform.writable.locked, "sender writable before"); + assert_false(receiverTransform.readable.locked, "receiver readable before"); + assert_false(receiverTransform.writable.locked, "receiver writable before"); + + sender1.transform = senderTransform; + receiver1.transform = receiverTransform; + + assert_true(senderTransform.readable.locked, "sender readable during"); + assert_true(senderTransform.writable.locked, "sender writable during"); + assert_true(receiverTransform.readable.locked, "receiver readable during"); + assert_true(receiverTransform.writable.locked, "receiver writable during"); + + sender1.transform = null; + receiver1.transform = null; + + assert_true(senderTransform.readable.locked, "sender readable after"); + assert_true(senderTransform.writable.locked, "sender writable after"); + assert_true(receiverTransform.readable.locked, "receiver readable after"); + assert_true(receiverTransform.writable.locked, "receiver writable after"); +}, "readable/writable are locked when attached and after being attached"); + +promise_test(async (test) => { + const key = await crypto.subtle.importKey("raw", new Uint8Array([143, 77, 43, 10, 72, 19, 37, 67, 236, 219, 24, 93, 26, 165, 91, 178]), "HKDF", false, ["deriveBits", "deriveKey"]); + + const senderTransform = new SFrameTransform({ role : 'encrypt', authenticationSize: 10 }); + senderTransform.setEncryptionKey(key); + + const receiverTransform = new SFrameTransform({ role : 'decrypt', authenticationSize: 10 }); + receiverTransform.setEncryptionKey(key); + + const writer = senderTransform.writable.getWriter(); + const reader = receiverTransform.readable.getReader(); + + senderTransform.readable.pipeTo(receiverTransform.writable); + + const sent = new ArrayBuffer(8); + const view = new Int8Array(sent); + for (let cptr = 0; cptr < sent.byteLength; ++cptr) + view[cptr] = cptr; + + writer.write(sent); + const received = await reader.read(); + + assert_equals(received.value.byteLength, 8); + const view2 = new Int8Array(received.value); + for (let cptr = 0; cptr < sent.byteLength; ++cptr) + assert_equals(view2[cptr], view[cptr]); +}, "SFrame with array buffer - authentication size 10"); + +promise_test(async (test) => { + const key = await crypto.subtle.importKey("raw", new Uint8Array([143, 77, 43, 10, 72, 19, 37, 67, 236, 219, 24, 93, 26, 165, 91, 178]), "HKDF", false, ["deriveBits", "deriveKey"]); + + const senderTransform = new SFrameTransform({ role : 'encrypt', authenticationSize: 10 }); + const senderWriter = senderTransform.writable.getWriter(); + const senderReader = senderTransform.readable.getReader(); + + const receiverTransform = new SFrameTransform({ role : 'decrypt', authenticationSize: 10 }); + const receiverWriter = receiverTransform.writable.getWriter(); + const receiverReader = receiverTransform.readable.getReader(); + + senderTransform.setEncryptionKey(key); + receiverTransform.setEncryptionKey(key); + + const chunk = new ArrayBuffer(8); + + // decryption should fail, leading to an empty array buffer. + await receiverWriter.write(chunk); + let received = await receiverReader.read(); + assert_equals(received.value.byteLength, 0); + + // We write again but this time with a chunk we can decrypt. + await senderWriter.write(chunk); + const encrypted = await senderReader.read(); + await receiverWriter.write(encrypted.value); + received = await receiverReader.read(); + assert_equals(received.value.byteLength, 8); +}, "SFrame decryption with array buffer that is too small"); + +promise_test(async (test) => { + const key = await crypto.subtle.importKey("raw", new Uint8Array([143, 77, 43, 10, 72, 19, 37, 67, 236, 219, 24, 93, 26, 165, 91, 178]), "HKDF", false, ["deriveBits", "deriveKey"]); + + const receiverTransform = new SFrameTransform({ role : 'decrypt', authenticationSize: 10 }); + const receiverWriter = receiverTransform.writable.getWriter(); + receiverTransform.setEncryptionKey(key); + + // decryption should fail, leading to erroring the transform. + await promise_rejects_js(test, TypeError, receiverWriter.write({ })); + await promise_rejects_js(test, TypeError, receiverWriter.closed); +}, "SFrame transform gets errored if trying to process unexpected value types"); + + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webrtc-extensions/META.yml b/testing/web-platform/tests/webrtc-extensions/META.yml new file mode 100644 index 0000000000..be8cb028f0 --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/META.yml @@ -0,0 +1,3 @@ +spec: https://w3c.github.io/webrtc-extensions/ +suggested_reviewers: + - hbos diff --git a/testing/web-platform/tests/webrtc-extensions/RTCOAuthCredential.html b/testing/web-platform/tests/webrtc-extensions/RTCOAuthCredential.html new file mode 100644 index 0000000000..63e92c6d08 --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/RTCOAuthCredential.html @@ -0,0 +1,44 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCConfiguration iceServers with OAuth credentials</title> +<script src='/resources/testharness.js'></script> +<script src='/resources/testharnessreport.js'></script> +<script src='../webrtc/RTCConfiguration-helper.js'></script> +<script> + 'use strict'; + +// These tests are based on +// https://w3c.github.io/webrtc-extensions/#rtcoauthcredential-dictionary + +/* + 4.3.2. To set a configuration + 11.6. If scheme name is turn or turns, and server.credentialType is "oauth", + and server.credential is not an RTCOAuthCredential, then throw an + InvalidAccessError and abort these steps. +*/ +config_test(makePc => { + assert_throws_dom('InvalidAccessError', () => + makePc({ iceServers: [{ + urls: 'turns:turn.example.org', + credentialType: 'oauth', + username: 'user', + credential: 'cred' + }] })); +}, 'with turns server, credentialType oauth, and string credential should throw InvalidAccessError'); + +config_test(makePc => { + const pc = makePc({ iceServers: [{ + urls: 'turns:turn2.example.net', + username: '22BIjxU93h/IgwEb', + credential: { + macKey: 'WmtzanB3ZW9peFhtdm42NzUzNG0=', + accessToken: 'AAwg3kPHWPfvk9bDFL936wYvkoctMADzQ5VhNDgeMR3+ZlZ35byg972fW8QjpEl7bx91YLBPFsIhsxloWcXPhA==' + }, + credentialType: 'oauth' + }]}); + const { iceServers } = pc.getConfiguration(); + const server = iceServers[0]; + assert_equals(server.credentialType, 'oauth'); +}, 'with turns server, credential type and credential from spec should not throw'); + +</script> diff --git a/testing/web-platform/tests/webrtc-extensions/RTCRtpParameters-adaptivePtime.html b/testing/web-platform/tests/webrtc-extensions/RTCRtpParameters-adaptivePtime.html new file mode 100644 index 0000000000..8a7a8b6ba6 --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/RTCRtpParameters-adaptivePtime.html @@ -0,0 +1,42 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>RTCRtpEncodingParameters adaptivePtime property</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> + 'use strict'; + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio', { + sendEncodings: [{adaptivePtime: true}], + }); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + assert_true(encoding.adaptivePtime); + + encoding.adaptivePtime = false; + await sender.setParameters(param); + param = sender.getParameters(); + encoding = param.encodings[0]; + + assert_false(encoding.adaptivePtime); + + }, `Setting adaptivePtime should be accepted`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio', { sendEncodings: [{}] }); + + const param = sender.getParameters(); + const encoding = param.encodings[0]; + + assert_false(encoding.adaptivePtime); + + }, `adaptivePtime should be default false`); + +</script> diff --git a/testing/web-platform/tests/webrtc-extensions/RTCRtpParameters-codec.html b/testing/web-platform/tests/webrtc-extensions/RTCRtpParameters-codec.html new file mode 100644 index 0000000000..5c81349b15 --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/RTCRtpParameters-codec.html @@ -0,0 +1,422 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>RTCRtpEncodingParameters codec property</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="../webrtc/third_party/sdp/sdp.js"></script> +<script src="../webrtc/simulcast/simulcast.js"></script> +<script> + 'use strict'; + + function findFirstCodec(name) { + return RTCRtpSender.getCapabilities(name.split('/')[0]).codecs.filter(c => c.mimeType.localeCompare(name, undefined, { sensitivity: 'base' }) === 0)[0]; + } + + function codecsNotMatching(mimeType) { + return RTCRtpSender.getCapabilities(mimeType.split('/')[0]).codecs.filter(c => c.mimeType.localeCompare(mimeType, undefined, {sensitivity: 'base'}) !== 0); + } + + function assertCodecEquals(a, b) { + assert_equals(a.mimeType, b.mimeType); + assert_equals(a.clockRate, b.clockRate); + assert_equals(a.channels, b.channels); + assert_equals(a.sdpFmtpLine, b.sdpFmtpLine); + } + + async function codecsForSender(sender) { + const rids = sender.getParameters().encodings.map(e => e.rid); + const stats = await sender.getStats(); + const codecs = [...stats] + .filter(([k, v]) => v.type === 'outbound-rtp') + .sort(([k, v], [k2, v2]) => rids.indexOf(v.rid) - rids.indexOf(v2.rid)) + .map(([k, v]) => stats.get(v.codecId).mimeType); + return codecs; + } + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const { sender } = pc.addTransceiver('audio'); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + assert_equals(encoding.codec, undefined); + }, `Codec should be undefined by default on audio encodings`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const { sender } = pc.addTransceiver('video'); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + assert_equals(encoding.codec, undefined); + }, `Codec should be undefined by default on video encodings`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const opus = findFirstCodec('audio/opus'); + + const { sender } = pc.addTransceiver('audio', { + sendEncodings: [{codec: opus}], + }); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + assertCodecEquals(opus, encoding.codec); + }, `Creating an audio sender with addTransceiver and codec should work`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const vp8 = findFirstCodec('video/VP8'); + + const { sender } = pc.addTransceiver('video', { + sendEncodings: [{codec: vp8}], + }); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + assertCodecEquals(vp8, encoding.codec); + }, `Creating a video sender with addTransceiver and codec should work`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const opus = findFirstCodec('audio/opus'); + + const { sender } = pc.addTransceiver('audio'); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + encoding.codec = opus; + await sender.setParameters(param); + param = sender.getParameters(); + encoding = param.encodings[0]; + + assertCodecEquals(opus, encoding.codec); + + delete encoding.codec; + await sender.setParameters(param); + param = sender.getParameters(); + encoding = param.encodings[0]; + + assert_equals(encoding.codec, undefined); + }, `Setting codec on an audio sender with setParameters should work`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const vp8 = findFirstCodec('video/VP8'); + + const { sender } = pc.addTransceiver('video'); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + encoding.codec = vp8; + await sender.setParameters(param); + param = sender.getParameters(); + encoding = param.encodings[0]; + + assertCodecEquals(vp8, encoding.codec); + + delete encoding.codec; + await sender.setParameters(param); + param = sender.getParameters(); + encoding = param.encodings[0]; + + assert_equals(encoding.codec, undefined); + }, `Setting codec on a video sender with setParameters should work`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const newCodec = { + mimeType: "audio/newCodec", + clockRate: 90000, + channel: 2, + }; + + assert_throws_dom('OperationError', () => pc.addTransceiver('video', { + sendEncodings: [{codec: newCodec}], + })); + }, `Creating an audio sender with addTransceiver and non-existing codec should throw OperationError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const newCodec = { + mimeType: "video/newCodec", + clockRate: 90000, + }; + + assert_throws_dom('OperationError', () => pc.addTransceiver('video', { + sendEncodings: [{codec: newCodec}], + })); + }, `Creating a video sender with addTransceiver and non-existing codec should throw OperationError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const newCodec = { + mimeType: "audio/newCodec", + clockRate: 90000, + channel: 2, + }; + + const { sender } = pc.addTransceiver('audio'); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + encoding.codec = newCodec; + await promise_rejects_dom(t, "InvalidModificationError", sender.setParameters(param)); + }, `Setting a non-existing codec on an audio sender with setParameters should throw InvalidModificationError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const newCodec = { + mimeType: "video/newCodec", + clockRate: 90000, + }; + + const { sender } = pc.addTransceiver('video'); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + encoding.codec = newCodec; + await promise_rejects_dom(t, "InvalidModificationError", sender.setParameters(param)); + }, `Setting a non-existing codec on a video sender with setParameters should throw InvalidModificationError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const opus = findFirstCodec('audio/opus'); + const nonOpus = codecsNotMatching(opus.mimeType); + + const transceiver = pc.addTransceiver('audio'); + const sender = transceiver.sender; + + transceiver.setCodecPreferences(nonOpus); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + encoding.codec = opus; + await promise_rejects_dom(t, "InvalidModificationError", sender.setParameters(param)); + }, `Setting a non-preferred codec on an audio sender with setParameters should throw InvalidModificationError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const vp8 = findFirstCodec('video/VP8'); + const nonVP8 = codecsNotMatching(vp8.mimeType); + + const transceiver = pc.addTransceiver('video'); + const sender = transceiver.sender; + + transceiver.setCodecPreferences(nonVP8); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + encoding.codec = vp8; + await promise_rejects_dom(t, "InvalidModificationError", sender.setParameters(param)); + }, `Setting a non-preferred codec on a video sender with setParameters should throw InvalidModificationError`); + + promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const opus = findFirstCodec('audio/opus'); + const nonOpus = codecsNotMatching(opus.mimeType); + + const transceiver = pc1.addTransceiver('audio'); + const sender = transceiver.sender; + + transceiver.setCodecPreferences(nonOpus); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + encoding.codec = opus; + await promise_rejects_dom(t, "InvalidModificationError", sender.setParameters(param)); + }, `Setting a non-negotiated codec on an audio sender with setParameters should throw InvalidModificationError`); + + promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const vp8 = findFirstCodec('video/VP8'); + const nonVP8 = codecsNotMatching(vp8.mimeType); + + const transceiver = pc1.addTransceiver('video'); + const sender = transceiver.sender; + + transceiver.setCodecPreferences(nonVP8); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + encoding.codec = vp8; + await promise_rejects_dom(t, "InvalidModificationError", sender.setParameters(param)); + }, `Setting a non-negotiated codec on a video sender with setParameters should throw InvalidModificationError`); + + promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const opus = findFirstCodec('audio/opus'); + const nonOpus = codecsNotMatching(opus.mimeType); + + const transceiver = pc1.addTransceiver('audio', { + sendEncodings: [{codec: opus}], + }); + const sender = transceiver.sender; + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + assertCodecEquals(opus, encoding.codec); + + transceiver.setCodecPreferences(nonOpus); + await exchangeOfferAnswer(pc1, pc2); + + param = sender.getParameters(); + encoding = param.encodings[0]; + + assert_equals(encoding.codec, undefined); + }, `Codec should be undefined after negotiating away the currently set codec on an audio sender`); + + promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const vp8 = findFirstCodec('video/VP8'); + const nonVP8 = codecsNotMatching(vp8.mimeType); + + const transceiver = pc1.addTransceiver('video', { + sendEncodings: [{codec: vp8}], + }); + const sender = transceiver.sender; + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + + assertCodecEquals(vp8, encoding.codec); + + transceiver.setCodecPreferences(nonVP8); + await exchangeOfferAnswer(pc1, pc2); + + param = sender.getParameters(); + encoding = param.encodings[0]; + + assert_equals(encoding.codec, undefined); + }, `Codec should be undefined after negotiating away the currently set codec on a video sender`); + + promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const stream = await getNoiseStream({audio:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + + const opus = findFirstCodec('audio/opus'); + const nonOpus = codecsNotMatching(opus.mimeType); + + const transceiver = pc1.addTransceiver(stream.getTracks()[0]); + const sender = transceiver.sender; + + transceiver.setCodecPreferences(nonOpus.concat([opus])); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + let codecs = await codecsForSender(sender); + assert_not_equals(codecs[0], opus.mimeType); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + encoding.codec = opus; + + await sender.setParameters(param); + + codecs = await codecsForSender(sender); + assert_array_equals(codecs, [opus.mimeType]); + }, `Stats output-rtp should match the selected codec in non-simulcast usecase on an audio sender`); + + promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const stream = await getNoiseStream({video:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + + const vp8 = findFirstCodec('video/VP8'); + const nonVP8 = codecsNotMatching(vp8.mimeType); + + const transceiver = pc1.addTransceiver(stream.getTracks()[0]); + const sender = transceiver.sender; + + transceiver.setCodecPreferences(nonVP8.concat([vp8])); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + let codecs = await codecsForSender(sender); + assert_not_equals(codecs[0], vp8.mimeType); + + let param = sender.getParameters(); + let encoding = param.encodings[0]; + encoding.codec = vp8; + + await sender.setParameters(param); + + codecs = await codecsForSender(sender); + assert_array_equals(codecs, [vp8.mimeType]); + }, `Stats output-rtp should match the selected codec in non-simulcast usecase on a video sender`); +</script> diff --git a/testing/web-platform/tests/webrtc-extensions/RTCRtpParameters-maxFramerate.html b/testing/web-platform/tests/webrtc-extensions/RTCRtpParameters-maxFramerate.html new file mode 100644 index 0000000000..3e348f0d14 --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/RTCRtpParameters-maxFramerate.html @@ -0,0 +1,101 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpParameters encodings</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/webrtc/dictionary-helper.js"></script> +<script src="/webrtc/RTCRtpParameters-helper.js"></script> +<script> +'use strict'; + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + assert_throws_js(RangeError, () => pc.addTransceiver('video', { + sendEncodings: [{ + maxFramerate: -10 + }] + })); +}, `addTransceiver() with sendEncoding.maxFramerate field set to less than 0 should reject with RangeError`); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + let {sender} = pc.addTransceiver('audio', { + sendEncodings: [{ + maxFramerate: -10 + }] + }); + let encodings = sender.getParameters().encodings; + assert_equals(encodings.length, 1); + assert_not_own_property(encodings[0], "maxFramerate"); + + sender = pc.addTransceiver('audio', { + sendEncodings: [{ + maxFramerate: 10 + }] + }).sender; + encodings = sender.getParameters().encodings; + assert_equals(encodings.length, 1); + assert_not_own_property(encodings[0], "maxFramerate"); +}, `addTransceiver('audio') with sendEncoding.maxFramerate should succeed, but remove the maxFramerate, even if it is invalid`); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {sender} = pc.addTransceiver('audio'); + let params = sender.getParameters(); + assert_equals(params.encodings.length, 1); + params.encodings[0].maxFramerate = 20; + await sender.setParameters(params); + const {encodings} = sender.getParameters(); + assert_equals(encodings.length, 1); + assert_not_own_property(encodings[0], "maxFramerate"); +}, `setParameters with maxFramerate on an audio sender should succeed, but remove the maxFramerate`); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {sender} = pc.addTransceiver('audio'); + let params = sender.getParameters(); + assert_equals(params.encodings.length, 1); + params.encodings[0].maxFramerate = -1; + await sender.setParameters(params); + const {encodings} = sender.getParameters(); + assert_equals(encodings.length, 1); + assert_not_own_property(encodings[0], "maxFramerate"); +}, `setParameters with an invalid maxFramerate on an audio sender should succeed, but remove the maxFramerate`); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video'); + await doOfferAnswerExchange(t, pc); + + const param = sender.getParameters(); + const encoding = param.encodings[0]; + assert_not_own_property(encoding, "maxFramerate"); + + encoding.maxFramerate = -10; + return promise_rejects_js(t, RangeError, + sender.setParameters(param)); +}, `setParameters() with encoding.maxFramerate field set to less than 0 should reject with RangeError`); + +// It would be great if we could test to see whether maxFramerate is actually +// honored. +test_modified_encoding('video', 'maxFramerate', 24, 16, + 'setParameters() with maxFramerate 24->16 should succeed'); + +test_modified_encoding('video', 'maxFramerate', undefined, 16, + 'setParameters() with maxFramerate undefined->16 should succeed'); + +test_modified_encoding('video', 'maxFramerate', 24, undefined, + 'setParameters() with maxFramerate 24->undefined should succeed'); + +test_modified_encoding('video', 'maxFramerate', 0, 16, + 'setParameters() with maxFramerate 0->16 should succeed'); + +test_modified_encoding('video', 'maxFramerate', 24, 0, + 'setParameters() with maxFramerate 24->0 should succeed'); + +</script> diff --git a/testing/web-platform/tests/webrtc-extensions/RTCRtpReceiver-jitterBufferTarget-stats.html b/testing/web-platform/tests/webrtc-extensions/RTCRtpReceiver-jitterBufferTarget-stats.html new file mode 100644 index 0000000000..33f71800bd --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/RTCRtpReceiver-jitterBufferTarget-stats.html @@ -0,0 +1,96 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<meta name="timeout" content="long"> +<title>Tests RTCRtpReceiver-jitterBufferTarget verified with stats</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/webrtc/RTCPeerConnection-helper.js"></script> +<body> +<script> +'use strict' + +function async_promise_test(func, name, properties) { + async_test(t => { + Promise.resolve(func(t)) + .catch(t.step_func(e => { throw e; })) + .then(() => t.done()); + }, name, properties); +} + +async_promise_test(t => applyJitterBufferTarget(t, "video", 4000), + "measure raising and lowering video jitterBufferTarget"); +async_promise_test(t => applyJitterBufferTarget(t, "audio", 4000), + "measure raising and lowering audio jitterBufferTarget"); + +async function applyJitterBufferTarget(t, kind, target) { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream = await getNoiseStream({[kind]:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + caller.addTransceiver(stream.getTracks()[0], {streams: [stream]}); + caller.addTransceiver(stream.getTracks()[0], {streams: [stream]}); + + exchangeIceCandidates(caller, callee); + await exchangeOffer(caller, callee); + const [unconstrainedReceiver, constrainedReceiver] = callee.getReceivers(); + const haveRtp = Promise.all([ + new Promise(r => constrainedReceiver.track.onunmute = r), + new Promise(r => unconstrainedReceiver.track.onunmute = r) + ]); + await exchangeAnswer(caller, callee); + const chromeTimeout = new Promise(r => t.step_timeout(r, 1000)); // crbug.com/1295295 + await Promise.race([haveRtp, chromeTimeout]); + + // Allow some data to be processed to let the jitter buffer to stabilize a bit before measuring + await new Promise(r => t.step_timeout(r, 5000)); + + t.step(() => assert_equals(constrainedReceiver.jitterBufferTarget, null, + `jitterBufferTarget supported for ${kind}`)); + + constrainedReceiver.jitterBufferTarget = target; + t.step(() => assert_equals(constrainedReceiver.jitterBufferTarget, target, + `jitterBufferTarget increase target for ${kind}`)); + + const [increased, base] = await Promise.all([ + measureDelayFromStats(t, constrainedReceiver, 20), + measureDelayFromStats(t, unconstrainedReceiver, 20) + ]); + + t.step(() => assert_greater_than(increased , base, + `${kind} increased delay ${increased} ` + + ` greater than base delay ${base}`)); + + constrainedReceiver.jitterBufferTarget = 0; + + // Allow the jitter buffer to stabilize a bit before measuring + await new Promise(r => t.step_timeout(r, 5000)); + t.step(() => assert_equals(constrainedReceiver.jitterBufferTarget, 0, + `jitterBufferTarget decrease target for ${kind}`)); + + const decreased = await measureDelayFromStats(t, constrainedReceiver, 20); + + t.step(() => assert_less_than(decreased, increased, + `${kind} decreasedDelay ${decreased} ` + + `less than increased delay ${increased}`)); +} + +async function measureDelayFromStats(t, receiver, cycles) { + + let statsReport = await receiver.getStats(); + const oldInboundStats = [...statsReport.values()].find(({type}) => type == "inbound-rtp"); + + await new Promise(r => t.step_timeout(r, 1000 * cycles)); + + statsReport = await receiver.getStats(); + const inboundStats = [...statsReport.values()].find(({type}) => type == "inbound-rtp"); + + const delay = ((inboundStats.jitterBufferDelay - oldInboundStats.jitterBufferDelay) / + (inboundStats.jitterBufferEmittedCount - oldInboundStats.jitterBufferEmittedCount) * 1000); + + return delay; +} +</script> +</body> diff --git a/testing/web-platform/tests/webrtc-extensions/RTCRtpReceiver-jitterBufferTarget.html b/testing/web-platform/tests/webrtc-extensions/RTCRtpReceiver-jitterBufferTarget.html new file mode 100644 index 0000000000..448162d3a2 --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/RTCRtpReceiver-jitterBufferTarget.html @@ -0,0 +1,130 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>Tests for RTCRtpReceiver-jitterBufferTarget attribute</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<body> +<script> +'use strict' + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {receiver} = pc.addTransceiver('audio', {direction:'recvonly'}); + assert_equals(receiver.jitterBufferTarget, null); +}, 'audio jitterBufferTarget is null by default'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {receiver} = pc.addTransceiver('audio', {direction:'recvonly'}); + assert_equals(receiver.jitterBufferTarget, null); + receiver.jitterBufferTarget = 500; + assert_equals(receiver.jitterBufferTarget, 500); +}, 'audio jitterBufferTarget accepts posititve values'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {receiver} = pc.addTransceiver('audio', {direction:'recvonly'}); + assert_equals(receiver.jitterBufferTarget, null); + receiver.jitterBufferTarget = 4000; + assert_throws_js(RangeError, () => { + receiver.jitterBufferTarget = 4001; + }, 'audio jitterBufferTarget doesn\'t accept values greater than 4000 milliseconds'); + assert_equals(receiver.jitterBufferTarget, 4000); +}, 'audio jitterBufferTarget accepts values up to 4000 milliseconds'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {receiver} = pc.addTransceiver('audio', {direction:'recvonly'}); + assert_equals(receiver.jitterBufferTarget, null); + receiver.jitterBufferTarget = 700; + assert_throws_js(RangeError, () => { + receiver.jitterBufferTarget = -500; + }, 'audio jitterBufferTarget doesn\'t accept negative values'); + assert_equals(receiver.jitterBufferTarget, 700); +}, 'audio jitterBufferTarget returns last valid value on throw'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {receiver} = pc.addTransceiver('audio', {direction:'recvonly'}); + assert_equals(receiver.jitterBufferTarget, null); + receiver.jitterBufferTarget = 0; + assert_equals(receiver.jitterBufferTarget, 0); +}, 'audio jitterBufferTarget allows zero value'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {receiver} = pc.addTransceiver('audio', {direction:'recvonly'}); + assert_equals(receiver.jitterBufferTarget, null); + receiver.jitterBufferTarget = 500; + assert_equals(receiver.jitterBufferTarget, 500); + receiver.jitterBufferTarget = null; + assert_equals(receiver.jitterBufferTarget, null); +}, 'audio jitterBufferTarget allows to reset value to null'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {receiver} = pc.addTransceiver('video', {direction:'recvonly'}); + assert_equals(receiver.jitterBufferTarget, null); +}, 'video jitterBufferTarget is null by default'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {receiver} = pc.addTransceiver('video', {direction:'recvonly'}); + assert_equals(receiver.jitterBufferTarget, null); + receiver.jitterBufferTarget = 500; + assert_equals(receiver.jitterBufferTarget, 500); +}, 'video jitterBufferTarget accepts posititve values'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {receiver} = pc.addTransceiver('video', {direction:'recvonly'}); + assert_equals(receiver.jitterBufferTarget, null); + receiver.jitterBufferTarget = 4000; + assert_throws_js(RangeError, () => { + receiver.jitterBufferTarget = 4001; + }, 'video jitterBufferTarget doesn\'t accept values greater than 4000 milliseconds'); + assert_equals(receiver.jitterBufferTarget, 4000); +}, 'video jitterBufferTarget accepts values up to 4000 milliseconds'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {receiver} = pc.addTransceiver('video', {direction:'recvonly'}); + assert_equals(receiver.jitterBufferTarget, null); + receiver.jitterBufferTarget = 700; + assert_throws_js(RangeError, () => { + receiver.jitterBufferTarget = -500; + }, 'video jitterBufferTarget doesn\'t accept negative values'); + assert_equals(receiver.jitterBufferTarget, 700); +}, 'video jitterBufferTarget returns last valid value'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {receiver} = pc.addTransceiver('video', {direction:'recvonly'}); + assert_equals(receiver.jitterBufferTarget, null); + receiver.jitterBufferTarget = 0; + assert_equals(receiver.jitterBufferTarget, 0); +}, 'video jitterBufferTarget allows zero value'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {receiver} = pc.addTransceiver('video', {direction:'recvonly'}); + assert_equals(receiver.jitterBufferTarget, null); + receiver.jitterBufferTarget = 500; + assert_equals(receiver.jitterBufferTarget, 500); + receiver.jitterBufferTarget = null; + assert_equals(receiver.jitterBufferTarget, null); +}, 'video jitterBufferTarget allows to reset value to null'); +</script> +</body> diff --git a/testing/web-platform/tests/webrtc-extensions/RTCRtpSynchronizationSource-captureTimestamp.html b/testing/web-platform/tests/webrtc-extensions/RTCRtpSynchronizationSource-captureTimestamp.html new file mode 100644 index 0000000000..60b4ed0a74 --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/RTCRtpSynchronizationSource-captureTimestamp.html @@ -0,0 +1,94 @@ +<!doctype html> +<meta charset=utf-8> +<!-- This file contains a test that waits for 2 seconds. --> +<meta name="timeout" content="long"> +<title>captureTimestamp attribute in RTCRtpSynchronizationSource</title> +<div><video id="remote" width="124" height="124" autoplay></video></div> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/webrtc/RTCPeerConnection-helper.js"></script> +<script src="/webrtc/RTCStats-helper.js"></script> +<script src="/webrtc-extensions/RTCRtpSynchronizationSource-helper.js"></script> +<script> +'use strict'; + +function listenForCaptureTimestamp(t, receiver) { + return new Promise((resolve) => { + function listen() { + const ssrcs = receiver.getSynchronizationSources(); + assert_true(ssrcs != undefined); + if (ssrcs.length > 0) { + assert_equals(ssrcs.length, 1); + if (ssrcs[0].captureTimestamp != undefined) { + resolve(ssrcs[0].captureTimestamp); + return true; + } + } + return false; + }; + t.step_wait(listen, 'No abs-capture-time capture time header extension.'); + }); +} + +// Passes if `getSynchronizationSources()` contains `captureTimestamp` if and +// only if expected. +for (const kind of ['audio', 'video']) { + promise_test(async t => { + const [caller, callee] = await initiateSingleTrackCall( + t, /* caps= */{[kind]: true}, /* absCaptureTimeOffered= */false, + /* absCaptureTimeAnswered= */false); + const receiver = callee.getReceivers()[0]; + + for (const ssrc of await listenForSSRCs(t, receiver)) { + assert_equals(typeof ssrc.captureTimestamp, 'undefined'); + } + }, '[' + kind + '] getSynchronizationSources() should not contain ' + + 'captureTimestamp if absolute capture time RTP header extension is not ' + + 'offered'); + + promise_test(async t => { + const [caller, callee] = await initiateSingleTrackCall( + t, /* caps= */{[kind]: true}, /* absCaptureTimeOffered= */false, + /* absCaptureTimeAnswered= */false); + const receiver = callee.getReceivers()[0]; + + for (const ssrc of await listenForSSRCs(t, receiver)) { + assert_equals(typeof ssrc.captureTimestamp, 'undefined'); + } + }, '[' + kind + '] getSynchronizationSources() should not contain ' + + 'captureTimestamp if absolute capture time RTP header extension is ' + + 'offered, but not answered'); + + promise_test(async t => { + const [caller, callee] = await initiateSingleTrackCall( + t, /* caps= */{[kind]: true}, /* absCaptureTimeOffered= */true, + /* absCaptureTimeAnswered= */true); + const receiver = callee.getReceivers()[0]; + await listenForCaptureTimestamp(t, receiver); + }, '[' + kind + '] getSynchronizationSources() should contain ' + + 'captureTimestamp if absolute capture time RTP header extension is ' + + 'negotiated'); +} + +// Passes if `captureTimestamp` for audio and video are comparable, which is +// expected since the test creates a local peer connection between `caller` and +// `callee`. +promise_test(async t => { + const [caller, callee] = await initiateSingleTrackCall( + t, /* caps= */{audio: true, video: true}, + /* absCaptureTimeOffered= */true, /* absCaptureTimeAnswered= */true); + const receivers = callee.getReceivers(); + assert_equals(receivers.length, 2); + + let captureTimestamps = [undefined, undefined]; + const t0 = performance.now(); + for (let i = 0; i < 2; ++i) { + captureTimestamps[i] = await listenForCaptureTimestamp(t, receivers[i]); + } + const t1 = performance.now(); + assert_less_than(Math.abs(captureTimestamps[0] - captureTimestamps[1]), + t1 - t0); +}, 'Audio and video RTCRtpSynchronizationSource.captureTimestamp are ' + + 'comparable'); + +</script> diff --git a/testing/web-platform/tests/webrtc-extensions/RTCRtpSynchronizationSource-helper.js b/testing/web-platform/tests/webrtc-extensions/RTCRtpSynchronizationSource-helper.js new file mode 100644 index 0000000000..10cfd65155 --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/RTCRtpSynchronizationSource-helper.js @@ -0,0 +1,140 @@ +'use strict'; + +// This file depends on `webrtc/RTCPeerConnection-helper.js` +// which should be loaded from the main HTML file. + +var kAbsCaptureTime = + 'http://www.webrtc.org/experiments/rtp-hdrext/abs-capture-time'; + +function addHeaderExtensionToSdp(sdp, uri) { + const extmap = new RegExp('a=extmap:(\\d+)'); + let sdpLines = sdp.split('\r\n'); + + // This assumes at most one audio m= section and one video m= section. + // If more are present, only the first section of each kind is munged. + for (const section of ['audio', 'video']) { + let found_section = false; + let maxId = undefined; + let maxIdLine = undefined; + let extmapAllowMixed = false; + + // find the largest header extension id for section. + for (let i = 0; i < sdpLines.length; ++i) { + if (!found_section) { + if (sdpLines[i].startsWith('m=' + section)) { + found_section = true; + } + continue; + } else { + if (sdpLines[i].startsWith('m=')) { + // end of section + break; + } + } + + if (sdpLines[i] === 'a=extmap-allow-mixed') { + extmapAllowMixed = true; + } + let result = sdpLines[i].match(extmap); + if (result && result.length === 2) { + if (maxId == undefined || result[1] > maxId) { + maxId = parseInt(result[1]); + maxIdLine = i; + } + } + } + + if (maxId == 14 && !extmapAllowMixed) { + // Reaching the limit of one byte header extension. Adding two byte header + // extension support. + sdpLines.splice(maxIdLine + 1, 0, 'a=extmap-allow-mixed'); + } + if (maxIdLine !== undefined) { + sdpLines.splice(maxIdLine + 1, 0, + 'a=extmap:' + (maxId + 1).toString() + ' ' + uri); + } + } + return sdpLines.join('\r\n'); +} + +// TODO(crbug.com/1051821): Use RTP header extension API instead of munging +// when the RTP header extension API is implemented. +async function addAbsCaptureTimeAndExchangeOffer(caller, callee) { + let offer = await caller.createOffer(); + + // Absolute capture time header extension may not be offered by default, + // in such case, munge the SDP. + offer.sdp = addHeaderExtensionToSdp(offer.sdp, kAbsCaptureTime); + + await caller.setLocalDescription(offer); + return callee.setRemoteDescription(offer); +} + +// TODO(crbug.com/1051821): Use RTP header extension API instead of munging +// when the RTP header extension API is implemented. +async function checkAbsCaptureTimeAndExchangeAnswer(caller, callee, + absCaptureTimeAnswered) { + let answer = await callee.createAnswer(); + + const extmap = new RegExp('a=extmap:\\d+ ' + kAbsCaptureTime + '\r\n', 'g'); + if (answer.sdp.match(extmap) == null) { + // We expect that absolute capture time RTP header extension is answered. + // But if not, there is no need to proceed with the test. + assert_false(absCaptureTimeAnswered, 'Absolute capture time RTP ' + + 'header extension is not answered'); + } else { + if (!absCaptureTimeAnswered) { + // We expect that absolute capture time RTP header extension is not + // answered, but it is, then we munge the answer to remove it. + answer.sdp = answer.sdp.replace(extmap, ''); + } + } + + await callee.setLocalDescription(answer); + return caller.setRemoteDescription(answer); +} + +async function exchangeOfferAndListenToOntrack(t, caller, callee, + absCaptureTimeOffered) { + const ontrackPromise = addEventListenerPromise(t, callee, 'track'); + // Absolute capture time header extension is expected not offered by default, + // and thus munging is needed to enable it. + await absCaptureTimeOffered + ? addAbsCaptureTimeAndExchangeOffer(caller, callee) + : exchangeOffer(caller, callee); + return ontrackPromise; +} + +async function initiateSingleTrackCall(t, cap, absCaptureTimeOffered, + absCaptureTimeAnswered) { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream = await getNoiseStream(cap); + stream.getTracks().forEach(track => { + caller.addTrack(track, stream); + t.add_cleanup(() => track.stop()); + }); + + // TODO(crbug.com/988432): `getSynchronizationSources() on the audio side + // needs a hardware sink for the returned dictionary entries to get updated. + const remoteVideo = document.getElementById('remote'); + + callee.ontrack = e => { + remoteVideo.srcObject = e.streams[0]; + } + + exchangeIceCandidates(caller, callee); + + await exchangeOfferAndListenToOntrack(t, caller, callee, + absCaptureTimeOffered); + + // Exchange answer and check whether the absolute capture time RTP header + // extension is answered. + await checkAbsCaptureTimeAndExchangeAnswer(caller, callee, + absCaptureTimeAnswered); + + return [caller, callee]; +} diff --git a/testing/web-platform/tests/webrtc-extensions/RTCRtpSynchronizationSource-senderCaptureTimeOffset.html b/testing/web-platform/tests/webrtc-extensions/RTCRtpSynchronizationSource-senderCaptureTimeOffset.html new file mode 100644 index 0000000000..63ad9bf888 --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/RTCRtpSynchronizationSource-senderCaptureTimeOffset.html @@ -0,0 +1,92 @@ +<!doctype html> +<meta charset=utf-8> +<!-- This file contains a test that waits for 2 seconds. --> +<meta name="timeout" content="long"> +<title>senderCaptureTimeOffset attribute in RTCRtpSynchronizationSource</title> +<div><video id="remote" width="124" height="124" autoplay></video></div> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/webrtc/RTCPeerConnection-helper.js"></script> +<script src="/webrtc/RTCStats-helper.js"></script> +<script src="/webrtc-extensions/RTCRtpSynchronizationSource-helper.js"></script> +<script> +'use strict'; + +function listenForSenderCaptureTimeOffset(t, receiver) { + return new Promise((resolve) => { + function listen() { + const ssrcs = receiver.getSynchronizationSources(); + assert_true(ssrcs != undefined); + if (ssrcs.length > 0) { + assert_equals(ssrcs.length, 1); + if (ssrcs[0].captureTimestamp != undefined) { + resolve(ssrcs[0].senderCaptureTimeOffset); + return true; + } + } + return false; + }; + t.step_wait(listen, 'No abs-capture-time capture time header extension.'); + }); +} + +// Passes if `getSynchronizationSources()` contains `senderCaptureTimeOffset` if +// and only if expected. +for (const kind of ['audio', 'video']) { + promise_test(async t => { + const [caller, callee] = await initiateSingleTrackCall( + t, /* caps= */{[kind]: true}, /* absCaptureTimeOffered= */false, + /* absCaptureTimeAnswered= */false); + const receiver = callee.getReceivers()[0]; + + for (const ssrc of await listenForSSRCs(t, receiver)) { + assert_equals(typeof ssrc.senderCaptureTimeOffset, 'undefined'); + } + }, '[' + kind + '] getSynchronizationSources() should not contain ' + + 'senderCaptureTimeOffset if absolute capture time RTP header extension ' + + 'is not offered'); + + promise_test(async t => { + const [caller, callee] = await initiateSingleTrackCall( + t, /* caps= */{[kind]: true}, /* absCaptureTimeOffered= */false, + /* absCaptureTimeAnswered= */false); + const receiver = callee.getReceivers()[0]; + + for (const ssrc of await listenForSSRCs(t, receiver)) { + assert_equals(typeof ssrc.senderCaptureTimeOffset, 'undefined'); + } + }, '[' + kind + '] getSynchronizationSources() should not contain ' + + 'senderCaptureTimeOffset if absolute capture time RTP header extension ' + + 'is offered, but not answered'); + + promise_test(async t => { + const [caller, callee] = await initiateSingleTrackCall( + t, /* caps= */{[kind]: true}, /* absCaptureTimeOffered= */true, + /* absCaptureTimeAnswered= */true); + const receiver = callee.getReceivers()[0]; + let senderCaptureTimeOffset = await listenForSenderCaptureTimeOffset( + t, receiver); + assert_true(senderCaptureTimeOffset != undefined); + }, '[' + kind + '] getSynchronizationSources() should contain ' + + 'senderCaptureTimeOffset if absolute capture time RTP header extension ' + + 'is negotiated'); +} + +// Passes if `senderCaptureTimeOffset` is zero, which is expected since the test +// creates a local peer connection between `caller` and `callee`. +promise_test(async t => { + const [caller, callee] = await initiateSingleTrackCall( + t, /* caps= */{audio: true, video: true}, + /* absCaptureTimeOffered= */true, /* absCaptureTimeAnswered= */true); + const receivers = callee.getReceivers(); + assert_equals(receivers.length, 2); + + for (let i = 0; i < 2; ++i) { + let senderCaptureTimeOffset = await listenForSenderCaptureTimeOffset( + t, receivers[i]); + assert_equals(senderCaptureTimeOffset, 0); + } +}, 'Audio and video RTCRtpSynchronizationSource.senderCaptureTimeOffset must ' + + 'be zero'); + +</script> diff --git a/testing/web-platform/tests/webrtc-extensions/RTCRtpTransceiver-headerExtensionControl.html b/testing/web-platform/tests/webrtc-extensions/RTCRtpTransceiver-headerExtensionControl.html new file mode 100644 index 0000000000..79eba02727 --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/RTCRtpTransceiver-headerExtensionControl.html @@ -0,0 +1,295 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpParameters encodings</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/webrtc/dictionary-helper.js"></script> +<script src="/webrtc/RTCRtpParameters-helper.js"></script> +<script src="/webrtc/third_party/sdp/sdp.js"></script> +<script> +'use strict'; + +async function negotiate(pc1, pc2) { + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); +} + +['audio', 'video'].forEach(kind => { + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver(kind); + const capabilities = transceiver.getHeaderExtensionsToNegotiate(); + const capability = capabilities.find((capability) => { + return capability.uri === 'urn:ietf:params:rtp-hdrext:sdes:mid'; + }); + assert_not_equals(capability, undefined); + assert_equals(capability.direction, 'sendrecv'); + }, `the ${kind} transceiver.getHeaderExtensionsToNegotiate() includes mandatory extensions`); +}); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const capabilities = transceiver.getHeaderExtensionsToNegotiate(); + capabilities[0].uri = ''; + assert_throws_js(TypeError, () => { + transceiver.setHeaderExtensionsToNegotiate(capabilities); + }, 'transceiver should throw TypeError when setting an empty URI'); +}, `setHeaderExtensionsToNegotiate throws TypeError on encountering missing URI`); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const capabilities = transceiver.getHeaderExtensionsToNegotiate(); + capabilities[0].direction = ''; + assert_throws_js(TypeError, () => { + transceiver.setHeaderExtensionsToNegotiate(capabilities); + }, 'transceiver should throw TypeError when setting an empty direction'); +}, `setHeaderExtensionsToNegotiate throws TypeError on encountering missing direction`); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const capabilities = transceiver.getHeaderExtensionsToNegotiate(); + capabilities[0].uri = '4711'; + assert_throws_dom('InvalidModificationError', () => { + transceiver.setHeaderExtensionsToNegotiate(capabilities); + }, 'transceiver should throw InvalidModificationError when setting an unknown URI'); +}, `setHeaderExtensionsToNegotiate throws InvalidModificationError on encountering unknown URI`); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('video'); + const capabilities = transceiver.getHeaderExtensionsToNegotiate().filter(capability => { + return capability.uri === 'urn:ietf:params:rtp-hdrext:sdes:mid'; + }); + assert_throws_dom('InvalidModificationError', () => { + transceiver.setHeaderExtensionsToNegotiate(capabilities); + }, 'transceiver should throw InvalidModificationError when removing elements from the list'); +}, `setHeaderExtensionsToNegotiate throws InvalidModificationError when removing elements from the list`); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('video'); + const capabilities = transceiver.getHeaderExtensionsToNegotiate(); + capabilities.push({ + uri: '4711', + direction: 'recvonly', + }); + assert_throws_dom('InvalidModificationError', () => { + transceiver.setHeaderExtensionsToNegotiate(capabilities); + }, 'transceiver should throw InvalidModificationError when adding elements to the list'); +}, `setHeaderExtensionsToNegotiate throws InvalidModificationError when adding elements to the list`); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const capabilities = transceiver.getHeaderExtensionsToNegotiate(); + let capability = capabilities.find((capability) => { + return capability.uri === 'urn:ietf:params:rtp-hdrext:sdes:mid'; + }); + ['sendonly', 'recvonly', 'inactive', 'stopped'].map(direction => { + capability.direction = direction; + assert_throws_dom('InvalidModificationError', () => { + transceiver.setHeaderExtensionsToNegotiate(capabilities); + }, `transceiver should throw InvalidModificationError when setting a mandatory header extension\'s direction to ${direction}`); + }); +}, `setHeaderExtensionsToNegotiate throws InvalidModificationError when setting a mandatory header extension\'s direction to something else than "sendrecv"`); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + let capabilities = transceiver.getHeaderExtensionsToNegotiate(); + let selected_capability = capabilities.find((capability) => { + return capability.direction === 'sendrecv' && + capability.uri !== 'urn:ietf:params:rtp-hdrext:sdes:mid'; + }); + selected_capability.direction = 'stopped'; + const offered_capabilities = transceiver.getHeaderExtensionsToNegotiate(); + let altered_capability = capabilities.find((capability) => { + return capability.uri === selected_capability.uri && + capability.direction === 'stopped'; + }); + assert_not_equals(altered_capability, undefined); +}, `modified direction set by setHeaderExtensionsToNegotiate is visible in subsequent getHeaderExtensionsToNegotiate`); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('video'); + const capabilities = transceiver.getHeaderExtensionsToNegotiate(); + const offer = await pc.createOffer(); + const extensions = SDPUtils.matchPrefix(SDPUtils.splitSections(offer.sdp)[1], 'a=extmap:') + .map(line => SDPUtils.parseExtmap(line)); + for (const capability of capabilities) { + if (capability.direction === 'stopped') { + assert_equals(undefined, extensions.find(e => e.uri === capability.uri)); + } else { + assert_not_equals(undefined, extensions.find(e => e.uri === capability.uri)); + } + } +}, `Unstopped extensions turn up in offer`); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('video'); + const capabilities = transceiver.getHeaderExtensionsToNegotiate(); + const selected_capability = capabilities.find((capability) => { + return capability.direction === 'sendrecv' && + capability.uri !== 'urn:ietf:params:rtp-hdrext:sdes:mid'; + }); + selected_capability.direction = 'stopped'; + transceiver.setHeaderExtensionsToNegotiate(capabilities); + const offer = await pc.createOffer(); + const extensions = SDPUtils.matchPrefix(SDPUtils.splitSections(offer.sdp)[1], 'a=extmap:') + .map(line => SDPUtils.parseExtmap(line)); + for (const capability of capabilities) { + if (capability.direction === 'stopped') { + assert_equals(undefined, extensions.find(e => e.uri === capability.uri)); + } else { + assert_not_equals(undefined, extensions.find(e => e.uri === capability.uri)); + } + } +}, `Stopped extensions do not turn up in offers`); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + // Disable a non-mandatory extension before first negotiation. + const transceiver = pc1.addTransceiver('video'); + const capabilities = transceiver.getHeaderExtensionsToNegotiate(); + const selected_capability = capabilities.find((capability) => { + return capability.direction === 'sendrecv' && + capability.uri !== 'urn:ietf:params:rtp-hdrext:sdes:mid'; + }); + selected_capability.direction = 'stopped'; + transceiver.setHeaderExtensionsToNegotiate(capabilities); + + await negotiate(pc1, pc2); + const negotiated_capabilites = transceiver.getNegotiatedHeaderExtensions(); + + assert_equals(capabilities.length, negotiated_capabilites.length); +}, `The set of negotiated extensions has the same size as the set of extensions to negotiate`); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + // Disable a non-mandatory extension before first negotiation. + const transceiver = pc1.addTransceiver('video'); + const capabilities = transceiver.getHeaderExtensionsToNegotiate(); + const selected_capability = capabilities.find((capability) => { + return capability.direction === 'sendrecv' && + capability.uri !== 'urn:ietf:params:rtp-hdrext:sdes:mid'; + }); + selected_capability.direction = 'stopped'; + transceiver.setHeaderExtensionsToNegotiate(capabilities); + + await negotiate(pc1, pc2); + const negotiated_capabilites = transceiver.getNegotiatedHeaderExtensions(); + + // Attempt enabling the extension. + selected_capability.direction = 'sendrecv'; + + // The enabled extension should not be part of the negotiated set. + transceiver.setHeaderExtensionsToNegotiate(capabilities); + await negotiate(pc1, pc2); + assert_not_equals( + transceiver.getNegotiatedHeaderExtensions().find(capability => { + return capability.uri === selected_capability.uri && + capability.direction === 'sendrecv'; + }), undefined); +}, `Header extensions can be reactivated in subsequent offers`); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const t1 = pc.addTransceiver('video'); + const t2 = pc.addTransceiver('video'); + const extensionUri = 'urn:3gpp:video-orientation'; + + assert_true(!!t1.getHeaderExtensionsToNegotiate().find(ext => ext.uri === extensionUri)); + const ext1 = t1.getHeaderExtensionsToNegotiate(); + ext1.find(ext => ext.uri === extensionUri).direction = 'stopped'; + t1.setHeaderExtensionsToNegotiate(ext1); + + assert_true(!!t2.getHeaderExtensionsToNegotiate().find(ext => ext.uri === extensionUri)); + const ext2 = t2.getHeaderExtensionsToNegotiate(); + ext2.find(ext => ext.uri === extensionUri).direction = 'sendrecv'; + t2.setHeaderExtensionsToNegotiate(ext2); + + const offer = await pc.createOffer(); + const sections = SDPUtils.splitSections(offer.sdp); + sections.shift(); + const extensions = sections.map(section => { + return SDPUtils.matchPrefix(section, 'a=extmap:') + .map(SDPUtils.parseExtmap); + }); + assert_equals(extensions.length, 2); + assert_false(!!extensions[0].find(extension => extension.uri === extensionUri)); + assert_true(!!extensions[1].find(extension => extension.uri === extensionUri)); +}, 'Header extensions can be deactivated on a per-mline basis'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const t1 = pc1.addTransceiver('video'); + + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + // Get the transceiver after it is created by SRD. + const t2 = pc2.getTransceivers()[0]; + const t2_capabilities = t2.getHeaderExtensionsToNegotiate(); + const t2_capability_to_stop = t2_capabilities + .find(capability => capability.uri !== 'urn:ietf:params:rtp-hdrext:sdes:mid'); + assert_not_equals(undefined, t2_capability_to_stop); + t2_capability_to_stop.direction = 'stopped'; + t2.setHeaderExtensionsToNegotiate(t2_capabilities); + + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + + const t1_negotiated = t1.getNegotiatedHeaderExtensions() + .find(extension => extension.uri === t2_capability_to_stop.uri); + assert_not_equals(undefined, t1_negotiated); + assert_equals(t1_negotiated.direction, 'stopped'); + const t1_capability = t1.getHeaderExtensionsToNegotiate() + .find(extension => extension.uri === t2_capability_to_stop.uri); + assert_not_equals(undefined, t1_capability); + assert_equals(t1_capability.direction, 'sendrecv'); +}, 'Extensions not negotiated by the peer are `stopped` in getNegotiatedHeaderExtensions'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('video'); + const negotiated_capabilites = transceiver.getNegotiatedHeaderExtensions(); + assert_equals(negotiated_capabilites.length, + transceiver.getHeaderExtensionsToNegotiate().length); + for (const capability of negotiated_capabilites) { + assert_equals(capability.direction, 'stopped'); + } +}, 'Prior to negotiation, getNegotiatedHeaderExtensions() returns `stopped` for all extensions.'); + +</script> diff --git a/testing/web-platform/tests/webrtc-extensions/transfer-datachannel-service-worker.https.html b/testing/web-platform/tests/webrtc-extensions/transfer-datachannel-service-worker.https.html new file mode 100644 index 0000000000..625fee4fe1 --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/transfer-datachannel-service-worker.https.html @@ -0,0 +1,83 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + </head> + <body> + <script src="../service-workers/service-worker/resources/test-helpers.sub.js"></script> + <script> +async function createConnections(test, firstConnectionCallback, secondConnectionCallback) +{ + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + + test.add_cleanup(() => pc1.close()); + test.add_cleanup(() => pc2.close()); + + pc1.onicecandidate = (e) => pc2.addIceCandidate(e.candidate); + pc2.onicecandidate = (e) => pc1.addIceCandidate(e.candidate); + + firstConnectionCallback(pc1); + + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + await pc2.setRemoteDescription(offer); + + secondConnectionCallback(pc2); + + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); +} + +async function waitForMessage(receiver, data) +{ + while (true) { + const received = await new Promise(resolve => receiver.onmessage = (event) => resolve(event.data)); + if (data === received) + return; + } +} + +promise_test(async (test) => { + let frame; + const scope = 'resources/'; + const script = 'transfer-datachannel-service-worker.js'; + + await service_worker_unregister(test, scope); + const registration = await navigator.serviceWorker.register(script, {scope}); + test.add_cleanup(async () => { + return service_worker_unregister(test, scope); + }); + const worker = registration.installing; + + const messageChannel = new MessageChannel(); + + let localChannel; + let remoteChannel; + + await new Promise((resolve, reject) => { + createConnections(test, (firstConnection) => { + localChannel = firstConnection.createDataChannel('sendDataChannel'); + worker.postMessage({channel: localChannel, port: messageChannel.port2}, [localChannel, messageChannel.port2]); + }, (secondConnection) => { + secondConnection.ondatachannel = (event) => { + remoteChannel = event.channel; + remoteChannel.onopen = resolve; + }; + }); + }); + + const promise = waitForMessage(messageChannel.port1, "OK"); + remoteChannel.send("OK"); + await promise; + + const data = new Promise(resolve => remoteChannel.onmessage = (event) => resolve(event.data)); + messageChannel.port1.postMessage({message: "OK2"}); + assert_equals(await data, "OK2"); +}, "offerer data channel in service worker"); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webrtc-extensions/transfer-datachannel-service-worker.js b/testing/web-platform/tests/webrtc-extensions/transfer-datachannel-service-worker.js new file mode 100644 index 0000000000..c1919d0b9a --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/transfer-datachannel-service-worker.js @@ -0,0 +1,15 @@ +let channel; +let port; +onmessage = (e) => { + if (e.data.port) { + port = e.data.port; + port.onmessage = (event) => channel.send(event.data.message); + } + if (e.data.channel) { + channel = e.data.channel; + channel.onopen = () => port.postMessage("opened"); + channel.onerror = () => port.postMessage("errored"); + channel.onclose = () => port.postMessage("closed"); + channel.onmessage = (event) => port.postMessage(event.data); + } +}; diff --git a/testing/web-platform/tests/webrtc-extensions/transfer-datachannel-worker.js b/testing/web-platform/tests/webrtc-extensions/transfer-datachannel-worker.js new file mode 100644 index 0000000000..10d71f68f0 --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/transfer-datachannel-worker.js @@ -0,0 +1,19 @@ +let channel; +onmessage = (event) => { + if (event.data.channel) { + channel = event.data.channel; + channel.onopen = () => self.postMessage("opened"); + channel.onerror = () => self.postMessage("errored"); + channel.onclose = () => self.postMessage("closed"); + channel.onmessage = event => self.postMessage(event.data); + } + if (event.data.message) { + if (channel) + channel.send(event.data.message); + } + if (event.data.close) { + if (channel) + channel.close(); + } +}; +self.postMessage("registered"); diff --git a/testing/web-platform/tests/webrtc-extensions/transfer-datachannel.html b/testing/web-platform/tests/webrtc-extensions/transfer-datachannel.html new file mode 100644 index 0000000000..9759a67a24 --- /dev/null +++ b/testing/web-platform/tests/webrtc-extensions/transfer-datachannel.html @@ -0,0 +1,165 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + </head> + <body> + <script src="../service-workers/service-worker/resources/test-helpers.sub.js"></script> + <script> +async function createConnections(test, firstConnectionCallback, secondConnectionCallback) +{ + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + + test.add_cleanup(() => pc1.close()); + test.add_cleanup(() => pc2.close()); + + pc1.onicecandidate = (e) => pc2.addIceCandidate(e.candidate); + pc2.onicecandidate = (e) => pc1.addIceCandidate(e.candidate); + + firstConnectionCallback(pc1); + + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + await pc2.setRemoteDescription(offer); + + secondConnectionCallback(pc2); + + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); +} + +async function waitForMessage(receiver, data) +{ + while (true) { + const received = await new Promise(resolve => receiver.onmessage = (event) => resolve(event.data)); + if (data === received) + return; + } +} + +promise_test(async (test) => { + let localChannel; + let remoteChannel; + + const worker = new Worker('transfer-datachannel-worker.js'); + let data = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + assert_equals(await data, "registered"); + + await new Promise((resolve, reject) => { + createConnections(test, (firstConnection) => { + localChannel = firstConnection.createDataChannel('sendDataChannel'); + worker.postMessage({channel: localChannel}, [localChannel]); + data = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + }, (secondConnection) => { + secondConnection.ondatachannel = (event) => { + remoteChannel = event.channel; + remoteChannel.onopen = resolve; + }; + }); + }); + + assert_equals(await data, "opened"); + + data = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + remoteChannel.send("OK"); + assert_equals(await data, "OK"); + + data = new Promise(resolve => remoteChannel.onmessage = (event) => resolve(event.data)); + worker.postMessage({message: "OK2"}); + assert_equals(await data, "OK2"); +}, "offerer data channel in workers"); + + +promise_test(async (test) => { + let localChannel; + let remoteChannel; + + const worker = new Worker('transfer-datachannel-worker.js'); + let data = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + assert_equals(await data, "registered"); + + data = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + await new Promise((resolve, reject) => { + createConnections(test, (firstConnection) => { + localChannel = firstConnection.createDataChannel('sendDataChannel'); + localChannel.onopen = resolve; + }, (secondConnection) => { + secondConnection.ondatachannel = (event) => { + remoteChannel = event.channel; + worker.postMessage({channel: remoteChannel}, [remoteChannel]); + }; + }); + }); + assert_equals(await data, "opened"); + + data = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + localChannel.send("OK"); + assert_equals(await data, "OK"); + + data = new Promise(resolve => localChannel.onmessage = (event) => resolve(event.data)); + worker.postMessage({message: "OK2"}); + assert_equals(await data, "OK2"); +}, "answerer data channel in workers"); + +promise_test(async (test) => { + let localChannel; + let remoteChannel; + + const worker = new Worker('transfer-datachannel-worker.js'); + let data = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + assert_equals(await data, "registered"); + + data = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + await new Promise((resolve, reject) => { + createConnections(test, (firstConnection) => { + localChannel = firstConnection.createDataChannel('sendDataChannel'); + worker.postMessage({channel: localChannel}, [localChannel]); + + }, (secondConnection) => { + secondConnection.ondatachannel = (event) => { + remoteChannel = event.channel; + remoteChannel.onopen = resolve; + }; + }); + }); + assert_equals(await data, "opened"); + + data = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + remoteChannel.close(); + assert_equals(await data, "closed"); + +}, "data channel close event in worker"); + +promise_test(async (test) => { + let localChannel; + let remoteChannel; + + const worker = new Worker('transfer-datachannel-worker.js'); + let data = new Promise(resolve => worker.onmessage = (event) => resolve(event.data)); + assert_equals(await data, "registered"); + + await new Promise((resolve, reject) => { + createConnections(test, (firstConnection) => { + localChannel = firstConnection.createDataChannel('sendDataChannel'); + }, (secondConnection) => { + secondConnection.ondatachannel = (event) => { + remoteChannel = event.channel; + test.step_timeout(() => { + try { + worker.postMessage({channel: remoteChannel}, [remoteChannel]); + reject("postMessage ok"); + } catch(e) { + resolve(); + } + }, 0); + }; + }); + }); +}, "Failing to transfer a data channel"); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webrtc-ice/META.yml b/testing/web-platform/tests/webrtc-ice/META.yml new file mode 100644 index 0000000000..e683349e3c --- /dev/null +++ b/testing/web-platform/tests/webrtc-ice/META.yml @@ -0,0 +1,3 @@ +spec: https://w3c.github.io/webrtc-ice/ +suggested_reviewers: + - alvestrand diff --git a/testing/web-platform/tests/webrtc-ice/RTCIceTransport-extension-helper.js b/testing/web-platform/tests/webrtc-ice/RTCIceTransport-extension-helper.js new file mode 100644 index 0000000000..659ec59b8d --- /dev/null +++ b/testing/web-platform/tests/webrtc-ice/RTCIceTransport-extension-helper.js @@ -0,0 +1,42 @@ +'use strict'; + +// Construct an RTCIceTransport instance. The instance will automatically be +// cleaned up when the test finishes. +function makeIceTransport(t) { + const iceTransport = new RTCIceTransport(); + t.add_cleanup(() => iceTransport.stop()); + return iceTransport; +} + +// Construct two RTCIceTransport instances, configure them to exchange +// candidates, then gather() them. +// Returns a 2-list: [ RTCIceTransport, RTCIceTransport ] +function makeAndGatherTwoIceTransports(t) { + const localTransport = makeIceTransport(t); + const remoteTransport = makeIceTransport(t); + localTransport.onicecandidate = e => { + if (e.candidate) { + remoteTransport.addRemoteCandidate(e.candidate); + } + }; + remoteTransport.onicecandidate = e => { + if (e.candidate) { + localTransport.addRemoteCandidate(e.candidate); + } + }; + localTransport.gather({}); + remoteTransport.gather({}); + return [ localTransport, remoteTransport ]; +} + +// Construct two RTCIceTransport instances, configure them to exchange +// candidates and parameters, then gather() and start() them. +// Returns a 2-list: +// [ controlling RTCIceTransport, +// controlled RTCIceTransport ] +function makeGatherAndStartTwoIceTransports(t) { + const [ localTransport, remoteTransport ] = makeAndGatherTwoIceTransports(t); + localTransport.start(remoteTransport.getLocalParameters(), 'controlling'); + remoteTransport.start(localTransport.getLocalParameters(), 'controlled'); + return [ localTransport, remoteTransport ]; +} diff --git a/testing/web-platform/tests/webrtc-ice/RTCIceTransport-extension.https.html b/testing/web-platform/tests/webrtc-ice/RTCIceTransport-extension.https.html new file mode 100644 index 0000000000..bb4d52adce --- /dev/null +++ b/testing/web-platform/tests/webrtc-ice/RTCIceTransport-extension.https.html @@ -0,0 +1,362 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCIceTransport-extensions.https.html</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCIceTransport-extension-helper.js"></script> +<script> +'use strict'; + +// These tests are based on the following extension specification: +// https://w3c.github.io/webrtc-ice/ + +// The following helper functions are called from +// RTCIceTransport-extension-helper.js: +// makeIceTransport +// makeGatherAndStartTwoIceTransports + +const ICE_UFRAG = 'u'.repeat(4); +const ICE_PWD = 'p'.repeat(22); + +test(() => { + const iceTransport = new RTCIceTransport(); +}, 'RTCIceTransport constructor does not throw'); + +test(() => { + const iceTransport = new RTCIceTransport(); + assert_equals(iceTransport.role, null, 'Expect role to be null'); + assert_equals(iceTransport.state, 'new', `Expect state to be 'new'`); + assert_equals(iceTransport.gatheringState, 'new', + `Expect gatheringState to be 'new'`); + assert_array_equals(iceTransport.getLocalCandidates(), [], + 'Expect no local candidates'); + assert_array_equals(iceTransport.getRemoteCandidates(), [], + 'Expect no remote candidates'); + assert_equals(iceTransport.getSelectedCandidatePair(), null, + 'Expect no selected candidate pair'); + assert_not_equals(iceTransport.getLocalParameters(), null, + 'Expect local parameters generated'); + assert_equals(iceTransport.getRemoteParameters(), null, + 'Expect no remote parameters'); +}, 'RTCIceTransport initial properties are set'); + +test(t => { + const iceTransport = makeIceTransport(t); + assert_throws_js(TypeError, () => + iceTransport.gather({ iceServers: null })); +}, 'gather() with { iceServers: null } should throw TypeError'); + +test(t => { + const iceTransport = makeIceTransport(t); + iceTransport.gather({ iceServers: undefined }); +}, 'gather() with { iceServers: undefined } should succeed'); + +test(t => { + const iceTransport = makeIceTransport(t); + iceTransport.gather({ iceServers: [{ + urls: ['turns:turn.example.org', 'turn:turn.example.net'], + username: 'user', + credential: 'cred', + }] }); +}, 'gather() with one turns server, one turn server, username, credential' + + ' should succeed'); + +test(t => { + const iceTransport = makeIceTransport(t); + iceTransport.gather({ iceServers: [{ + urls: ['stun:stun1.example.net', 'stun:stun2.example.net'], + }] }); +}, 'gather() with 2 stun servers should succeed'); + +test(t => { + const iceTransport = makeIceTransport(t); + iceTransport.stop(); + assert_throws_dom('InvalidStateError', () => iceTransport.gather({})); +}, 'gather() throws if closed'); + +test(t => { + const iceTransport = makeIceTransport(t); + iceTransport.gather({}); + assert_equals(iceTransport.gatheringState, 'gathering'); +}, `gather() transitions gatheringState to 'gathering'`); + +test(t => { + const iceTransport = makeIceTransport(t); + iceTransport.gather({}); + assert_throws_dom('InvalidStateError', () => iceTransport.gather({})); +}, 'gather() throws if called twice'); + +promise_test(async t => { + const iceTransport = makeIceTransport(t); + const watcher = new EventWatcher(t, iceTransport, 'gatheringstatechange'); + iceTransport.gather({}); + await watcher.wait_for('gatheringstatechange'); + assert_equals(iceTransport.gatheringState, 'complete'); +}, `eventually transition gatheringState to 'complete'`); + +promise_test(async t => { + const iceTransport = makeIceTransport(t); + const watcher = new EventWatcher(t, iceTransport, + [ 'icecandidate', 'gatheringstatechange' ]); + iceTransport.gather({}); + let candidate; + do { + (({ candidate } = await watcher.wait_for('icecandidate'))); + } while (candidate !== null); + assert_equals(iceTransport.gatheringState, 'gathering'); + await watcher.wait_for('gatheringstatechange'); + assert_equals(iceTransport.gatheringState, 'complete'); +}, 'onicecandidate fires with null candidate before gatheringState' + + ` transitions to 'complete'`); + +promise_test(async t => { + const iceTransport = makeIceTransport(t); + const watcher = new EventWatcher(t, iceTransport, 'icecandidate'); + iceTransport.gather({}); + const { candidate } = await watcher.wait_for('icecandidate'); + assert_not_equals(candidate.candidate, ''); + assert_array_equals(iceTransport.getLocalCandidates(), [candidate]); +}, 'gather() returns at least one host candidate'); + +promise_test(async t => { + const iceTransport = makeIceTransport(t); + const watcher = new EventWatcher(t, iceTransport, 'icecandidate'); + iceTransport.gather({ gatherPolicy: 'relay' }); + const { candidate } = await watcher.wait_for('icecandidate'); + assert_equals(candidate, null); + assert_array_equals(iceTransport.getLocalCandidates(), []); +}, `gather() returns no candidates with { gatherPolicy: 'relay'} and no turn` + + ' servers'); + +const dummyRemoteParameters = { + usernameFragment: ICE_UFRAG, + password: ICE_PWD, +}; + +test(() => { + const iceTransport = new RTCIceTransport(); + iceTransport.stop(); + assert_throws_dom('InvalidStateError', + () => iceTransport.start(dummyRemoteParameters)); + assert_equals(iceTransport.getRemoteParameters(), null); +}, `start() throws if closed`); + +test(() => { + const iceTransport = new RTCIceTransport(); + assert_throws_js(TypeError, () => iceTransport.start({})); + assert_throws_js(TypeError, + () => iceTransport.start({ usernameFragment: ICE_UFRAG })); + assert_throws_js(TypeError, + () => iceTransport.start({ password: ICE_PWD })); + assert_equals(iceTransport.getRemoteParameters(), null); +}, 'start() throws if usernameFragment or password not set'); + +test(() => { + const TEST_CASES = [ + {usernameFragment: '2sh', description: 'less than 4 characters long'}, + { + usernameFragment: 'x'.repeat(257), + description: 'greater than 256 characters long', + }, + {usernameFragment: '123\n', description: 'illegal character'}, + ]; + for (const {usernameFragment, description} of TEST_CASES) { + const iceTransport = new RTCIceTransport(); + assert_throws_dom( + 'SyntaxError', + () => iceTransport.start({ usernameFragment, password: ICE_PWD }), + `illegal usernameFragment (${description}) should throw a SyntaxError`); + } +}, 'start() throws if usernameFragment does not conform to syntax'); + +test(() => { + const TEST_CASES = [ + {password: 'x'.repeat(21), description: 'less than 22 characters long'}, + { + password: 'x'.repeat(257), + description: 'greater than 256 characters long', + }, + {password: ('x'.repeat(21) + '\n'), description: 'illegal character'}, + ]; + for (const {password, description} of TEST_CASES) { + const iceTransport = new RTCIceTransport(); + assert_throws_dom( + 'SyntaxError', + () => iceTransport.start({ usernameFragment: ICE_UFRAG, password }), + `illegal password (${description}) should throw a SyntaxError`); + } +}, 'start() throws if password does not conform to syntax'); + +const assert_ice_parameters_equals = (a, b) => { + assert_equals(a.usernameFragment, b.usernameFragment, + 'usernameFragments are equal'); + assert_equals(a.password, b.password, 'passwords are equal'); +}; + +test(t => { + const iceTransport = makeIceTransport(t); + iceTransport.start(dummyRemoteParameters); + assert_equals(iceTransport.state, 'new'); + assert_ice_parameters_equals(iceTransport.getRemoteParameters(), + dummyRemoteParameters); +}, `start() does not transition state to 'checking' if no remote candidates ` + + 'added'); + +test(t => { + const iceTransport = makeIceTransport(t); + iceTransport.start(dummyRemoteParameters); + assert_equals(iceTransport.role, 'controlled'); +}, `start() with default role sets role attribute to 'controlled'`); + +test(t => { + const iceTransport = makeIceTransport(t); + iceTransport.start(dummyRemoteParameters, 'controlling'); + assert_equals(iceTransport.role, 'controlling'); +}, `start() sets role attribute to 'controlling'`); + +const candidate1 = new RTCIceCandidate({ + candidate: 'candidate:1 1 udp 2113929471 203.0.113.100 10100 typ host', + sdpMid: '', +}); + +test(() => { + const iceTransport = new RTCIceTransport(); + iceTransport.stop(); + assert_throws_dom('InvalidStateError', + () => iceTransport.addRemoteCandidate(candidate1)); + assert_array_equals(iceTransport.getRemoteCandidates(), []); +}, 'addRemoteCandidate() throws if closed'); + +test(() => { + const iceTransport = new RTCIceTransport(); + assert_throws_dom('OperationError', + () => iceTransport.addRemoteCandidate( + new RTCIceCandidate({ candidate: 'invalid', sdpMid: '' }))); + assert_array_equals(iceTransport.getRemoteCandidates(), []); +}, 'addRemoteCandidate() throws on invalid candidate'); + +test(t => { + const iceTransport = makeIceTransport(t); + iceTransport.addRemoteCandidate(candidate1); + iceTransport.start(dummyRemoteParameters); + assert_equals(iceTransport.state, 'checking'); + assert_array_equals(iceTransport.getRemoteCandidates(), [candidate1]); +}, `start() transitions state to 'checking' if one remote candidate had been ` + + 'added'); + +test(t => { + const iceTransport = makeIceTransport(t); + iceTransport.start(dummyRemoteParameters); + iceTransport.addRemoteCandidate(candidate1); + assert_equals(iceTransport.state, 'checking'); + assert_array_equals(iceTransport.getRemoteCandidates(), [candidate1]); +}, `addRemoteCandidate() transitions state to 'checking' if start() had been ` + + 'called before'); + +test(t => { + const iceTransport = makeIceTransport(t); + iceTransport.start(dummyRemoteParameters); + assert_throws_dom('InvalidStateError', + () => iceTransport.start(dummyRemoteParameters, 'controlling')); +}, 'start() throws if later called with a different role'); + +test(t => { + const iceTransport = makeIceTransport(t); + iceTransport.start({ + usernameFragment: '1'.repeat(4), + password: '1'.repeat(22), + }); + iceTransport.addRemoteCandidate(candidate1); + const changedRemoteParameters = { + usernameFragment: '2'.repeat(4), + password: '2'.repeat(22), + }; + iceTransport.start(changedRemoteParameters); + assert_equals(iceTransport.state, 'new'); + assert_array_equals(iceTransport.getRemoteCandidates(), []); + assert_ice_parameters_equals(iceTransport.getRemoteParameters(), + changedRemoteParameters); +}, `start() flushes remote candidates and transitions state to 'new' if ` + + 'later called with different remote parameters'); + +promise_test(async t => { + const [ localTransport, remoteTransport ] = + makeGatherAndStartTwoIceTransports(t); + const localWatcher = new EventWatcher(t, localTransport, 'statechange'); + const remoteWatcher = new EventWatcher(t, remoteTransport, 'statechange'); + await Promise.all([ + localWatcher.wait_for('statechange').then(() => { + assert_equals(localTransport.state, 'connected'); + }), + remoteWatcher.wait_for('statechange').then(() => { + assert_equals(remoteTransport.state, 'connected'); + }), + ]); +}, 'Two RTCIceTransports connect to each other'); + +['controlling', 'controlled'].forEach(role => { + promise_test(async t => { + const [ localTransport, remoteTransport ] = + makeAndGatherTwoIceTransports(t); + localTransport.start(remoteTransport.getLocalParameters(), role); + remoteTransport.start(localTransport.getLocalParameters(), role); + const localWatcher = new EventWatcher(t, localTransport, 'statechange'); + const remoteWatcher = new EventWatcher(t, remoteTransport, 'statechange'); + await Promise.all([ + localWatcher.wait_for('statechange').then(() => { + assert_equals(localTransport.state, 'connected'); + }), + remoteWatcher.wait_for('statechange').then(() => { + assert_equals(remoteTransport.state, 'connected'); + }), + ]); + }, `Two RTCIceTransports configured with the ${role} role resolve the ` + + 'conflict in band and still connect.'); +}); + +promise_test(async t => { + async function waitForSelectedCandidatePairChangeThenConnected(t, transport, + transportName) { + const watcher = new EventWatcher(t, transport, + [ 'statechange', 'selectedcandidatepairchange' ]); + await watcher.wait_for('selectedcandidatepairchange'); + const selectedCandidatePair = transport.getSelectedCandidatePair(); + assert_not_equals(selectedCandidatePair, null, + `${transportName} selected candidate pair should not be null once ` + + 'the selectedcandidatepairchange event fires'); + assert_true( + transport.getLocalCandidates().some( + ({ candidate }) => + candidate === selectedCandidatePair.local.candidate), + `${transportName} selected candidate pair local should be in the ` + + 'list of local candidates'); + assert_true( + transport.getRemoteCandidates().some( + ({ candidate }) => + candidate === selectedCandidatePair.remote.candidate), + `${transportName} selected candidate pair local should be in the ` + + 'list of remote candidates'); + await watcher.wait_for('statechange'); + assert_equals(transport.state, 'connected', + `${transportName} state should be 'connected'`); + } + const [ localTransport, remoteTransport ] = + makeGatherAndStartTwoIceTransports(t); + await Promise.all([ + waitForSelectedCandidatePairChangeThenConnected(t, localTransport, + 'local transport'), + waitForSelectedCandidatePairChangeThenConnected(t, remoteTransport, + 'remote transport'), + ]); +}, 'Selected candidate pair changes once the RTCIceTransports connect.'); + +promise_test(async t => { + const [ transport, ] = makeGatherAndStartTwoIceTransports(t); + const watcher = new EventWatcher(t, transport, 'selectedcandidatepairchange'); + await watcher.wait_for('selectedcandidatepairchange'); + transport.stop(); + assert_equals(transport.getSelectedCandidatePair(), null); +}, 'getSelectedCandidatePair() returns null once the RTCIceTransport is ' + + 'stopped.'); + +</script> diff --git a/testing/web-platform/tests/webrtc-identity/META.yml b/testing/web-platform/tests/webrtc-identity/META.yml new file mode 100644 index 0000000000..fb919db954 --- /dev/null +++ b/testing/web-platform/tests/webrtc-identity/META.yml @@ -0,0 +1,4 @@ +spec: https://w3c.github.io/webrtc-identity/identity.html +suggested_reviewers: + - martinthomson + - jan-ivar diff --git a/testing/web-platform/tests/webrtc-identity/RTCPeerConnection-constructor.html b/testing/web-platform/tests/webrtc-identity/RTCPeerConnection-constructor.html new file mode 100644 index 0000000000..e7b7016338 --- /dev/null +++ b/testing/web-platform/tests/webrtc-identity/RTCPeerConnection-constructor.html @@ -0,0 +1,11 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection constructor</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +test(() => { + const toStringThrows = { toString: function() { throw new Error; } }; + assert_throws_js(Error, () => new RTCPeerConnection({ peerIdentity: toStringThrows })); +}, "RTCPeerConnection constructor throws if the given peerIdentity getter throws"); +</script> diff --git a/testing/web-platform/tests/webrtc-identity/RTCPeerConnection-getIdentityAssertion.sub.https.html b/testing/web-platform/tests/webrtc-identity/RTCPeerConnection-getIdentityAssertion.sub.https.html new file mode 100644 index 0000000000..57d7b16165 --- /dev/null +++ b/testing/web-platform/tests/webrtc-identity/RTCPeerConnection-getIdentityAssertion.sub.https.html @@ -0,0 +1,397 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.getIdentityAssertion</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="identity-helper.sub.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The tests here interacts with the mock identity provider located at + // /.well-known/idp-proxy/mock-idp.js + + // The following helper functions are called from identity-helper.sub.js + // parseAssertionResult + // getIdpDomains + // assert_rtcerror_rejection + // hostString + + /* + 9.6. RTCPeerConnection Interface Extensions + partial interface RTCPeerConnection { + void setIdentityProvider(DOMString provider, + optional RTCIdentityProviderOptions options); + Promise<DOMString> getIdentityAssertion(); + readonly attribute Promise<RTCIdentityAssertion> peerIdentity; + readonly attribute DOMString? idpLoginUrl; + readonly attribute DOMString? idpErrorInfo; + }; + + dictionary RTCIdentityProviderOptions { + DOMString protocol = "default"; + DOMString usernameHint; + DOMString peerIdentity; + }; + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + const port = window.location.port; + + const [idpDomain] = getIdpDomains(); + const idpHost = hostString(idpDomain, port); + + pc.setIdentityProvider(idpHost, { + protocol: 'mock-idp.js?foo=bar', + usernameHint: `alice@${idpDomain}`, + peerIdentity: 'bob@example.org' + }); + + return pc.getIdentityAssertion() + .then(assertionResultStr => { + const { idp, assertion } = parseAssertionResult(assertionResultStr); + + assert_equals(idp.domain, idpHost, + 'Expect mock-idp.js to construct domain from its location.host'); + + assert_equals(idp.protocol, 'mock-idp.js', + 'Expect mock-idp.js to return protocol of itself with no query string'); + + const { + watermark, + args, + env, + query, + } = assertion; + + assert_equals(watermark, 'mock-idp.js.watermark', + 'Expect assertion result to contain watermark left by mock-idp.js'); + + assert_equals(args.origin, window.origin, + 'Expect args.origin argument to be the origin of this window'); + + assert_equals(env.location.href, + `https://${idpHost}/.well-known/idp-proxy/mock-idp.js?foo=bar`, + 'Expect IdP proxy to be loaded with full well-known URL constructed from provider and protocol'); + + assert_equals(env.location.origin, `https://${idpHost}`, + 'Expect IdP to have its own origin'); + + assert_equals(args.options.protocol, 'mock-idp.js?foo=bar', + 'Expect options.protocol to be the same value as being passed from here'); + + assert_equals(args.options.usernameHint, `alice@${idpDomain}`, + 'Expect options.usernameHint to be the same value as being passed from here'); + + assert_equals(args.options.peerIdentity, 'bob@example.org', + 'Expect options.peerIdentity to be the same value as being passed from here'); + + assert_equals(query.foo, 'bar', + 'Expect query string to be parsed by mock-idp.js and returned back'); + }); + }, 'getIdentityAssertion() should load IdP proxy and return assertion generated'); + + // When generating assertion, the RTCPeerConnection doesn't care if the returned assertion + // represents identity of different domain + promise_test(t => { + const pc = new RTCPeerConnection(); + const port = window.location.port; + + const [idpDomain1, idpDomain2] = getIdpDomains(); + assert_not_equals(idpDomain1, idpDomain2, + 'Sanity check two idpDomains are different'); + + // Ask mock-idp.js to return a custom domain idpDomain2 and custom protocol foo + pc.setIdentityProvider(hostString(idpDomain1, port), { + protocol: `mock-idp.js?generatorAction=return-custom-idp&domain=${idpDomain2}&protocol=foo`, + usernameHint: `alice@${idpDomain2}`, + }); + + return pc.getIdentityAssertion() + .then(assertionResultStr => { + const { idp, assertion } = parseAssertionResult(assertionResultStr); + assert_equals(idp.domain, idpDomain2); + assert_equals(idp.protocol, 'foo'); + assert_equals(assertion.args.options.usernameHint, `alice@${idpDomain2}`); + }); + }, 'getIdentityAssertion() should succeed if mock-idp.js return different domain and protocol in assertion'); + + /* + 9.3. Requesting Identity Assertions + 4. If the IdP proxy produces an error or returns a promise that does not resolve to + a valid RTCIdentityValidationResult (see 9.5 IdP Error Handling), then identity + validation fails. + + 9.5. IdP Error Handling + - If an identity provider throws an exception or returns a promise that is ultimately + rejected, then the procedure that depends on the IdP MUST also fail. These types of + errors will cause an IdP failure with an RTCError with errorDetail set to + "idp-execution-failure". + + 9.6. RTCPeerConnection Interface Extensions + idpErrorInfo + An attribute that the IdP can use to pass additional information back to the + applications about the error. The format of this string is defined by the IdP and + may be JSON. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + assert_equals(pc.idpErrorInfo, null, + 'Expect initial pc.idpErrorInfo to be null'); + + const port = window.location.port; + const [idpDomain] = getIdpDomains(); + + // Ask mock-idp.js to throw an error with err.errorInfo set to bar + pc.setIdentityProvider(hostString(idpDomain, port), { + protocol: `mock-idp.js?generatorAction=throw-error&errorInfo=bar`, + usernameHint: `alice@${idpDomain}`, + }); + + return assert_rtcerror_rejection('idp-execution-failure', + pc.getIdentityAssertion()) + .then(() => { + assert_equals(pc.idpErrorInfo, 'bar', + 'Expect pc.idpErrorInfo to be set to the err.idpErrorInfo thrown by mock-idp.js'); + }); + }, `getIdentityAssertion() should reject with RTCError('idp-execution-failure') if mock-idp.js throws error`); + + /* + 9.5. IdP Error Handling + - If the script loaded from the identity provider is not valid JavaScript or does + not implement the correct interfaces, it causes an IdP failure with an RTCError + with errorDetail set to "idp-bad-script-failure". + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + const port = window.location.port; + const [idpDomain] = getIdpDomains(); + + // Ask mock-idp.js to not register its callback to the + // RTCIdentityProviderRegistrar + pc.setIdentityProvider(hostString(idpDomain, port), { + protocol: `mock-idp.js?action=do-not-register`, + usernameHint: `alice@${idpDomain}`, + }); + + return assert_rtcerror_rejection('idp-bad-script-failure', + pc.getIdentityAssertion()); + + }, `getIdentityAssertion() should reject with RTCError('idp-bad-script-failure') if IdP proxy script do not register its callback`); + + /* + 9.3. Requesting Identity Assertions + 4. If the IdP proxy produces an error or returns a promise that does not resolve + to a valid RTCIdentityAssertionResult (see 9.5 IdP Error Handling), then assertion + generation fails. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + const port = window.location.port; + const [idpDomain] = getIdpDomains(); + + // Ask mock-idp.js to return an invalid result that is not proper + // RTCIdentityAssertionResult + pc.setIdentityProvider(hostString(idpDomain, port), { + protocol: `mock-idp.js?generatorAction=return-invalid-result`, + usernameHint: `alice@${idpDomain}`, + }); + + return promise_rejects_dom(t, 'OperationError', + pc.getIdentityAssertion()); + }, `getIdentityAssertion() should reject with OperationError if mock-idp.js return invalid result`); + + /* + 9.5. IdP Error Handling + - A RTCPeerConnection might be configured with an identity provider, but loading of + the IdP URI fails. Any procedure that attempts to invoke such an identity provider + and cannot load the URI fails with an RTCError with errorDetail set to + "idp-load-failure" and the httpRequestStatusCode attribute of the error set to the + HTTP status code of the response. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + pc.setIdentityProvider('nonexistent.{{domains[]}}', { + protocol: `non-existent`, + usernameHint: `alice@example.org`, + }); + + return assert_rtcerror_rejection('idp-load-failure', + pc.getIdentityAssertion()); + }, `getIdentityAssertion() should reject with RTCError('idp-load-failure') if IdP cannot be loaded`); + + /* + 9.3.1. User Login Procedure + Rejecting the promise returned by generateAssertion will cause the error to + propagate to the application. Login errors are indicated by rejecting the + promise with an RTCError with errorDetail set to "idp-need-login". + + The URL to login at will be passed to the application in the idpLoginUrl + attribute of the RTCPeerConnection. + + 9.5. IdP Error Handling + - If the identity provider requires the user to login, the operation will fail + RTCError with errorDetail set to "idp-need-login" and the idpLoginUrl attribute + of the error set to the URL that can be used to login. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + assert_equals(pc.idpLoginUrl, null, + 'Expect initial pc.idpLoginUrl to be null'); + + const port = window.location.port; + const [idpDomain] = getIdpDomains(); + const idpHost = hostString(idpDomain, port); + + pc.setIdentityProvider(idpHost, { + protocol: `mock-idp.js?generatorAction=require-login`, + usernameHint: `alice@${idpDomain}`, + }); + + return assert_rtcerror_rejection('idp-need-login', + pc.getIdentityAssertion()) + .then(err => { + assert_equals(err.idpLoginUrl, `https://${idpHost}/login`, + 'Expect err.idpLoginUrl to be set to url set by mock-idp.js'); + + assert_equals(pc.idpLoginUrl, `https://${idpHost}/login`, + 'Expect pc.idpLoginUrl to be set to url set by mock-idp.js'); + + assert_equals(pc.idpErrorInfo, 'login required', + 'Expect pc.idpErrorInfo to be set to info set by mock-idp.js'); + }); + }, `getIdentityAssertion() should reject with RTCError('idp-need-login') when mock-idp.js requires login`); + + /* + RTCIdentityProviderOptions Members + peerIdentity + The identity of the peer. For identity providers that bind their assertions to a + particular pair of communication peers, this allows them to generate an assertion + that includes both local and remote identities. If this value is omitted, but a + value is provided for the peerIdentity member of RTCConfiguration, the value from + RTCConfiguration is used. + */ + promise_test(t => { + const pc = new RTCPeerConnection({ + peerIdentity: 'bob@example.net' + }); + + const port = window.location.port; + const [idpDomain] = getIdpDomains(); + const idpHost = hostString(idpDomain, port); + + pc.setIdentityProvider(idpHost, { + protocol: 'mock-idp.js' + }); + + return pc.getIdentityAssertion() + .then(assertionResultStr => { + const { assertion } = parseAssertionResult(assertionResultStr); + assert_equals(assertion.args.options.peerIdentity, 'bob@example.net'); + }); + }, 'setIdentityProvider() with no peerIdentity provided should use peerIdentity value from getConfiguration()'); + + /* + 9.6. setIdentityProvider + 3. If any identity provider value has changed, discard any stored identity assertion. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + const port = window.location.port; + const [idpDomain] = getIdpDomains(); + const idpHost = hostString(idpDomain, port); + + pc.setIdentityProvider(idpHost, { + protocol: 'mock-idp.js?mark=first' + }); + + return pc.getIdentityAssertion() + .then(assertionResultStr => { + const { assertion } = parseAssertionResult(assertionResultStr); + assert_equals(assertion.query.mark, 'first'); + + pc.setIdentityProvider(idpHost, { + protocol: 'mock-idp.js?mark=second' + }); + + return pc.getIdentityAssertion(); + }) + .then(assertionResultStr => { + const { assertion } = parseAssertionResult(assertionResultStr); + assert_equals(assertion.query.mark, 'second', + 'Expect generated assertion is from second IdP config'); + }); + }, `Calling setIdentityProvider() multiple times should reset identity assertions`); + + promise_test(t => { + const pc = new RTCPeerConnection(); + const port = window.location.port; + const [idpDomain] = getIdpDomains(); + + pc.setIdentityProvider(hostString(idpDomain, port), { + protocol: 'mock-idp.js', + usernameHint: `alice@${idpDomain}` + }); + + return pc.getIdentityAssertion() + .then(assertionResultStr => + pc.createOffer() + .then(offer => { + assert_true(offer.sdp.includes(`\r\na=identity:${assertionResultStr}`, + 'Expect SDP to have a=identity line containing assertion string')); + })); + }, 'createOffer() should return SDP containing identity assertion string if identity provider is set'); + + /* + 6. Requesting Identity Assertions + + The identity assertion request process is triggered by a call to + createOffer, createAnswer, or getIdentityAssertion. When these calls are + invoked and an identity provider has been set, the following steps are + executed: + + ... + + If assertion generation fails, then the promise for the corresponding + function call is rejected with a newly created OperationError. */ + promise_test(t => { + const pc = new RTCPeerConnection(); + const port = window.location.port; + const [idpDomain] = getIdpDomains(); + + pc.setIdentityProvider(hostString(idpDomain, port), { + protocol: 'mock-idp.js?generatorAction=throw-error', + usernameHint: `alice@${idpDomain}` + }); + + return promise_rejects_dom(t, 'OperationError', + pc.createOffer()); + }, 'createOffer() should reject with OperationError if identity assertion request fails'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + const port = window.location.port; + const [idpDomain] = getIdpDomains(); + + pc.setIdentityProvider(hostString(idpDomain, port), { + protocol: 'mock-idp.js?generatorAction=throw-error', + usernameHint: `alice@${idpDomain}` + }); + + return new RTCPeerConnection() + .createOffer() + .then(offer => pc.setRemoteDescription(offer)) + .then(() => + promise_rejects_dom(t, 'OperationError', + pc.createAnswer())); + + }, 'createAnswer() should reject with OperationError if identity assertion request fails'); + +</script> diff --git a/testing/web-platform/tests/webrtc-identity/RTCPeerConnection-peerIdentity.https.html b/testing/web-platform/tests/webrtc-identity/RTCPeerConnection-peerIdentity.https.html new file mode 100644 index 0000000000..268e406211 --- /dev/null +++ b/testing/web-platform/tests/webrtc-identity/RTCPeerConnection-peerIdentity.https.html @@ -0,0 +1,328 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.peerIdentity</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="identity-helper.sub.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-identity/identity.html + + // The tests here interacts with the mock identity provider located at + // /.well-known/idp-proxy/mock-idp.js + + // The following helper functions are called from identity-helper.sub.js + // parseAssertionResult + // getIdpDomains + // assert_rtcerror_rejection + // hostString + + /* + 9.6. RTCPeerConnection Interface Extensions + partial interface RTCPeerConnection { + void setIdentityProvider(DOMString provider, + optional RTCIdentityProviderOptions options); + Promise<DOMString> getIdentityAssertion(); + readonly attribute Promise<RTCIdentityAssertion> peerIdentity; + readonly attribute DOMString? idpLoginUrl; + readonly attribute DOMString? idpErrorInfo; + }; + + dictionary RTCIdentityProviderOptions { + DOMString protocol = "default"; + DOMString usernameHint; + DOMString peerIdentity; + }; + + [Constructor(DOMString idp, DOMString name)] + interface RTCIdentityAssertion { + attribute DOMString idp; + attribute DOMString name; + }; + */ + + /* + 4.3.2. setRemoteDescription + If an a=identity attribute is present in the session description, the browser + validates the identity assertion.. + + If the "peerIdentity" configuration is applied to the RTCPeerConnection, this + establishes a target peer identity of the provided value. Alternatively, if the + RTCPeerConnection has previously authenticated the identity of the peer (that + is, there is a current value for peerIdentity ), then this also establishes a + target peer identity. + */ + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + + t.add_cleanup(() => pc2.close()); + + const port = window.location.port; + const [idpDomain] = getIdpDomains(); + const idpHost = hostString(idpDomain, port); + + pc1.setIdentityProvider(idpHost, { + protocol: 'mock-idp.js', + usernameHint: `alice@${idpDomain}` + }); + + const peerIdentity = pc2.peerIdentity; + await pc2.setRemoteDescription(await pc1.createOffer()); + const { idp, name } = await peerIdentity; + assert_equals(idp, idpHost, `Expect IdP to be ${idpHost}`); + assert_equals(name, `alice@${idpDomain}`, + `Expect validated identity from mock-idp.js to be same as specified in usernameHint`); + }, 'setRemoteDescription() on offer with a=identity should establish peerIdentity'); + + promise_test(async t => { + const port = window.location.port; + const [idpDomain] = getIdpDomains(); + const idpHost = hostString(idpDomain, port); + + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + pc1.setIdentityProvider(idpHost, { + protocol: 'mock-idp.js', + usernameHint: `doesnt_matter@${idpDomain}` + }); + + const pc2 = new RTCPeerConnection({ + peerIdentity: `bob@${idpDomain}` + }); + + t.add_cleanup(() => pc2.close()); + + pc2.setIdentityProvider(idpHost, { + protocol: 'mock-idp.js', + usernameHint: `alice@${idpDomain}` + }); + + const offer = await pc1.createOffer(); + + await promise_rejects_dom(t, 'OperationError', + pc2.setRemoteDescription(offer)); + await promise_rejects_dom(t, 'OperationError', + pc2.peerIdentity); + }, 'setRemoteDescription() on offer with a=identity that resolve to value different from target peer identity should reject with OperationError'); + + /* + 9.4. Verifying Identity Assertions + 8. The RTCPeerConnection decodes the contents and validates that it contains a + fingerprint value for every a=fingerprint attribute in the session description. + This ensures that the certificate used by the remote peer for communications + is covered by the identity assertion. + + If identity validation fails, the peerIdentity promise is rejected with a newly + created OperationError. + + If identity validation fails and there is a target peer identity for the + RTCPeerConnection, the promise returned by setRemoteDescription MUST be rejected + with the same DOMException. + */ + promise_test(t => { + const port = window.location.port; + const [idpDomain] = getIdpDomains(); + const idpHost = hostString(idpDomain, port); + + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection({ + peerIdentity: `alice@${idpDomain}` + }); + + t.add_cleanup(() => pc2.close()); + + // Ask mockidp.js to return custom contents in validation result + pc1.setIdentityProvider(idpHost, { + protocol: 'mock-idp.js?validatorAction=return-custom-contents&contents=bogus', + usernameHint: `alice@${idpDomain}` + }); + + const peerIdentityPromise = pc2.peerIdentity; + + return pc1.createOffer() + .then(offer => Promise.all([ + promise_rejects_dom(t, 'IdpError', + pc2.setRemoteDescription(offer)), + promise_rejects_dom(t, 'OperationError', + peerIdentityPromise) + ])); + }, 'setRemoteDescription() with peerIdentity set and with IdP proxy that return validationAssertion with mismatch contents should reject with OperationError'); + + /* + 9.4. Verifying Identity Assertions + 9. The RTCPeerConnection validates that the domain portion of the identity matches + the domain of the IdP as described in [RTCWEB-SECURITY-ARCH]. If this check + fails then the identity validation fails. + */ + promise_test(t => { + const port = window.location.port; + const [idpDomain1, idpDomain2] = getIdpDomains(); + assert_not_equals(idpDomain1, idpDomain2, + 'Sanity check two idpDomains are different'); + + const idpHost1 = hostString(idpDomain1, port); + + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection({ + peerIdentity: `alice@${idpDomain2}` + }); + + t.add_cleanup(() => pc2.close()); + + // mock-idp.js will return assertion of domain2 identity + // with domain1 in the idp.domain field + pc1.setIdentityProvider(idpHost1, { + protocol: 'mock-idp.js', + usernameHint: `alice@${idpDomain2}` + }); + + return pc1.getIdentityAssertion() + .then(assertionResultStr => { + const { idp, assertion } = parseAssertionResult(assertionResultStr); + + assert_equals(idp.domain, idpHost1, + 'Sanity check domain of assertion is host1'); + + assert_equals(assertion.args.options.usernameHint, `alice@${idpDomain2}`, + 'Sanity check domain1 is going to validate a domain2 identity'); + + return pc1.createOffer(); + }) + .then(offer => Promise.all([ + promise_rejects_dom(t, 'OperationError', + pc2.setRemoteDescription(offer)), + promise_rejects_dom(t, 'OperationError', + pc2.peerIdentity) + ])); + }, 'setRemoteDescription() and peerIdentity should reject with OperationError if IdP return validated identity that is different from its own domain'); + + /* + 9.4 Verifying Identity Assertions + If identity validation fails and there is a target peer identity for the + RTCPeerConnection, the promise returned by setRemoteDescription MUST be rejected + with the same DOMException. + + 9.5 IdP Error Handling + - If an identity provider throws an exception or returns a promise that is ultimately + rejected, then the procedure that depends on the IdP MUST also fail. These types of + errors will cause an IdP failure with an RTCError with errorDetail set to + "idp-execution-failure". + + Any error generated by the IdP MAY provide additional information in the + idpErrorInfo attribute. The information in this string is defined by the + IdP in use. + */ + promise_test(t => { + const port = window.location.port; + const [idpDomain] = getIdpDomains(); + const idpHost = hostString(idpDomain, port); + + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection({ + peerIdentity: `alice@${idpDomain}` + }); + + t.add_cleanup(() => pc2.close()); + + // Ask mock-idp.js to throw error during validation, + // i.e. during pc2.setRemoteDescription() + pc1.setIdentityProvider(idpHost, { + protocol: 'mock-idp.js?validatorAction=throw-error&errorInfo=bar', + usernameHint: `alice@${idpDomain}` + }); + + return pc1.createOffer() + .then(offer => Promise.all([ + assert_rtcerror_rejection('idp-execution-failure', + pc2.setRemoteDescription(offer)), + assert_rtcerror_rejection('idp-execution-failure', + pc2.peerIdentity) + ])) + .then(() => { + assert_equals(pc2.idpErrorInfo, 'bar', + 'Expect pc2.idpErrorInfo to be set to the err.idpErrorInfo thrown by mock-idp.js'); + }); + }, `When IdP throws error and pc has target peer identity, setRemoteDescription() and peerIdentity rejected with RTCError('idp-execution-error')`); + + /* + 4.3.2. setRemoteDescription + If there is no target peer identity, then setRemoteDescription does not await the + completion of identity validation. + + 9.5. IdP Error Handling + - If an identity provider throws an exception or returns a promise that is + ultimately rejected, then the procedure that depends on the IdP MUST also fail. + These types of errors will cause an IdP failure with an RTCError with errorDetail + set to "idp-execution-failure". + + 9.4. Verifying Identity Assertions + If identity validation fails and there is no a target peer identity, the value of + the peerIdentity MUST be set to a new, unresolved promise instance. This permits + the use of renegotiation (or a subsequent answer, if the session description was + a provisional answer) to resolve or reject the identity. + */ + promise_test(t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + + t.add_cleanup(() => pc2.close()); + + const port = window.location.port; + const [idpDomain] = getIdpDomains(); + const idpHost = hostString(idpDomain, port); + + // Ask mock-idp.js to throw error during validation, + // i.e. during pc2.setRemoteDescription() + pc1.setIdentityProvider(idpHost, { + protocol: 'mock-idp.js?validatorAction=throw-error', + usernameHint: `alice@${idpDomain}` + }); + + const peerIdentityPromise1 = pc2.peerIdentity; + + return pc1.createOffer() + .then(offer => + // setRemoteDescription should succeed because there is no target peer identity set + pc2.setRemoteDescription(offer)) + .then(() => + assert_rtcerror_rejection('idp-execution-failure', + peerIdentityPromise1, + `Expect first peerIdentity promise to be rejected with RTCError('idp-execution-failure')`)) + .then(() => { + const peerIdentityPromise2 = pc2.peerIdentity; + assert_not_equals(peerIdentityPromise2, peerIdentityPromise1, + 'Expect pc2.peerIdentity to be replaced with a fresh unresolved promise'); + + // regenerate an identity assertion with no test option to throw error + pc1.setIdentityProvider(idpHost, { + protocol: 'idp-test.js', + usernameHint: `alice@${idpDomain}` + }); + + return pc1.createOffer() + .then(offer => pc2.setRemoteDescription(offer)) + .then(peerIdentityPromise2) + .then(identityAssertion => { + const { idp, name } = identityAssertion; + + assert_equals(idp, idpDomain, + `Expect IdP domain to be ${idpDomain}`); + + assert_equals(name, `alice@${idpDomain}`, + `Expect validated identity to be alice@${idpDomain}`); + + assert_equals(pc2.peeridentity, peerIdentityPromise2, + 'Expect pc2.peerIdentity to stay fixed after identity is validated'); + }); + }); + }, 'IdP failure with no target peer identity should have following setRemoteDescription() succeed and replace pc.peerIdentity with a new promise'); + +</script> diff --git a/testing/web-platform/tests/webrtc-identity/identity-helper.sub.js b/testing/web-platform/tests/webrtc-identity/identity-helper.sub.js new file mode 100644 index 0000000000..90363662f7 --- /dev/null +++ b/testing/web-platform/tests/webrtc-identity/identity-helper.sub.js @@ -0,0 +1,70 @@ +'use strict'; + +/* + In web-platform-test, a number of domains are required to be set up locally. + The list is available at docs/_writing-tests/server-features.md. The + appropriate hosts file entries can be generated with the WPT CLI via the + following command: `wpt make-hosts-file`. + */ + +/* + dictionary RTCIdentityProviderDetails { + required DOMString domain; + DOMString protocol = "default"; + }; + */ + +// Parse a base64 JSON encoded string returned from getIdentityAssertion(). +// This is also the string that is set in the a=identity line. +// Returns a { idp, assertion } where idp is of type RTCIdentityProviderDetails +// and assertion is the deserialized JSON that was returned by the +// IdP proxy's generateAssertion() function. +function parseAssertionResult(assertionResultStr) { + const assertionResult = JSON.parse(atob(assertionResultStr)); + + const { idp } = assertionResult; + const assertion = JSON.parse(assertionResult.assertion); + + return { idp, assertion }; +} + +// Return two distinct IdP domains that are different from current domain +function getIdpDomains() { + const domainA = '{{domains[www]}}'; + const domainB = '{{domains[www1]}}'; + const domainC = '{{domains[www2]}}'; + + if(window.location.hostname === domainA) { + return [domainB, domainC]; + } else if(window.location.hostname === domainB) { + return [domainA, domainC]; + } else { + return [domainA, domainB]; + } +} + +function assert_rtcerror_rejection(errorDetail, promise, desc) { + return promise.then( + res => { + assert_unreached(`Expect promise to be rejected with RTCError, but instead got ${res}`); + }, err => { + assert_true(err instanceof RTCError, + 'Expect error object to be instance of RTCError'); + + assert_equals(err.errorDetail, errorDetail, + `Expect RTCError object have errorDetail set to ${errorDetail}`); + + return err; + }); +} + +// construct a host string consist of domain and optionally port +// If the default HTTP/HTTPS port is used, window.location.port returns +// empty string. +function hostString(domain, port) { + if(port === '') { + return domain; + } else { + return `${domain}:${port}`; + } +} diff --git a/testing/web-platform/tests/webrtc-identity/idlharness.https.window.js b/testing/web-platform/tests/webrtc-identity/idlharness.https.window.js new file mode 100644 index 0000000000..8eb60c960a --- /dev/null +++ b/testing/web-platform/tests/webrtc-identity/idlharness.https.window.js @@ -0,0 +1,24 @@ +// META: script=/resources/WebIDLParser.js +// META: script=/resources/idlharness.js + +'use strict'; + +idl_test( + ['webrtc-identity'], + ['webrtc', 'mediacapture-streams', 'html', 'dom', 'webidl'], + async idlArray => { + idlArray.add_objects({ + RTCPeerConnection: [`new RTCPeerConnection()`], + RTCIdentityAssertion: [`new RTCIdentityAssertion('idp', 'name')`], + MediaStreamTrack: ['track'], + // TODO: RTCIdentityProviderGlobalScope + // TODO: RTCIdentityProviderRegistrar + }); + + try { + self.track = await navigator.mediaDevices + .getUserMedia({audio: true}) + .then(m => m.getTracks()[0]); + } catch (e) {} + } +); diff --git a/testing/web-platform/tests/webrtc-priority/META.yml b/testing/web-platform/tests/webrtc-priority/META.yml new file mode 100644 index 0000000000..a422e81447 --- /dev/null +++ b/testing/web-platform/tests/webrtc-priority/META.yml @@ -0,0 +1 @@ +spec: https://w3c.github.io/webrtc-priority/ diff --git a/testing/web-platform/tests/webrtc-priority/RTCPeerConnection-ondatachannel.html b/testing/web-platform/tests/webrtc-priority/RTCPeerConnection-ondatachannel.html new file mode 100644 index 0000000000..e4b1e8d58a --- /dev/null +++ b/testing/web-platform/tests/webrtc-priority/RTCPeerConnection-ondatachannel.html @@ -0,0 +1,66 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.ondatachannel</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +promise_test(async (t) => { + const resolver = new Resolver(); + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const dc1 = pc1.createDataChannel('test', { + ordered: false, + maxRetransmits: 1, + protocol: 'custom', + priority: 'high' + }); + + assert_equals(dc1.priority, 'high'); + + pc2.ondatachannel = t.step_func((event) => { + const dc2 = event.channel; + + assert_equals(dc2.priority, 'high'); + + resolver.resolve(); + }); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + await resolver; +}, 'In-band negotiated channel created on remote peer should match the same configuration as local ' + + 'peer'); + +promise_test(async (t) => { + const resolver = new Resolver(); + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const dc1 = pc1.createDataChannel(''); + + assert_equals(dc1.priority, 'low'); + + pc2.ondatachannel = t.step_func((event) => { + const dc2 = event.channel; + assert_equals(dc2.priority, 'low'); + + resolver.resolve(); + }); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + await resolver; +}, 'In-band negotiated channel created on remote peer should match the same (default) ' + + 'configuration as local peer'); + +</script> diff --git a/testing/web-platform/tests/webrtc-priority/RTCRtpParameters-encodings.html b/testing/web-platform/tests/webrtc-priority/RTCRtpParameters-encodings.html new file mode 100644 index 0000000000..1519ee84f7 --- /dev/null +++ b/testing/web-platform/tests/webrtc-priority/RTCRtpParameters-encodings.html @@ -0,0 +1,44 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpParameters encodings</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../webrtc/dictionary-helper.js"></script> +<script src="../webrtc/RTCRtpParameters-helper.js"></script> +<script> + 'use strict'; + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video', { + sendEncodings: [{ + active: false, + priority: 'low', + networkPriority: 'low', + maxBitrate: 8, + maxFramerate: 25, + rid: 'foo' + }] + }); + await doOfferAnswerExchange(t, pc); + + const param = sender.getParameters(); + validateSenderRtpParameters(param); + const encoding = param.encodings[0]; + + assert_equals(encoding.active, false); + assert_equals(encoding.priority, 'low'); + assert_equals(encoding.networkPriority, 'low'); + }, `sender.getParameters() should return sendEncodings set by addTransceiver()`); + + test_modified_encoding('audio', 'active', false, true, + 'setParameters() with modified encoding.active should succeed'); + + test_modified_encoding('audio', 'priority', 'very-low', 'high', + 'setParameters() with modified encoding.priority should succeed'); + + test_modified_encoding('audio', 'networkPriority', 'very-low', 'high', + 'setParameters() with modified encoding.networkPriority should succeed'); + +</script> diff --git a/testing/web-platform/tests/webrtc-stats/META.yml b/testing/web-platform/tests/webrtc-stats/META.yml new file mode 100644 index 0000000000..10bcf856eb --- /dev/null +++ b/testing/web-platform/tests/webrtc-stats/META.yml @@ -0,0 +1,5 @@ +spec: https://w3c.github.io/webrtc-stats/ +suggested_reviewers: + - henbos + - vr000m + - jan-ivar diff --git a/testing/web-platform/tests/webrtc-stats/README.md b/testing/web-platform/tests/webrtc-stats/README.md new file mode 100644 index 0000000000..2b69372894 --- /dev/null +++ b/testing/web-platform/tests/webrtc-stats/README.md @@ -0,0 +1,7 @@ +The following 4 test cases in the `webrtc/` directory test some of the mandatory-to-implement stats defined in WebRTC Statistics: + +* `getstats.html` +* `RTCPeerConnection-getStats.https.html` +* `RTCPeerConnection-track-stats.https.html` +* `RTCRtpReceiver-getStats.https.html` +* `RTCRtpSender-getStats.https.html` diff --git a/testing/web-platform/tests/webrtc-stats/getStats-remote-candidate-address.html b/testing/web-platform/tests/webrtc-stats/getStats-remote-candidate-address.html new file mode 100644 index 0000000000..08e2aec90e --- /dev/null +++ b/testing/web-platform/tests/webrtc-stats/getStats-remote-candidate-address.html @@ -0,0 +1,81 @@ +<!doctype html> +<meta charset=utf-8> +<title>Exposure or remote candidate address on stats</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="../webrtc/RTCStats-helper.js"></script> +<script> + 'use strict'; + +promise_test(async (test) => { + const localPc = new RTCPeerConnection(); + test.add_cleanup(() => localPc.close()); + const remotePc = new RTCPeerConnection(); + test.add_cleanup(() => remotePc.close()); + + const promiseDataChannel = new Promise(resolve => { + remotePc.addEventListener('datachannel', (event) => { + resolve(event.channel); + }); + }); + + const localDataChannel = localPc.createDataChannel('test'); + + localPc.addEventListener('icecandidate', event => { + if (event.candidate) + remotePc.addIceCandidate(event.candidate); + }); + exchangeOfferAnswer(localPc, remotePc); + + const remoteDataChannel = await promiseDataChannel; + + localDataChannel.send("test"); + + await new Promise(resolve => { + remoteDataChannel.onmessage = resolve; + }); + + const remoteCandidateStats = getRequiredStats(await localPc.getStats(), "remote-candidate"); + assert_equals(remoteCandidateStats.address, null, "address should be null"); +}, "Do not expose in stats remote addresses that are not known to be already exposed to JS"); + + +promise_test(async (test) => { + const localPc = new RTCPeerConnection(); + test.add_cleanup(() => localPc.close()); + const remotePc = new RTCPeerConnection(); + test.add_cleanup(() => remotePc.close()); + + const promiseDataChannel = new Promise(resolve => { + remotePc.addEventListener('datachannel', (event) => { + resolve(event.channel); + }); + }); + + const localDataChannel = localPc.createDataChannel('test'); + + localPc.addEventListener('icecandidate', event => { + if (event.candidate) + remotePc.addIceCandidate(event.candidate); + }); + remotePc.addEventListener('icecandidate', event => { + if (event.candidate) + localPc.addIceCandidate(event.candidate); + }); + exchangeOfferAnswer(localPc, remotePc); + + const remoteDataChannel = await promiseDataChannel; + + localDataChannel.send("test"); + + await new Promise(resolve => { + remoteDataChannel.onmessage = resolve; + }); + + const remoteCandidateStats = getRequiredStats(await localPc.getStats(), "remote-candidate"); + assert_not_equals(remoteCandidateStats.address, null, "address should not be null"); + +}, "Expose in stats remote addresses that are already exposed to JS"); + +</script> diff --git a/testing/web-platform/tests/webrtc-stats/hardware-capability-stats.https.html b/testing/web-platform/tests/webrtc-stats/hardware-capability-stats.https.html new file mode 100644 index 0000000000..49f80d4b65 --- /dev/null +++ b/testing/web-platform/tests/webrtc-stats/hardware-capability-stats.https.html @@ -0,0 +1,107 @@ +<!doctype html> +<meta charset=utf-8> +<title>Stats exposing hardware capability</title> +<meta name="timeout" content="long"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="../webrtc/RTCStats-helper.js"></script> +<script> +/* + * Test stats that expose hardware capabilities are only exposed according to + * the conditions described in https://w3c.github.io/webrtc-stats/#limiting-exposure-of-hardware-capabilities. + */ +'use strict'; + +function getStatEntry(report, type, kind) { + const values = [...report.values()]; + const for_kind = values.filter( + stat => stat.type == type && stat.kind == kind); + + assert_equals(1, for_kind.length, + "Expected report to have only 1 entry with type '" + type + + "' and kind '" + kind + "'. Found values " + for_kind); + return for_kind[0]; +} + +async function hasEncodedAndDecodedFrames(pc, t) { + while (true) { + const report = await pc.getStats(); + const inboundRtp = getStatEntry(report, 'inbound-rtp', 'video'); + const outboundRtp = getStatEntry(report, 'outbound-rtp', 'video'); + if (inboundRtp.framesDecoded > 0 && outboundRtp.framesEncoded > 0) { + return; + } + // Avoid any stats caching, which can otherwise make this an infinite loop. + await (new Promise(r => t.step_timeout(r, 100))); + } +} + +async function setupPcAndGetStatEntry( + t, stream, type, kind, stat) { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + for (const track of stream.getTracks()) { + pc1.addTrack(track, stream); + pc2.addTrack(track, stream); + t.add_cleanup(() => track.stop()); + } + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + await hasEncodedAndDecodedFrames(pc1, t); + const report = await pc1.getStats(); + return getStatEntry(report, type, kind); +} + +for (const args of [ + // RTCOutboundRtpStreamStats.powerEfficientEncoder + ['outbound-rtp', 'video', 'powerEfficientEncoder'], + // RTCOutboundRtpStreamStats.encoderImplementation + ['outbound-rtp', 'video', 'encoderImplementation'], + // RTCInboundRtpStreamStats.powerEfficientDecoder + ['inbound-rtp', 'video', 'powerEfficientDecoder'], + // RTCOutboundRtpStreamStats.decoderImplementation + ['inbound-rtp', 'video', 'decoderImplementation'], +]) { + const type = args[0]; + const kind = args[1]; + const stat = args[2]; + + promise_test(async (t) => { + const stream = await getNoiseStream({video: true, audio: true}); + const statsEntry = await setupPcAndGetStatEntry(t, stream, type, kind, stat); + assert_not_own_property(statsEntry, stat); + }, stat + " not exposed when not capturing."); + + // Exposing hardware capabilities when a there is a fullscreen element was + // removed with https://github.com/w3c/webrtc-stats/pull/713. + promise_test(async (t) => { + const stream = await getNoiseStream({video: true, audio: true}); + + const element = document.getElementById('elementToFullscreen'); + await test_driver.bless("fullscreen", () => element.requestFullscreen()); + t.add_cleanup(() => document.exitFullscreen()); + + const statsEntry = await setupPcAndGetStatEntry( + t, stream, type, kind, stat); + assert_not_own_property(statsEntry, stat); + }, stat + " not exposed when fullscreen and not capturing."); + + promise_test(async (t) => { + const stream = await navigator.mediaDevices.getUserMedia( + {video: true, audio: true}); + const statsEntry = await setupPcAndGetStatEntry( + t, stream, type, kind, stat); + assert_own_property(statsEntry, stat); + }, stat + " exposed when capturing."); +} + +</script> +<body> + <div id="elementToFullscreen"></div> +</body> diff --git a/testing/web-platform/tests/webrtc-stats/idlharness.window.js b/testing/web-platform/tests/webrtc-stats/idlharness.window.js new file mode 100644 index 0000000000..d98712fc48 --- /dev/null +++ b/testing/web-platform/tests/webrtc-stats/idlharness.window.js @@ -0,0 +1,14 @@ +// META: script=/resources/WebIDLParser.js +// META: script=/resources/idlharness.js + +'use strict'; + +// https://w3c.github.io/webrtc-stats/ + +idl_test( + ['webrtc-stats'], + ['webrtc'], + idl_array => { + // No interfaces to test + } +); diff --git a/testing/web-platform/tests/webrtc-stats/outbound-rtp.https.html b/testing/web-platform/tests/webrtc-stats/outbound-rtp.https.html new file mode 100644 index 0000000000..ff87d54256 --- /dev/null +++ b/testing/web-platform/tests/webrtc-stats/outbound-rtp.https.html @@ -0,0 +1,49 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection getStats test related to outbound-rtp stats</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script> +function extractOutboundRtpStats(stats) { + const wantedStats = []; + stats.forEach(report => { + if (report.type === 'outbound-rtp') { + wantedStats.push(report); + } + }); + return wantedStats; +} + +promise_test(async (test) => { + const pc1 = new RTCPeerConnection(); + test.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + test.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true, video: true}); + stream.getTracks().forEach(t => pc1.addTrack(t, stream)); + exchangeIceCandidates(pc1, pc2); + exchangeOfferAnswer(pc1, pc2); + const {track} = await new Promise(r => pc2.ontrack = r); + await new Promise(r => track.onunmute = r); + let outboundStats = extractOutboundRtpStats(await pc1.getStats()); + assert_equals(outboundStats.length, 2); + assert_true(outboundStats[0].active); + assert_true(outboundStats[1].active); + + pc1.getSenders().forEach(async sender => { + const parameters = sender.getParameters(); + parameters.encodings[0].active = false; + await sender.setParameters(parameters); + }); + // Avoid any stats caching. + await (new Promise(r => test.step_timeout(r, 100))); + + outboundStats = extractOutboundRtpStats(await pc1.getStats()); + assert_equals(outboundStats.length, 2); + assert_false(outboundStats[0].active); + assert_false(outboundStats[1].active); +}, 'setting an encoding to false is reflected in outbound-rtp stats'); +</script> diff --git a/testing/web-platform/tests/webrtc-stats/rtp-stats-creation.html b/testing/web-platform/tests/webrtc-stats/rtp-stats-creation.html new file mode 100644 index 0000000000..7a6d9df456 --- /dev/null +++ b/testing/web-platform/tests/webrtc-stats/rtp-stats-creation.html @@ -0,0 +1,110 @@ +<!doctype html> +<meta charset=utf-8> +<title>No RTCRtpStreamStats should exist prior to RTP/RTCP packet flow</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +promise_test(async (test) => { + const localPc = createPeerConnectionWithCleanup(test); + const remotePc = createPeerConnectionWithCleanup(test); + + localPc.addTransceiver("audio"); + localPc.addTransceiver("video"); + await exchangeOfferAndListenToOntrack(test, localPc, remotePc); + const report = await remotePc.getStats(); + const rtp = [...report.values()].filter(({type}) => type.endsWith("rtp")); + assert_equals(rtp.length, 0, "no rtp stats with only remote description"); +}, "No RTCRtpStreamStats exist when only remote description is set"); + +promise_test(async (test) => { + const localPc = createPeerConnectionWithCleanup(test); + const remotePc = createPeerConnectionWithCleanup(test); + + localPc.addTrack(...await createTrackAndStreamWithCleanup(test, "audio")); + localPc.addTrack(...await createTrackAndStreamWithCleanup(test, "video")); + await exchangeOfferAndListenToOntrack(test, localPc, remotePc); + const report = await localPc.getStats(); + const rtp = [...report.values()].filter(({type}) => type.endsWith("rtp")); + assert_equals(rtp.length, 0, "no rtp stats with only local description"); +}, "No RTCRtpStreamStats exist when only local description is set"); + +promise_test(async (test) => { + const localPc = createPeerConnectionWithCleanup(test); + const remotePc = createPeerConnectionWithCleanup(test); + + localPc.addTrack(...await createTrackAndStreamWithCleanup(test, "audio")); + localPc.addTrack(...await createTrackAndStreamWithCleanup(test, "video")); + exchangeIceCandidates(localPc, remotePc); + await Promise.all([ + exchangeOfferAnswer(localPc, remotePc), + new Promise(r => remotePc.ontrack = e => e.track.onunmute = r) + ]); + const start = performance.now(); + while (true) { + const report = await localPc.getStats(); + const outbound = + [...report.values()].filter(({type}) => type == "outbound-rtp"); + assert_true(outbound.every(({packetsSent}) => packetsSent > 0), + "no outbound rtp stats before packets sent"); + if (outbound.length == 2) { + // One outbound stat for each track is present. We're done. + break; + } + if (performance.now() > start + 5000) { + assert_unreached("outbound stats should become available"); + } + await new Promise(r => test.step_timeout(r, 100)); + } +}, "No RTCOutboundRtpStreamStats exist until packets have been sent"); + +promise_test(async (test) => { + const localPc = createPeerConnectionWithCleanup(test); + const remotePc = createPeerConnectionWithCleanup(test); + + localPc.addTrack(...await createTrackAndStreamWithCleanup(test, "audio")); + localPc.addTrack(...await createTrackAndStreamWithCleanup(test, "video")); + exchangeIceCandidates(localPc, remotePc); + await exchangeOfferAnswer(localPc, remotePc); + const start = performance.now(); + while (true) { + const report = await remotePc.getStats(); + const inbound = + [...report.values()].filter(({type}) => type == "inbound-rtp"); + assert_true(inbound.every(({packetsReceived}) => packetsReceived > 0), + "no inbound rtp stats before packets received"); + if (inbound.length == 2) { + // One inbound stat for each track is present. We're done. + break; + } + if (performance.now() > start + 5000) { + assert_unreached("inbound stats should become available"); + } + await new Promise(r => test.step_timeout(r, 100)); + } +}, "No RTCInboundRtpStreamStats exist until packets have been received"); + +promise_test(async (test) => { + const localPc = createPeerConnectionWithCleanup(test); + const remotePc = createPeerConnectionWithCleanup(test); + + localPc.addTrack(...await createTrackAndStreamWithCleanup(test, "audio")); + exchangeIceCandidates(localPc, remotePc); + await exchangeOfferAnswer(localPc, remotePc); + const start = performance.now(); + while (true) { + const report = await remotePc.getStats(); + const audioPlayout = + [...report.values()].filter(({type}) => type == "media-playout"); + if (audioPlayout.length == 1) { + break; + } + if (performance.now() > start + 5000) { + assert_unreached("Audio playout stats should become available"); + } + await new Promise(r => test.step_timeout(r, 100)); + } +}, "RTCAudioPlayoutStats should be present"); +</script> diff --git a/testing/web-platform/tests/webrtc-stats/supported-stats.https.html b/testing/web-platform/tests/webrtc-stats/supported-stats.https.html new file mode 100644 index 0000000000..24b4d3f06f --- /dev/null +++ b/testing/web-platform/tests/webrtc-stats/supported-stats.https.html @@ -0,0 +1,212 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>Support for all stats defined in WebRTC Stats</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script src="../webrtc/dictionary-helper.js"></script> +<script src="../webrtc/RTCStats-helper.js"></script> +<script src="/resources/WebIDLParser.js"></script> +<script> +'use strict'; + +// inspired from similar test for MTI stats in ../webrtc/RTCPeerConnection-mandatory-getStats.https.html + + + +// From https://w3c.github.io/webrtc-stats/webrtc-stats.html#rtcstatstype-str* + +const dictionaryNames = { + "codec": "RTCCodecStats", + "inbound-rtp": "RTCInboundRtpStreamStats", + "outbound-rtp": "RTCOutboundRtpStreamStats", + "remote-inbound-rtp": "RTCRemoteInboundRtpStreamStats", + "remote-outbound-rtp": "RTCRemoteOutboundRtpStreamStats", + "csrc": "RTCRtpContributingSourceStats", + "peer-connection": "RTCPeerConnectionStats", + "data-channel": "RTCDataChannelStats", + "media-source": { + audio: "RTCAudioSourceStats", + video: "RTCVideoSourceStats" + }, + "media-playout": "RTCAudioPlayoutStats", + "sender": { + audio: "RTCAudioSenderStats", + video: "RTCVideoSenderStats" + }, + "receiver": { + audio: "RTCAudioReceiverStats", + video: "RTCVideoReceiverStats", + }, + "transport": "RTCTransportStats", + "candidate-pair": "RTCIceCandidatePairStats", + "local-candidate": "RTCIceCandidateStats", + "remote-candidate": "RTCIceCandidateStats", + "certificate": "RTCCertificateStats", +}; + +function isPropertyTestable(type, property) { + // List of properties which are not testable by this test. + // When adding something to this list, please explain why. + const untestablePropertiesByType = { + 'candidate-pair': [ + 'availableIncomingBitrate', // requires REMB, no TWCC. + ], + 'certificate': [ + 'issuerCertificateId', // we only use self-signed certificates. + ], + 'local-candidate': [ + 'url', // requires a STUN/TURN server. + 'relayProtocol', // requires a TURN server. + 'relatedAddress', // requires a STUN/TURN server. + 'relatedPort', // requires a STUN/TURN server. + ], + 'remote-candidate': [ + 'url', // requires a STUN/TURN server. + 'relayProtocol', // requires a TURN server. + 'relatedAddress', // requires a STUN/TURN server. + 'relatedPort', // requires a STUN/TURN server. + 'tcpType', // requires ICE-TCP connection. + ], + 'outbound-rtp': [ + 'rid', // requires simulcast. + ], + 'media-source': [ + 'echoReturnLoss', // requires gUM with an audio input device. + 'echoReturnLossEnhancement', // requires gUM with an audio input device. + ] + }; + if (!untestablePropertiesByType[type]) { + return true; + } + return !untestablePropertiesByType[type].includes(property); +} + +async function getAllStats(t, pc) { + // Try to obtain as many stats as possible, waiting up to 20 seconds for + // roundTripTime which can take several RTCP messages to calculate. + let stats; + for (let i = 0; i < 20; i++) { + stats = await pc.getStats(); + const values = [...stats.values()]; + const [remoteInboundAudio, remoteInboundVideo] = + ["audio", "video"].map(kind => + values.find(s => s.type == "remote-inbound-rtp" && s.kind == kind)); + const [remoteOutboundAudio, remoteOutboundVideo] = + ["audio", "video"].map(kind => + values.find(s => s.type == "remote-outbound-rtp" && s.kind == kind)); + // We expect both audio and video remote-inbound-rtp RTT. + const hasRemoteInbound = + remoteInboundAudio && "roundTripTime" in remoteInboundAudio && + remoteInboundVideo && "roundTripTime" in remoteInboundVideo; + // Due to current implementation limitations, we don't put as hard + // requirements on remote-outbound-rtp as remote-inbound-rtp. It's enough if + // it is available for either kind and `roundTripTime` is not required. In + // Chromium, remote-outbound-rtp is only implemented for audio and + // `roundTripTime` is missing in this test, but awaiting for any + // remote-outbound-rtp avoids flaky failures. + const hasRemoteOutbound = remoteOutboundAudio || remoteOutboundVideo; + const hasMediaPlayout = values.find(({type}) => type == "media-playout") != undefined; + if (hasRemoteInbound && hasRemoteOutbound && hasMediaPlayout) { + return stats; + } + await new Promise(r => t.step_timeout(r, 1000)); + } + return stats; +} + + +promise_test(async t => { + // load the IDL to know which members to be looking for + const idl = await fetch("/interfaces/webrtc-stats.idl").then(r => r.text()); + // for RTCStats definition + const webrtcIdl = await fetch("/interfaces/webrtc.idl").then(r => r.text()); + const astArray = WebIDL2.parse(idl + webrtcIdl); + + let all = {}; + for (let type in dictionaryNames) { + // TODO: make use of audio/video distinction + let dictionaries = dictionaryNames[type].audio ? Object.values(dictionaryNames[type]) : [dictionaryNames[type]]; + all[type] = []; + let i = 0; + // Recursively collect members from inherited dictionaries + while (i < dictionaries.length) { + const dictName = dictionaries[i]; + const dict = astArray.find(i => i.name === dictName && i.type === "dictionary"); + if (dict && dict.members) { + all[type] = all[type].concat(dict.members.map(m => m.name)); + if (dict.inheritance) { + dictionaries.push(dict.inheritance); + } + } + i++; + } + // Unique-ify + all[type] = [...new Set(all[type])]; + } + + const remaining = JSON.parse(JSON.stringify(all)); + for (const type in remaining) { + remaining[type] = new Set(remaining[type]); + } + + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const dc1 = pc1.createDataChannel("dummy", {negotiated: true, id: 0}); + const dc2 = pc2.createDataChannel("dummy", {negotiated: true, id: 0}); + // Use a real gUM to ensure that all stats exposing hardware capabilities are + // also exposed. + const stream = await navigator.mediaDevices.getUserMedia( + {video: true, audio: true}); + for (const track of stream.getTracks()) { + pc1.addTrack(track, stream); + pc2.addTrack(track, stream); + t.add_cleanup(() => track.stop()); + } + + // Do a non-trickle ICE handshake to ensure that TCP candidates are gathered. + await pc1.setLocalDescription(); + await waitForIceGatheringState(pc1, ['complete']); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await waitForIceGatheringState(pc2, ['complete']); + await pc1.setRemoteDescription(pc2.localDescription); + + const stats = await getAllStats(t, pc1); + + // The focus of this test is not API correctness, but rather to provide an + // accessible metric of implementation progress by dictionary member. We count + // whether we've seen each dictionary's members in getStats(). + + test(t => { + for (const stat of stats.values()) { + if (all[stat.type]) { + const memberNames = all[stat.type]; + const remainingNames = remaining[stat.type]; + assert_true(memberNames.length > 0, "Test error. No member found."); + for (const memberName of memberNames) { + if (memberName in stat) { + assert_not_equals(stat[memberName], undefined, "Not undefined"); + remainingNames.delete(memberName); + } + } + } + } + }, "Validating stats"); + + for (const type in all) { + for (const memberName of all[type]) { + test(t => { + assert_implements_optional(isPropertyTestable(type, memberName), + `${type}.${memberName} marked as not testable.`); + assert_true(!remaining[type].has(memberName), + `Is ${memberName} present`); + }, `${type}'s ${memberName}`); + } + } +}, 'getStats succeeds'); +</script> diff --git a/testing/web-platform/tests/webrtc-svc/META.yml b/testing/web-platform/tests/webrtc-svc/META.yml new file mode 100644 index 0000000000..17d93c51a9 --- /dev/null +++ b/testing/web-platform/tests/webrtc-svc/META.yml @@ -0,0 +1 @@ +spec: https://w3c.github.io/webrtc-svc/ diff --git a/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability-av1.html b/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability-av1.html new file mode 100644 index 0000000000..24cfcb8f4a --- /dev/null +++ b/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability-av1.html @@ -0,0 +1,43 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>AV1 scalabilityMode</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/webrtc/RTCRtpParameters-helper.js"></script> +<script src="/webrtc/RTCPeerConnection-helper.js"></script> +<script src="/webrtc-svc/svc-helper.js"></script> +<script> + 'use strict'; + + createScalabilityTest('video/AV1', [ + "L1T1", + "L1T2", + "L1T3", + "L2T1", + "L2T2", + "L2T3", + "L3T1", + "L3T2", + "L3T3", + "L2T1h", + "L2T2h", + "L2T3h", + "S2T1", + "S2T2", + "S2T3", + "S2T1h", + "S2T2h", + "S2T3h", + "S3T1", + "S3T2", + "S3T3", + "S3T1h", + "S3T2h", + "S3T3h", + "L2T2_KEY", + "L2T3_KEY", + "L3T2_KEY", + "L3T3_KEY" + ]); +</script> diff --git a/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability-h264.html b/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability-h264.html new file mode 100644 index 0000000000..2a595a8169 --- /dev/null +++ b/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability-h264.html @@ -0,0 +1,18 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>H264 scalabilityMode</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/webrtc/RTCRtpParameters-helper.js"></script> +<script src="/webrtc/RTCPeerConnection-helper.js"></script> +<script src="/webrtc-svc/svc-helper.js"></script> +<script> + 'use strict'; + + createScalabilityTest('video/H264', [ + "L1T1", + "L1T2", + "L1T3" + ]); +</script> diff --git a/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability-vp8.html b/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability-vp8.html new file mode 100644 index 0000000000..1708ab1017 --- /dev/null +++ b/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability-vp8.html @@ -0,0 +1,18 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>VP8 scalabilityMode</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/webrtc/RTCRtpParameters-helper.js"></script> +<script src="/webrtc/RTCPeerConnection-helper.js"></script> +<script src="/webrtc-svc/svc-helper.js"></script> +<script> + 'use strict'; + + createScalabilityTest('video/VP8', [ + "L1T1", + "L1T2", + "L1T3" + ]); +</script> diff --git a/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability-vp9.html b/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability-vp9.html new file mode 100644 index 0000000000..f1f4923868 --- /dev/null +++ b/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability-vp9.html @@ -0,0 +1,43 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>VP9 scalabilityMode</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/webrtc/RTCRtpParameters-helper.js"></script> +<script src="/webrtc/RTCPeerConnection-helper.js"></script> +<script src="/webrtc-svc/svc-helper.js"></script> +<script> + 'use strict'; + + createScalabilityTest('video/VP9', [ + "L1T1", + "L1T2", + "L1T3", + "L2T1", + "L2T2", + "L2T3", + "L3T1", + "L3T2", + "L3T3", + "L2T1h", + "L2T2h", + "L2T3h", + "S2T1", + "S2T2", + "S2T3", + "S2T1h", + "S2T2h", + "S2T3h", + "S3T1", + "S3T2", + "S3T3", + "S3T1h", + "S3T2h", + "S3T3h", + "L2T2_KEY", + "L2T3_KEY", + "L3T2_KEY", + "L3T3_KEY" + ]); +</script> diff --git a/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability.html b/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability.html new file mode 100644 index 0000000000..ff28c2b5e9 --- /dev/null +++ b/testing/web-platform/tests/webrtc-svc/RTCRtpParameters-scalability.html @@ -0,0 +1,93 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCRtpParameters encodings</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/webrtc/dictionary-helper.js"></script> +<script src="/webrtc/RTCRtpParameters-helper.js"></script> +<script src="../webrtc/RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-svc/ + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video', { + sendEncodings: [{scalabilityMode: 'L1T3'}], + }); + + const param = sender.getParameters(); + const encoding = param.encodings[0]; + + assert_equals(encoding.scalabilityMode, 'L1T3'); + + encoding.scalabilityMode = 'L1T2'; + await sender.setParameters(param); + + const updatedParam = sender.getParameters(); + const updatedEncoding = updatedParam.encodings[0]; + + assert_equals(updatedEncoding.scalabilityMode, 'L1T2'); + }, `Setting and updating scalabilityMode to a legal value should be accepted`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video'); + const param = sender.getParameters(); + const encoding = param.encodings[0]; + assert_true(!('scalabilityMode' in encoding)); + }, 'Not setting sendEncodings results in no mode info before negotiation'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video', { + sendEncodings: [{}], + }); + const param = sender.getParameters(); + const encoding = param.encodings[0]; + assert_true(!('scalabilityMode' in encoding)); + }, 'Not setting a scalability mode results in no mode set before negotiation'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + assert_throws_dom('OperationError', () => { + pc.addTransceiver('video', { + sendEncodings: [{scalabilityMode: 'TotalNonsense'}], + }); + }); + }, 'Setting a scalability mode to nonsense throws an exception'); + + promise_test(async t => { + const v = document.createElement('video'); + v.autoplay = true; + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const transceiver = pc1.addTransceiver('video', { + sendEncodings: [{ scalabilityMode: 'L3T3' }], + }); + // Before negotiation, the mode should be preserved. + const param = transceiver.sender.getParameters(); + const encoding = param.encodings[0]; + assert_true('scalabilityMode' in encoding); + // If L3T3 is not supported at all, abort test. + assert_implements_optional(encoding.scalabilityMode === 'L3T3'); + // Pick a codec known to not have L3T3 support + const capabilities = RTCRtpSender.getCapabilities('video'); + const codec = capabilities.codecs.find(c => c.mimeType === 'video/VP8'); + assert_true(codec !== undefined); + transceiver.setCodecPreferences([codec]); + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + const sendParams = pc1.getSenders()[0].getParameters(); + assert_not_equals(sendParams.encodings[0].scalabilityMode, 'L3T3'); + }, 'L3T3 on VP8 should return something other than L3T3'); +</script> diff --git a/testing/web-platform/tests/webrtc-svc/svc-helper.js b/testing/web-platform/tests/webrtc-svc/svc-helper.js new file mode 100644 index 0000000000..e73ccfa750 --- /dev/null +++ b/testing/web-platform/tests/webrtc-svc/svc-helper.js @@ -0,0 +1,50 @@ +function supportsCodec(mimeType) { + return RTCRtpSender.getCapabilities('video').codecs.filter(c => c.mimeType == mimeType).length() > 0; +} + +async function supportsScalabilityMode(mimeType, scalabilityMode) { + let result = await navigator.mediaCapabilities.encodingInfo({ + type: 'webrtc', + video: { + contentType: mimeType, + width: 60, + height: 60, + bitrate: 10000, + framerate: 30, + scalabilityMode: scalabilityMode + } + }); + return result.supported; +} + +function createScalabilityTest(mimeType, scalabilityModes) { + for (const scalabilityMode of scalabilityModes) { + promise_test(async t => { + assert_implements_optional( + supportsScalabilityMode(mimeType, scalabilityMode), + `${mimeType} supported` + ); + const v = document.createElement('video'); + v.autoplay = true; + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const stream1 = await getNoiseStream({ video: { signal: 100, width: 60, height: 60 } }); + const [track1] = stream1.getTracks(); + t.add_cleanup(() => track1.stop()); + const transceiver = pc1.addTransceiver(track1, { + sendEncodings: [{ scalabilityMode: scalabilityMode }], + }); + transceiver.setCodecPreferences(RTCRtpSender.getCapabilities('video').codecs.filter(c => c.mimeType == mimeType)); + const haveTrackEvent = new Promise(r => pc2.ontrack = r); + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + v.srcObject = new MediaStream([(await haveTrackEvent).track]); + await new Promise(r => v.onloadedmetadata = r); + await detectSignal(t, v, 100); + const sendParams = pc1.getSenders()[0].getParameters(); + assert_equals(sendParams.encodings[0].scalabilityMode, scalabilityMode); + }, `${mimeType} - ${scalabilityMode} should produce valid video content`); + } +} diff --git a/testing/web-platform/tests/webrtc/META.yml b/testing/web-platform/tests/webrtc/META.yml new file mode 100644 index 0000000000..69fcad76f1 --- /dev/null +++ b/testing/web-platform/tests/webrtc/META.yml @@ -0,0 +1,9 @@ +spec: https://w3c.github.io/webrtc-pc/ +suggested_reviewers: + - snuggs + - alvestrand + - guidou + - henbos + - youennf + - rwaldron + - jan-ivar diff --git a/testing/web-platform/tests/webrtc/README.md b/testing/web-platform/tests/webrtc/README.md new file mode 100644 index 0000000000..4477e4f375 --- /dev/null +++ b/testing/web-platform/tests/webrtc/README.md @@ -0,0 +1,12 @@ +# WebRTC + +This directory contains the WebRTC test suite. + +## Acknowledgements + +Some data channel tests are based on the [data channel conformance test +suite][nplab-webrtc-dc-playground] of the Network Programming Lab of the MÞnster +University of Applied Sciences. We would like to thank Peter Titz, Felix Weinrank and Timo +VÃķlker for agreeing to contribute their test cases to this repository. + +[nplab-webrtc-dc-playground]: https://github.com/nplab/WebRTC-Data-Channel-Playground/tree/master/conformance-tests diff --git a/testing/web-platform/tests/webrtc/RTCCertificate-postMessage.html b/testing/web-platform/tests/webrtc/RTCCertificate-postMessage.html new file mode 100644 index 0000000000..6cca240057 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCCertificate-postMessage.html @@ -0,0 +1,78 @@ +<!doctype html> +<meta charset="utf-8"> +<title>RTCCertificate persistent Tests</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/common/get-host-info.sub.js"></script> +<body> +<script> + function findMatchingFingerprint(fingerprints, fingerprint) { + for (let f of fingerprints) { + if (f.value == fingerprint.value && f.algorithm == fingerprint.algorithm) + return true; + } + return false; + } + + function with_iframe(url) { + return new Promise(function(resolve) { + var frame = document.createElement('iframe'); + frame.src = url; + frame.onload = function() { resolve(frame); }; + document.body.appendChild(frame); + }); + } + + function testPostMessageCertificate(isCrossOrigin) { + promise_test(async t => { + let certificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' }); + + let url = "resources/RTCCertificate-postMessage-iframe.html"; + if (isCrossOrigin) + url = get_host_info().HTTP_REMOTE_ORIGIN + "/webrtc/" + url; + + let iframe = await with_iframe(url); + + let promise = new Promise((resolve, reject) => { + window.onmessage = (event) => { + resolve(event.data); + }; + t.step_timeout(() => reject("Timed out waiting for frame to send back certificate"), 5000); + }); + iframe.contentWindow.postMessage(certificate, "*"); + let certificate2 = await promise; + + const pc1 = new RTCPeerConnection({certificates: [certificate]}); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection({certificates: [certificate2]}); + t.add_cleanup(() => pc2.close()); + + assert_equals(certificate.expires, certificate2.expires); + for (let fingerprint of certificate2.getFingerprints()) + assert_true(findMatchingFingerprint(certificate.getFingerprints(), fingerprint), "check fingerprints"); + + iframe.remove(); + }, "Check " + (isCrossOrigin ? "cross-origin" : "same-origin") + " RTCCertificate serialization"); + } + + testPostMessageCertificate(false); + testPostMessageCertificate(true); + + promise_test(async t => { + let url = get_host_info().HTTP_REMOTE_ORIGIN + "/webrtc/resources/RTCCertificate-postMessage-iframe.html"; + let iframe = await with_iframe(url); + + let promise = new Promise((resolve, reject) => { + window.onmessage = (event) => { + resolve(event.data); + }; + t.step_timeout(() => reject("Timed out waiting for frame to send back certificate"), 5000); + }); + iframe.contentWindow.postMessage(null, "*"); + let certificate2 = await promise; + + assert_throws_dom("InvalidAccessError", () => { new RTCPeerConnection({certificates: [certificate2]}) }); + iframe.remove(); + }, "Check cross-origin created RTCCertificate"); +</script> +</body> diff --git a/testing/web-platform/tests/webrtc/RTCCertificate.html b/testing/web-platform/tests/webrtc/RTCCertificate.html new file mode 100644 index 0000000000..6b7626c92e --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCCertificate.html @@ -0,0 +1,283 @@ +<!doctype html> +<meta charset="utf-8"> +<title>RTCCertificate Tests</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> + 'use strict'; + + // Test is based on the Candidate Recommendation: + // https://www.w3.org/TR/webrtc/ + + /* + 4.2.1. RTCConfiguration Dictionary + dictionary RTCConfiguration { + sequence<RTCCertificate> certificates; + ... + }; + + certificates of type sequence<RTCCertificate> + If this value is absent, then a default set of certificates is + generated for each RTCPeerConnection instance. + + The value for this configuration option cannot change after its + value is initially selected. + + 4.10.2. RTCCertificate Interface + interface RTCCertificate { + readonly attribute DOMTimeStamp expires; + static sequence<AlgorithmIdentifier> getSupportedAlgorithms(); + sequence<RTCDtlsFingerprint> getFingerprints(); + }; + + 5.5.1 The RTCDtlsFingerprint Dictionary + dictionary RTCDtlsFingerprint { + DOMString algorithm; + DOMString value; + }; + + [RFC4572] Comedia over TLS in SDP + 5. Fingerprint Attribute + Figure 2. Augmented Backus-Naur Syntax for the Fingerprint Attribute + + attribute =/ fingerprint-attribute + + fingerprint-attribute = "fingerprint" ":" hash-func SP fingerprint + + hash-func = "sha-1" / "sha-224" / "sha-256" / + "sha-384" / "sha-512" / + "md5" / "md2" / token + ; Additional hash functions can only come + ; from updates to RFC 3279 + + fingerprint = 2UHEX *(":" 2UHEX) + ; Each byte in upper-case hex, separated + ; by colons. + + UHEX = DIGIT / %x41-46 ; A-F uppercase + */ + + // Helper function to generate certificate with a set of + // default parameters + function generateCertificate() { + return RTCPeerConnection.generateCertificate({ + name: 'ECDSA', + namedCurve: 'P-256' + }); + } + + // Helper function that takes in an RTCDtlsFingerprint + // and return an a=fingerprint SDP line + function fingerprintToSdpLine(fingerprint) { + return `\r\na=fingerprint:${fingerprint.algorithm} ${fingerprint.value.toUpperCase()}\r\n`; + } + + // Assert that an SDP string has fingerprint line for all the cert's fingerprints + function assert_sdp_has_cert_fingerprints(sdp, cert) { + for(const fingerprint of cert.getFingerprints()) { + const fingerprintLine = fingerprintToSdpLine(fingerprint); + assert_true(sdp.includes(fingerprintLine), + 'Expect fingerprint line to be found in SDP'); + } + } + + /* + 4.3.1. Operation + When the RTCPeerConnection() constructor is invoked + 2. If the certificates value in configuration is non-empty, + check that the expires on each value is in the future. + If a certificate has expired, throw an InvalidAccessError; + otherwise, store the certificates. If no certificates value + was specified, one or more new RTCCertificate instances are + generated for use with this RTCPeerConnection instance. + This may happen asynchronously and the value of certificates + remains undefined for the subsequent steps. + */ + promise_test(t => { + return RTCPeerConnection.generateCertificate({ + name: 'ECDSA', + namedCurve: 'P-256', + expires: 0 + }).then(cert => { + assert_less_than_equal(cert.expires, Date.now()); + assert_throws_dom('InvalidAccessError', () => + new RTCPeerConnection({ certificates: [cert] })); + }); + }, 'Constructing RTCPeerConnection with expired certificate should reject with InvalidAccessError'); + + /* + 4.3.2 Interface Definition + setConfiguration + 4. If configuration.certificates is set and the set of + certificates differs from the ones used when connection + was constructed, throw an InvalidModificationError. + */ + promise_test(t => { + return Promise.all([ + generateCertificate(), + generateCertificate() + ]).then(([cert1, cert2]) => { + const pc = new RTCPeerConnection({ + certificates: [cert1] + }); + + // should not throw + pc.setConfiguration({ + certificates: [cert1] + }); + + assert_throws_dom('InvalidModificationError', () => + pc.setConfiguration({ + certificates: [cert2] + })); + + assert_throws_dom('InvalidModificationError', () => + pc.setConfiguration({ + certificates: [cert1, cert2] + })); + }); + }, 'Calling setConfiguration with different set of certs should reject with InvalidModificationError'); + + /* + 4.10.2. RTCCertificate Interface + getFingerprints + Returns the list of certificate fingerprints, one of which is + computed with the digest algorithm used in the certificate signature. + + 5.5.1 The RTCDtlsFingerprint Dictionary + algorithm of type DOMString + One of the the hash function algorithms defined in the 'Hash function + Textual Names' registry, initially specified in [RFC4572] Section 8. + As noted in [JSEP] Section 5.2.1, the digest algorithm used for the + fingerprint matches that used in the certificate signature. + + value of type DOMString + The value of the certificate fingerprint in lowercase hex string as + expressed utilizing the syntax of 'fingerprint' in [ RFC4572] Section 5. + + */ + promise_test(t => { + return generateCertificate() + .then(cert => { + assert_idl_attribute(cert, 'getFingerprints'); + + const fingerprints = cert.getFingerprints(); + assert_true(Array.isArray(fingerprints), + 'Expect fingerprints to return an array'); + + assert_greater_than_equal(fingerprints.length, 1, + 'Expect at last one fingerprint in array'); + + for(const fingerprint of fingerprints) { + assert_equals(typeof fingerprint, 'object', + 'Expect fingerprint to be an object (dictionary)'); + + // https://www.iana.org/assignments/hash-function-text-names/hash-function-text-names.xml + const algorithms = ['md2', 'md5', 'sha-1', 'sha-224', 'sha-256', 'sha-384', 'sha-512']; + assert_in_array(fingerprint.algorithm, algorithms, + 'Expect fingerprint.algorithm to be string of algorithm identifier'); + + assert_true(/^([0-9a-f]{2}\:)+[0-9a-f]{2}$/.test(fingerprint.value), + 'Expect fingerprint.value to be lowercase hexadecimal separated by colon'); + } + }); + }, 'RTCCertificate should have at least one fingerprint'); + + /* + 4.3.2 Interface Definition + createOffer + The value for certificates in the RTCConfiguration for the + RTCPeerConnection is used to produce a set of certificate + fingerprints. These certificate fingerprints are used in the + construction of SDP and as input to requests for identity + assertions. + + [JSEP] + 5.2.1. Initial Offers + For DTLS, all m= sections MUST use all the certificate(s) that have + been specified for the PeerConnection; as a result, they MUST all + have the same [I-D.ietf-mmusic-4572-update] fingerprint value(s), or + these value(s) MUST be session-level attributes. + + The following attributes, which are of category IDENTICAL or + TRANSPORT, MUST appear only in "m=" sections which either have a + unique address or which are associated with the bundle-tag. (In + initial offers, this means those "m=" sections which do not contain + an "a=bundle-only" attribute.) + + - An "a=fingerprint" line for each of the endpoint's certificates, + as specified in [RFC4572], Section 5; the digest algorithm used + for the fingerprint MUST match that used in the certificate + signature. + + Each m= section which is not bundled into another m= section, MUST + contain the following attributes (which are of category IDENTICAL or + TRANSPORT): + + - An "a=fingerprint" line for each of the endpoint's certificates, + as specified in [RFC4572], Section 5; the digest algorithm used + for the fingerprint MUST match that used in the certificate + signature. + */ + promise_test(t => { + return generateCertificate() + .then(cert => { + const pc = new RTCPeerConnection({ + certificates: [cert] + }); + pc.createDataChannel('test'); + + return pc.createOffer() + .then(offer => { + assert_sdp_has_cert_fingerprints(offer.sdp, cert); + }); + }); + }, 'RTCPeerConnection({ certificates }) should generate offer SDP with fingerprint of provided certificate'); + + promise_test(t => { + return Promise.all([ + generateCertificate(), + generateCertificate() + ]).then(certs => { + const pc = new RTCPeerConnection({ + certificates: certs + }); + pc.createDataChannel('test'); + + return pc.createOffer() + .then(offer => { + for(const cert of certs) { + assert_sdp_has_cert_fingerprints(offer.sdp, cert); + } + }); + }); + }, 'RTCPeerConnection({ certificates }) should generate offer SDP with fingerprint of all provided certificates'); + + /* + TODO + + 4.10.2. RTCCertificate Interface + getSupportedAlgorithms + Returns a sequence providing a representative set of supported + certificate algorithms. At least one algorithm MUST be returned. + + The RTCCertificate object can be stored and retrieved from persistent + storage by an application. When a user agent is required to obtain a + structured clone [HTML5] of a RTCCertificate object, it performs the + following steps: + 1. Let input and memory be the corresponding inputs defined by the + internal structured cloning algorithm, where input represents a + RTCCertificate object to be cloned. + 2. Let output be a newly constructed RTCCertificate object. + 3. Copy the value of the expires attribute from input to output. + 4. Let the [[certificate]] internal slot of output be set to the + result of invoking the internal structured clone algorithm + recursively on the corresponding internal slots of input, with + the slot contents as the new " input" argument and memory as + the new " memory" argument. + 5. Let the [[handle]] internal slot of output refer to the same + private keying material represented by the [[handle]] internal + slot of input. + */ + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCConfiguration-bundlePolicy.html b/testing/web-platform/tests/webrtc/RTCConfiguration-bundlePolicy.html new file mode 100644 index 0000000000..e825d7b402 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCConfiguration-bundlePolicy.html @@ -0,0 +1,128 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCConfiguration bundlePolicy</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + /* + 4.3.2. Interface Definition + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + ... + RTCConfiguration getConfiguration(); + void setConfiguration(RTCConfiguration configuration); + }; + + 4.2.1. RTCConfiguration Dictionary + dictionary RTCConfiguration { + RTCBundlePolicy bundlePolicy = "balanced"; + ... + }; + + 4.2.6. RTCBundlePolicy Enum + enum RTCBundlePolicy { + "balanced", + "max-compat", + "max-bundle" + }; + */ + + test(() => { + const pc = new RTCPeerConnection(); + assert_equals(pc.getConfiguration().bundlePolicy, 'balanced'); + }, 'Default bundlePolicy should be balanced'); + + test(() => { + const pc = new RTCPeerConnection({ bundlePolicy: undefined }); + assert_equals(pc.getConfiguration().bundlePolicy, 'balanced'); + }, `new RTCPeerConnection({ bundlePolicy: undefined }) should have bundlePolicy balanced`); + + test(() => { + const pc = new RTCPeerConnection({ bundlePolicy: 'balanced' }); + assert_equals(pc.getConfiguration().bundlePolicy, 'balanced'); + }, `new RTCPeerConnection({ bundlePolicy: 'balanced' }) should succeed`); + + test(() => { + const pc = new RTCPeerConnection({ bundlePolicy: 'max-compat' }); + assert_equals(pc.getConfiguration().bundlePolicy, 'max-compat'); + }, `new RTCPeerConnection({ bundlePolicy: 'max-compat' }) should succeed`); + + test(() => { + const pc = new RTCPeerConnection({ bundlePolicy: 'max-bundle' }); + assert_equals(pc.getConfiguration().bundlePolicy, 'max-bundle'); + }, `new RTCPeerConnection({ bundlePolicy: 'max-bundle' }) should succeed`); + + test(() => { + const pc = new RTCPeerConnection(); + pc.setConfiguration({}); + }, 'setConfiguration({}) with initial default bundlePolicy balanced should succeed'); + + test(() => { + const pc = new RTCPeerConnection({ bundlePolicy: 'balanced' }); + pc.setConfiguration({}); + }, 'setConfiguration({}) with initial bundlePolicy balanced should succeed'); + + test(() => { + const pc = new RTCPeerConnection(); + pc.setConfiguration({ bundlePolicy: 'balanced' }); + }, 'setConfiguration({ bundlePolicy: balanced }) with initial default bundlePolicy balanced should succeed'); + + test(() => { + const pc = new RTCPeerConnection({ bundlePolicy: 'balanced' }); + pc.setConfiguration({ bundlePolicy: 'balanced' }); + }, `setConfiguration({ bundlePolicy: 'balanced' }) with initial bundlePolicy balanced should succeed`); + + test(() => { + const pc = new RTCPeerConnection({ bundlePolicy: 'max-compat' }); + pc.setConfiguration({ bundlePolicy: 'max-compat' }); + }, `setConfiguration({ bundlePolicy: 'max-compat' }) with initial bundlePolicy max-compat should succeed`); + + test(() => { + const pc = new RTCPeerConnection({ bundlePolicy: 'max-bundle' }); + pc.setConfiguration({ bundlePolicy: 'max-bundle' }); + }, `setConfiguration({ bundlePolicy: 'max-bundle' }) with initial bundlePolicy max-bundle should succeed`); + + test(() => { + assert_throws_js(TypeError, () => + new RTCPeerConnection({ bundlePolicy: null })); + }, `new RTCPeerConnection({ bundlePolicy: null }) should throw TypeError`); + + test(() => { + assert_throws_js(TypeError, () => + new RTCPeerConnection({ bundlePolicy: 'invalid' })); + }, `new RTCPeerConnection({ bundlePolicy: 'invalid' }) should throw TypeError`); + + /* + 4.3.2. Interface Definition + To set a configuration + 5. If configuration.bundlePolicy is set and its value differs from the + connection's bundle policy, throw an InvalidModificationError. + */ + test(() => { + const pc = new RTCPeerConnection({ bundlePolicy: 'max-bundle' }); + assert_idl_attribute(pc, 'setConfiguration'); + + assert_throws_dom('InvalidModificationError', () => + pc.setConfiguration({ bundlePolicy: 'max-compat' })); + }, `setConfiguration({ bundlePolicy: 'max-compat' }) with initial bundlePolicy max-bundle should throw InvalidModificationError`); + + test(() => { + const pc = new RTCPeerConnection({ bundlePolicy: 'max-bundle' }); + assert_idl_attribute(pc, 'setConfiguration'); + + // the default value for bundlePolicy is balanced + assert_throws_dom('InvalidModificationError', () => + pc.setConfiguration({})); + }, `setConfiguration({}) with initial bundlePolicy max-bundle should throw InvalidModificationError`); + + /* + Coverage Report + Tested 2 + Total 2 + */ +</script> diff --git a/testing/web-platform/tests/webrtc/RTCConfiguration-helper.js b/testing/web-platform/tests/webrtc/RTCConfiguration-helper.js new file mode 100644 index 0000000000..fb8eb50995 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCConfiguration-helper.js @@ -0,0 +1,24 @@ +'use strict'; + +// Run a test function as two test cases. +// The first test case test the configuration by passing a given config +// to the constructor. +// The second test case create an RTCPeerConnection object with default +// configuration, then call setConfiguration with the provided config. +// The test function is given a constructor function to create +// a new instance of RTCPeerConnection with given config, +// either directly as constructor parameter or through setConfiguration. +function config_test(test_func, desc) { + test(() => { + test_func(config => new RTCPeerConnection(config)); + }, `new RTCPeerConnection(config) - ${desc}`); + + test(() => { + test_func(config => { + const pc = new RTCPeerConnection(); + assert_idl_attribute(pc, 'setConfiguration'); + pc.setConfiguration(config); + return pc; + }) + }, `setConfiguration(config) - ${desc}`); +} diff --git a/testing/web-platform/tests/webrtc/RTCConfiguration-iceCandidatePoolSize.html b/testing/web-platform/tests/webrtc/RTCConfiguration-iceCandidatePoolSize.html new file mode 100644 index 0000000000..495b043e12 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCConfiguration-iceCandidatePoolSize.html @@ -0,0 +1,117 @@ +<!doctype html> +<meta charset="utf-8"> +<!-- +4.2.1 RTCConfiguration Dictionary + + The RTCConfiguration defines a set of parameters to configure how the peer to peer communication established via RTCPeerConnection is established or re-established. + + ... + + iceCandidatePoolSize of type octet, defaulting to 0 + Size of the prefetched ICE pool as defined in [JSEP] (section 3.5.4. and section 4.1.1.). +--> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> + +/* + +dictionary RTCConfiguration { + ... + [EnforceRange] + octet iceCandidatePoolSize = 0; +}; + +... of type octet +*/ +test(() => { + const pc = new RTCPeerConnection(); + assert_idl_attribute(pc, "getConfiguration"); + assert_equals(pc.getConfiguration().iceCandidatePoolSize, 0); +}, "Initialize a new RTCPeerConnection with no iceCandidatePoolSize"); + +test(() => { + const pc = new RTCPeerConnection({ + iceCandidatePoolSize: 0 + }); + assert_idl_attribute(pc, "getConfiguration"); + assert_equals(pc.getConfiguration().iceCandidatePoolSize, 0); +}, "Initialize a new RTCPeerConnection with iceCandidatePoolSize: 0"); + +test(() => { + const pc = new RTCPeerConnection({ + iceCandidatePoolSize: 255 + }); + assert_idl_attribute(pc, "getConfiguration"); + assert_equals(pc.getConfiguration().iceCandidatePoolSize, 255); +}, "Initialize a new RTCPeerConnection with iceCandidatePoolSize: 255"); + +test(() => { + assert_throws_js(TypeError, () => { + new RTCPeerConnection({ + iceCandidatePoolSize: -1 + }); + }); +}, "Initialize a new RTCPeerConnection with iceCandidatePoolSize: -1 (Out Of Range)"); + +test(() => { + assert_throws_js(TypeError, () => { + new RTCPeerConnection({ + iceCandidatePoolSize: 256 + }); + }); +}, "Initialize a new RTCPeerConnection with iceCandidatePoolSize: 256 (Out Of Range)"); + + +/* +Reconfiguration +*/ + +test(() => { + const pc = new RTCPeerConnection(); + assert_idl_attribute(pc, "getConfiguration"); + assert_idl_attribute(pc, "setConfiguration"); + pc.setConfiguration({ + iceCandidatePoolSize: 0 + }); + assert_equals(pc.getConfiguration().iceCandidatePoolSize, 0); +}, "Reconfigure RTCPeerConnection instance iceCandidatePoolSize to 0"); + +test(() => { + const pc = new RTCPeerConnection(); + assert_idl_attribute(pc, "getConfiguration"); + assert_idl_attribute(pc, "setConfiguration"); + pc.setConfiguration({ + iceCandidatePoolSize: 255 + }); + assert_equals(pc.getConfiguration().iceCandidatePoolSize, 255); +}, "Reconfigure RTCPeerConnection instance iceCandidatePoolSize to 255"); + +/* +The following tests include an explicit assertion for the existence of a +setConfiguration function to prevent the assert_throws_js from catching the +TypeError object that will be thrown when attempting to call the +non-existent setConfiguration method (in cases where it has not yet +been implemented). Without this check, these tests will pass incorrectly. +*/ + +test(() => { + const pc = new RTCPeerConnection(); + assert_equals(typeof pc.setConfiguration, "function", "RTCPeerConnection.prototype.setConfiguration is not implemented"); + assert_throws_js(TypeError, () => { + pc.setConfiguration({ + iceCandidatePoolSize: -1 + }); + }); +}, "Reconfigure RTCPeerConnection instance iceCandidatePoolSize to -1 (Out Of Range)"); + +test(() => { + const pc = new RTCPeerConnection(); + assert_equals(typeof pc.setConfiguration, "function", "RTCPeerConnection.prototype.setConfiguration is not implemented"); + assert_throws_js(TypeError, () => { + pc.setConfiguration({ + iceCandidatePoolSize: 256 + }); + }); +}, "Reconfigure RTCPeerConnection instance iceCandidatePoolSize to 256 (Out Of Range)"); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCConfiguration-iceServers.html b/testing/web-platform/tests/webrtc/RTCConfiguration-iceServers.html new file mode 100644 index 0000000000..1893ba02f3 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCConfiguration-iceServers.html @@ -0,0 +1,330 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCConfiguration iceServers</title> +<script src='/resources/testharness.js'></script> +<script src='/resources/testharnessreport.js'></script> +<script src='RTCConfiguration-helper.js'></script> +<script> + 'use strict'; + + // Test is based on the following editor's draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper function is called from + // RTCConfiguration-helper.js: + // config_test + + /* + 4.3.2. Interface Definition + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + ... + }; + + 4.2.1. RTCConfiguration Dictionary + dictionary RTCConfiguration { + sequence<RTCIceServer> iceServers = []; + ... + }; + + 4.2.4. RTCIceServer Dictionary + dictionary RTCIceServer { + required (DOMString or sequence<DOMString>) urls; + DOMString username; + DOMString credential; + }; + */ + + test(() => { + const pc = new RTCPeerConnection(); + assert_array_equals(pc.getConfiguration().iceServers, []); + }, 'new RTCPeerConnection() should have default configuration.iceServers of undefined'); + + config_test(makePc => { + makePc({}); + }, '{} should succeed'); + + config_test(makePc => { + assert_throws_js(TypeError, () => + makePc({ iceServers: null })); + }, '{ iceServers: null } should throw TypeError'); + + config_test(makePc => { + const pc = makePc({ iceServers: undefined }); + assert_array_equals(pc.getConfiguration().iceServers, []); + }, '{ iceServers: undefined } should succeed'); + + config_test(makePc => { + const pc = makePc({ iceServers: [] }); + assert_array_equals(pc.getConfiguration().iceServers, []); + }, '{ iceServers: [] } should succeed'); + + config_test(makePc => { + assert_throws_js(TypeError, () => + makePc({ iceServers: [null] })); + }, '{ iceServers: [null] } should throw TypeError'); + + config_test(makePc => { + assert_throws_js(TypeError, () => + makePc({ iceServers: [undefined] })); + }, '{ iceServers: [undefined] } should throw TypeError'); + + config_test(makePc => { + assert_throws_js(TypeError, () => + makePc({ iceServers: [{}] })); + }, '{ iceServers: [{}] } should throw TypeError'); + + config_test(makePc => { + const pc = makePc({ iceServers: [{ + urls: 'stun:stun1.example.net' + }] }); + + const { iceServers } = pc.getConfiguration(); + assert_equals(iceServers.length, 1); + + const server = iceServers[0]; + assert_array_equals(server.urls, ['stun:stun1.example.net']); + + }, `with stun server should succeed`); + + config_test(makePc => { + const pc = makePc({ iceServers: [{ + urls: ['stun:stun1.example.net'] + }] }); + + const { iceServers } = pc.getConfiguration(); + assert_equals(iceServers.length, 1); + + const server = iceServers[0]; + assert_array_equals(server.urls, ['stun:stun1.example.net']); + + }, `with stun server array should succeed`); + + config_test(makePc => { + const pc = makePc({ iceServers: [{ + urls: ['stun:stun1.example.net', 'stun:stun2.example.net'] + }] }); + + const { iceServers } = pc.getConfiguration(); + assert_equals(iceServers.length, 1); + + const server = iceServers[0]; + assert_array_equals(server.urls, ['stun:stun1.example.net', 'stun:stun2.example.net']); + + }, `with 2 stun servers should succeed`); + + config_test(makePc => { + const pc = makePc({ iceServers: [{ + urls: 'turn:turn.example.org', + username: 'user', + credential: 'cred' + }] }); + + const { iceServers } = pc.getConfiguration(); + assert_equals(iceServers.length, 1); + + const server = iceServers[0]; + assert_array_equals(server.urls, ['turn:turn.example.org']); + assert_equals(server.username, 'user'); + assert_equals(server.credential, 'cred'); + + }, `with turn server, username, credential should succeed`); + + config_test(makePc => { + const pc = makePc({ iceServers: [{ + urls: 'turns:turn.example.org', + username: '', + credential: '' + }] }); + + const { iceServers } = pc.getConfiguration(); + assert_equals(iceServers.length, 1); + + const server = iceServers[0]; + assert_array_equals(server.urls, ['turns:turn.example.org']); + assert_equals(server.username, ''); + assert_equals(server.credential, ''); + + }, `with turns server and empty string username, credential should succeed`); + + config_test(makePc => { + const pc = makePc({ iceServers: [{ + urls: 'turn:turn.example.org', + username: '', + credential: '' + }] }); + + const { iceServers } = pc.getConfiguration(); + assert_equals(iceServers.length, 1); + + const server = iceServers[0]; + assert_array_equals(server.urls, ['turn:turn.example.org']); + assert_equals(server.username, ''); + assert_equals(server.credential, ''); + + }, `with turn server and empty string username, credential should succeed`); + + config_test(makePc => { + const pc = makePc({ iceServers: [{ + urls: ['turns:turn.example.org', 'turn:turn.example.net'], + username: 'user', + credential: 'cred' + }] }); + + const { iceServers } = pc.getConfiguration(); + assert_equals(iceServers.length, 1); + + const server = iceServers[0]; + assert_array_equals(server.urls, ['turns:turn.example.org', 'turn:turn.example.net']); + assert_equals(server.username, 'user'); + assert_equals(server.credential, 'cred'); + + }, `with one turns server, one turn server, username, credential should succeed`); + + /* + 4.3.2. To set a configuration + 11.4. If scheme name is turn or turns, and either of server.username or + server.credential are omitted, then throw an InvalidAccessError. + */ + config_test(makePc => { + assert_throws_dom('InvalidAccessError', () => + makePc({ iceServers: [{ + urls: 'turn:turn.example.net' + }] })); + }, 'with turn server and no credentials should throw InvalidAccessError'); + + config_test(makePc => { + assert_throws_dom('InvalidAccessError', () => + makePc({ iceServers: [{ + urls: 'turn:turn.example.net', + username: 'user' + }] })); + }, 'with turn server and only username should throw InvalidAccessError'); + + config_test(makePc => { + assert_throws_dom('InvalidAccessError', () => + makePc({ iceServers: [{ + urls: 'turn:turn.example.net', + credential: 'cred' + }] })); + }, 'with turn server and only credential should throw InvalidAccessError'); + + config_test(makePc => { + assert_throws_dom('InvalidAccessError', () => + makePc({ iceServers: [{ + urls: 'turns:turn.example.net' + }] })); + }, 'with turns server and no credentials should throw InvalidAccessError'); + + config_test(makePc => { + assert_throws_dom('InvalidAccessError', () => + makePc({ iceServers: [{ + urls: 'turns:turn.example.net', + username: 'user' + }] })); + }, 'with turns server and only username should throw InvalidAccessError'); + + config_test(makePc => { + assert_throws_dom('InvalidAccessError', () => + makePc({ iceServers: [{ + urls: 'turns:turn.example.net', + credential: 'cred' + }] })); + }, 'with turns server and only credential should throw InvalidAccessError'); + + /* + 4.3.2. To set a configuration + 11.3. For each url in server.urls parse url and obtain scheme name. + - If the scheme name is not implemented by the browser, throw a SyntaxError. + - or if parsing based on the syntax defined in [ RFC7064] and [RFC7065] fails, + throw a SyntaxError. + + [RFC7064] URI Scheme for the Session Traversal Utilities for NAT (STUN) Protocol + 3.1. URI Scheme Syntax + stunURI = scheme ":" host [ ":" port ] + scheme = "stun" / "stuns" + + [RFC7065] Traversal Using Relays around NAT (TURN) Uniform Resource Identifiers + 3.1. URI Scheme Syntax + turnURI = scheme ":" host [ ":" port ] + [ "?transport=" transport ] + scheme = "turn" / "turns" + transport = "udp" / "tcp" / transport-ext + transport-ext = 1*unreserved + */ + config_test(makePc => { + assert_throws_dom("SyntaxError", () => + makePc({ iceServers: [{ + urls: '' + }] })); + }, 'with "" url should throw SyntaxError'); + + config_test(makePc => { + assert_throws_dom("SyntaxError", () => + makePc({ iceServers: [{ + urls: ['stun:stun1.example.net', ''] + }] })); + }, 'with ["stun:stun1.example.net", ""] url should throw SyntaxError'); + + config_test(makePc => { + assert_throws_dom("SyntaxError", () => + makePc({ iceServers: [{ + urls: 'relative-url' + }] })); + }, 'with relative url should throw SyntaxError'); + + config_test(makePc => { + assert_throws_dom("SyntaxError", () => + makePc({ iceServers: [{ + urls: 'http://example.com' + }] })); + }, 'with http url should throw SyntaxError'); + + config_test(makePc => { + assert_throws_dom("SyntaxError", () => + makePc({ iceServers: [{ + urls: 'turn://example.org/foo?x=y' + }] })); + }, 'with invalid turn url should throw SyntaxError'); + + config_test(makePc => { + assert_throws_dom("SyntaxError", () => + makePc({ iceServers: [{ + urls: 'stun://example.org/foo?x=y' + }] })); + }, 'with invalid stun url should throw SyntaxError'); + + config_test(makePc => { + assert_throws_dom("SyntaxError", () => + makePc({ iceServers: [{ + urls: [] + }] })); + }, `with empty urls should throw SyntaxError`); + + // Blink and Gecko fall back to url, but it's not in the spec. + config_test(makePc => { + assert_throws_js(TypeError, () => + makePc({ iceServers: [{ + url: 'stun:stun1.example.net' + }] })); + }, 'with url field should throw TypeError'); + + /* + 4.3.2. To set a configuration + 11.5. If scheme name is turn or turns, + and server.credential is not a DOMString, then throw an InvalidAccessError + and abort these steps. + */ + config_test(makePc => { + assert_throws_dom('InvalidAccessError', () => + makePc({ iceServers: [{ + urls: 'turns:turn.example.org', + username: 'user', + credential: { + macKey: '', + accessToken: '' + } + }] })); + }, 'with turns server, and object credential should throw InvalidAccessError'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCConfiguration-iceTransportPolicy.html b/testing/web-platform/tests/webrtc/RTCConfiguration-iceTransportPolicy.html new file mode 100644 index 0000000000..ebc79048a3 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCConfiguration-iceTransportPolicy.html @@ -0,0 +1,306 @@ +<!doctype html> +<meta name="timeout" content="long"> +<title>RTCConfiguration iceTransportPolicy</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCConfiguration-helper.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper function is called from RTCConfiguration-helper.js: + // config_test + + /* + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + RTCConfiguration getConfiguration(); + void setConfiguration(RTCConfiguration configuration); + ... + }; + + dictionary RTCConfiguration { + sequence<RTCIceServer> iceServers; + RTCIceTransportPolicy iceTransportPolicy = "all"; + }; + + enum RTCIceTransportPolicy { + "relay", + "all" + }; + */ + + test(() => { + const pc = new RTCPeerConnection(); + assert_equals(pc.getConfiguration().iceTransportPolicy, 'all'); + }, `new RTCPeerConnection() should have default iceTransportPolicy all`); + + test(() => { + const pc = new RTCPeerConnection({ iceTransportPolicy: undefined }); + assert_equals(pc.getConfiguration().iceTransportPolicy, 'all'); + }, `new RTCPeerConnection({ iceTransportPolicy: undefined }) should have default iceTransportPolicy all`); + + test(() => { + const pc = new RTCPeerConnection({ iceTransportPolicy: 'all' }); + assert_equals(pc.getConfiguration().iceTransportPolicy, 'all'); + }, `new RTCPeerConnection({ iceTransportPolicy: 'all' }) should succeed`); + + test(() => { + const pc = new RTCPeerConnection({ iceTransportPolicy: 'relay' }); + assert_equals(pc.getConfiguration().iceTransportPolicy, 'relay'); + }, `new RTCPeerConnection({ iceTransportPolicy: 'relay' }) should succeed`); + + /* + 4.3.2. Set a configuration + 8. Set the ICE Agent's ICE transports setting to the value of + configuration.iceTransportPolicy. As defined in [JSEP] (section 4.1.16.), + if the new ICE transports setting changes the existing setting, no action + will be taken until the next gathering phase. If a script wants this to + happen immediately, it should do an ICE restart. + */ + test(() => { + const pc = new RTCPeerConnection({ iceTransportPolicy: 'all' }); + assert_equals(pc.getConfiguration().iceTransportPolicy, 'all'); + + pc.setConfiguration({ iceTransportPolicy: 'relay' }); + assert_equals(pc.getConfiguration().iceTransportPolicy, 'relay'); + }, `setConfiguration({ iceTransportPolicy: 'relay' }) with initial iceTransportPolicy all should succeed`); + + test(() => { + const pc = new RTCPeerConnection({ iceTransportPolicy: 'relay' }); + assert_equals(pc.getConfiguration().iceTransportPolicy, 'relay'); + + pc.setConfiguration({ iceTransportPolicy: 'all' }); + assert_equals(pc.getConfiguration().iceTransportPolicy, 'all'); + }, `setConfiguration({ iceTransportPolicy: 'all' }) with initial iceTransportPolicy relay should succeed`); + + test(() => { + const pc = new RTCPeerConnection({ iceTransportPolicy: 'relay' }); + assert_equals(pc.getConfiguration().iceTransportPolicy, 'relay'); + + // default value for iceTransportPolicy is all + pc.setConfiguration({}); + assert_equals(pc.getConfiguration().iceTransportPolicy, 'all'); + }, `setConfiguration({}) with initial iceTransportPolicy relay should set new value to all`); + + config_test(makePc => { + assert_throws_js(TypeError, () => + makePc({ iceTransportPolicy: 'invalid' })); + }, `with invalid iceTransportPolicy should throw TypeError`); + + // "none" is in Blink and Gecko's IDL, but not in the spec. + config_test(makePc => { + assert_throws_js(TypeError, () => + makePc({ iceTransportPolicy: 'none' })); + }, `with none iceTransportPolicy should throw TypeError`); + + config_test(makePc => { + assert_throws_js(TypeError, () => + makePc({ iceTransportPolicy: null })); + }, `with null iceTransportPolicy should throw TypeError`); + + // iceTransportPolicy is called iceTransports in Blink. + test(() => { + const pc = new RTCPeerConnection({ iceTransports: 'relay' }); + assert_equals(pc.getConfiguration().iceTransportPolicy, 'all'); + }, `new RTCPeerConnection({ iceTransports: 'relay' }) should have no effect`); + + test(() => { + const pc = new RTCPeerConnection({ iceTransports: 'invalid' }); + assert_equals(pc.getConfiguration().iceTransportPolicy, 'all'); + }, `new RTCPeerConnection({ iceTransports: 'invalid' }) should have no effect`); + + test(() => { + const pc = new RTCPeerConnection({ iceTransports: null }); + assert_equals(pc.getConfiguration().iceTransportPolicy, 'all'); + }, `new RTCPeerConnection({ iceTransports: null }) should have no effect`); + + const getLines = (sdp, startsWith) => + sdp.split('\r\n').filter(l => l.startsWith(startsWith)); + + const getUfrags = ({sdp}) => getLines(sdp, 'a=ice-ufrag:'); + + promise_test(async t => { + const offerer = new RTCPeerConnection({iceTransportPolicy: 'relay'}); + t.add_cleanup(() => offerer.close()); + + offerer.addEventListener('icecandidate', + e => assert_equals(e.candidate, null, 'Should get no ICE candidates')); + + offerer.addTransceiver('audio'); + await offerer.setLocalDescription(); + + await waitForIceGatheringState(offerer, ['complete']); + }, `iceTransportPolicy "relay" on offerer should prevent candidate gathering`); + + promise_test(async t => { + const offerer = new RTCPeerConnection(); + const answerer = new RTCPeerConnection({iceTransportPolicy: 'relay'}); + t.add_cleanup(() => offerer.close()); + t.add_cleanup(() => answerer.close()); + + answerer.addEventListener('icecandidate', + e => assert_equals(e.candidate, null, 'Should get no ICE candidates')); + + offerer.addTransceiver('audio'); + const offer = await offerer.createOffer(); + await answerer.setRemoteDescription(offer); + await answerer.setLocalDescription(await answerer.createAnswer()); + await waitForIceGatheringState(answerer, ['complete']); + }, `iceTransportPolicy "relay" on answerer should prevent candidate gathering`); + + promise_test(async t => { + const offerer = new RTCPeerConnection(); + const answerer = new RTCPeerConnection(); + t.add_cleanup(() => offerer.close()); + t.add_cleanup(() => answerer.close()); + + offerer.addTransceiver('audio'); + + exchangeIceCandidates(offerer, answerer); + + await Promise.all([ + exchangeOfferAnswer(offerer, answerer), + listenToIceConnected(offerer), + listenToIceConnected(answerer), + waitForIceGatheringState(offerer, ['complete']), + waitForIceGatheringState(answerer, ['complete']) + ]); + + const [oldUfrag] = getUfrags(offerer.localDescription); + + offerer.setConfiguration({iceTransportPolicy: 'relay'}); + + offerer.addEventListener('icecandidate', + e => assert_equals(e.candidate, null, 'Should get no ICE candidates')); + + await Promise.all([ + exchangeOfferAnswer(offerer, answerer), + waitForIceStateChange(offerer, ['failed']), + waitForIceStateChange(answerer, ['failed']), + waitForIceGatheringState(offerer, ['complete']), + waitForIceGatheringState(answerer, ['complete']) + ]); + + const [newUfrag] = getUfrags(offerer.localDescription); + assert_not_equals(oldUfrag, newUfrag, + 'Changing iceTransportPolicy should prompt an ICE restart'); + }, `Changing iceTransportPolicy from "all" to "relay" causes an ICE restart which should fail, with no new candidates`); + + promise_test(async t => { + const offerer = new RTCPeerConnection({iceTransportPolicy: 'relay'}); + const answerer = new RTCPeerConnection(); + t.add_cleanup(() => offerer.close()); + t.add_cleanup(() => answerer.close()); + + offerer.addTransceiver('audio'); + + exchangeIceCandidates(offerer, answerer); + + const checkNoCandidate = + e => assert_equals(e.candidate, null, 'Should get no ICE candidates'); + + offerer.addEventListener('icecandidate', checkNoCandidate); + + await Promise.all([ + exchangeOfferAnswer(offerer, answerer), + waitForIceStateChange(offerer, ['failed']), + waitForIceStateChange(answerer, ['failed']), + waitForIceGatheringState(offerer, ['complete']), + waitForIceGatheringState(answerer, ['complete']) + ]); + + const [oldUfrag] = getUfrags(offerer.localDescription); + + offerer.setConfiguration({iceTransportPolicy: 'all'}); + + offerer.removeEventListener('icecandidate', checkNoCandidate); + + await Promise.all([ + exchangeOfferAnswer(offerer, answerer), + listenToIceConnected(offerer), + listenToIceConnected(answerer), + waitForIceGatheringState(offerer, ['complete']), + waitForIceGatheringState(answerer, ['complete']) + ]); + + const [newUfrag] = getUfrags(offerer.localDescription); + assert_not_equals(oldUfrag, newUfrag, + 'Changing iceTransportPolicy should prompt an ICE restart'); + }, `Changing iceTransportPolicy from "relay" to "all" causes an ICE restart which should succeed`); + + promise_test(async t => { + const offerer = new RTCPeerConnection(); + const answerer = new RTCPeerConnection(); + t.add_cleanup(() => offerer.close()); + t.add_cleanup(() => answerer.close()); + + offerer.addTransceiver('audio'); + + exchangeIceCandidates(offerer, answerer); + + await Promise.all([ + exchangeOfferAnswer(offerer, answerer), + listenToIceConnected(offerer), + listenToIceConnected(answerer), + waitForIceGatheringState(offerer, ['complete']), + waitForIceGatheringState(answerer, ['complete']) + ]); + + const [oldUfrag] = getUfrags(offerer.localDescription); + + offerer.setConfiguration({iceTransportPolicy: 'relay'}); + offerer.setConfiguration({iceTransportPolicy: 'all'}); + + await Promise.all([ + exchangeOfferAnswer(offerer, answerer), + listenToIceConnected(offerer), + listenToIceConnected(answerer), + waitForIceGatheringState(offerer, ['complete']), + waitForIceGatheringState(answerer, ['complete']) + ]); + + const [newUfrag] = getUfrags(offerer.localDescription); + assert_not_equals(oldUfrag, newUfrag, + 'Changing iceTransportPolicy should prompt an ICE restart'); + }, `Changing iceTransportPolicy from "all" to "relay", and back to "all" prompts an ICE restart`); + + promise_test(async t => { + const offerer = new RTCPeerConnection(); + const answerer = new RTCPeerConnection(); + t.add_cleanup(() => offerer.close()); + t.add_cleanup(() => answerer.close()); + + offerer.addTransceiver('audio'); + + exchangeIceCandidates(offerer, answerer); + + await Promise.all([ + exchangeOfferAnswer(offerer, answerer), + listenToIceConnected(offerer), + listenToIceConnected(answerer), + waitForIceGatheringState(offerer, ['complete']), + waitForIceGatheringState(answerer, ['complete']) + ]); + + const [oldUfrag] = getUfrags(answerer.localDescription); + + answerer.setConfiguration({iceTransportPolicy: 'relay'}); + + await Promise.all([ + exchangeOfferAnswer(offerer, answerer), + listenToIceConnected(offerer), + listenToIceConnected(answerer), + waitForIceGatheringState(offerer, ['complete']), + waitForIceGatheringState(answerer, ['complete']) + ]); + + const [newUfrag] = getUfrags(answerer.localDescription); + assert_equals(oldUfrag, newUfrag, + 'Changing iceTransportPolicy on answerer should not effect ufrag'); + }, `Changing iceTransportPolicy from "all" to "relay" on the answerer has no effect on a subsequent offer/answer`); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCConfiguration-rtcpMuxPolicy.html b/testing/web-platform/tests/webrtc/RTCConfiguration-rtcpMuxPolicy.html new file mode 100644 index 0000000000..48e772fb51 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCConfiguration-rtcpMuxPolicy.html @@ -0,0 +1,196 @@ +<!doctype html> +<title>RTCConfiguration rtcpMuxPolicy</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCConfiguration-helper.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper function is called from RTCConfiguration-helper.js: + // config_test + + /* + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + RTCConfiguration getConfiguration(); + void setConfiguration(RTCConfiguration configuration); + ... + }; + + dictionary RTCConfiguration { + RTCRtcpMuxPolicy rtcpMuxPolicy = "require"; + ... + }; + + enum RTCRtcpMuxPolicy { + "negotiate", + "require" + }; + */ + + test(() => { + const pc = new RTCPeerConnection(); + assert_equals(pc.getConfiguration().rtcpMuxPolicy, 'require'); + }, `new RTCPeerConnection() should have default rtcpMuxPolicy require`); + + test(() => { + const pc = new RTCPeerConnection({ rtcpMuxPolicy: undefined }); + assert_equals(pc.getConfiguration().rtcpMuxPolicy, 'require'); + }, `new RTCPeerConnection({ rtcpMuxPolicy: undefined }) should have default rtcpMuxPolicy require`); + + test(() => { + const pc = new RTCPeerConnection({ rtcpMuxPolicy: 'require' }); + assert_equals(pc.getConfiguration().rtcpMuxPolicy, 'require'); + }, `new RTCPeerConnection({ rtcpMuxPolicy: 'require' }) should succeed`); + + /* + 4.3.1.1. Constructor + 3. If configuration.rtcpMuxPolicy is negotiate, and the user agent does not + implement non-muxed RTCP, throw a NotSupportedError. + */ + test(() => { + let pc; + try { + pc = new RTCPeerConnection({ rtcpMuxPolicy: 'negotiate' }); + } catch(err) { + // NotSupportedError is a DOMException with code 9 + if(err.code === 9 && err.name === 'NotSupportedError') { + // ignore error and pass test if negotiate is not supported + return; + } else { + throw err; + } + } + + assert_equals(pc.getConfiguration().rtcpMuxPolicy, 'negotiate'); + + }, `new RTCPeerConnection({ rtcpMuxPolicy: 'negotiate' }) may succeed or throw NotSupportedError`); + + config_test(makePc => { + assert_throws_js(TypeError, () => + makePc({ rtcpMuxPolicy: null })); + }, `with { rtcpMuxPolicy: null } should throw TypeError`); + + config_test(makePc => { + assert_throws_js(TypeError, () => + makePc({ rtcpMuxPolicy: 'invalid' })); + }, `with { rtcpMuxPolicy: 'invalid' } should throw TypeError`); + + /* + 4.3.2. Set a configuration + 6. If configuration.rtcpMuxPolicy is set and its value differs from the + connection's rtcpMux policy, throw an InvalidModificationError. + */ + + test(() => { + const pc = new RTCPeerConnection({ rtcpMuxPolicy: 'require' }); + assert_idl_attribute(pc, 'setConfiguration'); + assert_throws_dom('InvalidModificationError', () => + pc.setConfiguration({ rtcpMuxPolicy: 'negotiate' })); + + }, `setConfiguration({ rtcpMuxPolicy: 'negotiate' }) with initial rtcpMuxPolicy require should throw InvalidModificationError`); + + test(() => { + let pc; + try { + pc = new RTCPeerConnection({ rtcpMuxPolicy: 'negotiate' }); + } catch(err) { + // NotSupportedError is a DOMException with code 9 + if(err.code === 9 && err.name === 'NotSupportedError') { + // ignore error and pass test if negotiate is not supported + return; + } else { + throw err; + } + } + + assert_idl_attribute(pc, 'setConfiguration'); + assert_throws_dom('InvalidModificationError', () => + pc.setConfiguration({ rtcpMuxPolicy: 'require' })); + + }, `setConfiguration({ rtcpMuxPolicy: 'require' }) with initial rtcpMuxPolicy negotiate should throw InvalidModificationError`); + + test(() => { + let pc; + try { + pc = new RTCPeerConnection({ rtcpMuxPolicy: 'negotiate' }); + } catch(err) { + // NotSupportedError is a DOMException with code 9 + if(err.code === 9 && err.name === 'NotSupportedError') { + // ignore error and pass test if negotiate is not supported + return; + } else { + throw err; + } + } + + assert_idl_attribute(pc, 'setConfiguration'); + // default value for rtcpMuxPolicy is require + assert_throws_dom('InvalidModificationError', () => + pc.setConfiguration({})); + + }, `setConfiguration({}) with initial rtcpMuxPolicy negotiate should throw InvalidModificationError`); + + /* + Coverage Report + + Tested 2 + Total 2 + */ + const FINGERPRINT_SHA256 = '00:00:00:00:00:00:00:00:00:00:00:00:00' + + ':00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00'; + const ICEUFRAG = 'someufrag'; + const ICEPWD = 'somelongpwdwithenoughrandomness'; + + promise_test(async t => { + // audio-only SDP offer without BUNDLE and rtcp-mux. + const sdp = 'v=0\r\n' + + 'o=- 166855176514521964 2 IN IP4 127.0.0.1\r\n' + + 's=-\r\n' + + 't=0 0\r\n' + + 'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' + + 'c=IN IP4 0.0.0.0\r\n' + + 'a=rtcp:9 IN IP4 0.0.0.0\r\n' + + 'a=ice-ufrag:' + ICEUFRAG + '\r\n' + + 'a=ice-pwd:' + ICEPWD + '\r\n' + + 'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' + + 'a=setup:actpass\r\n' + + 'a=mid:audio1\r\n' + + 'a=sendonly\r\n' + + 'a=rtcp-rsize\r\n' + + 'a=rtpmap:111 opus/48000/2\r\n'; + const pc = new RTCPeerConnection({rtcpMuxPolicy: 'require'}); + t.add_cleanup(() => pc.close()); + + return promise_rejects_dom(t, 'InvalidAccessError', pc.setRemoteDescription({type: 'offer', sdp})); + }, 'setRemoteDescription throws InvalidAccessError when called with an offer without rtcp-mux and rtcpMuxPolicy is set to require'); + + promise_test(async t => { + // audio-only SDP answer without BUNDLE and rtcp-mux. + // Also omitting a=mid in order to avoid parsing it from the offer as this needs to match. + const sdp = 'v=0\r\n' + + 'o=- 166855176514521964 2 IN IP4 127.0.0.1\r\n' + + 's=-\r\n' + + 't=0 0\r\n' + + 'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' + + 'c=IN IP4 0.0.0.0\r\n' + + 'a=rtcp:9 IN IP4 0.0.0.0\r\n' + + 'a=ice-ufrag:' + ICEUFRAG + '\r\n' + + 'a=ice-pwd:' + ICEPWD + '\r\n' + + 'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' + + 'a=setup:active\r\n' + + 'a=sendonly\r\n' + + 'a=rtcp-rsize\r\n' + + 'a=rtpmap:111 opus/48000/2\r\n'; + const pc = new RTCPeerConnection({rtcpMuxPolicy: 'require'}); + t.add_cleanup(() => pc.close()); + + const offer = await generateAudioReceiveOnlyOffer(pc); + await pc.setLocalDescription(offer); + return promise_rejects_dom(t, 'InvalidAccessError', pc.setRemoteDescription({type: 'answer', sdp})); + }, 'setRemoteDescription throws InvalidAccessError when called with an answer without rtcp-mux and rtcpMuxPolicy is set to require'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCDTMFSender-helper.js b/testing/web-platform/tests/webrtc/RTCDTMFSender-helper.js new file mode 100644 index 0000000000..4316c3804a --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCDTMFSender-helper.js @@ -0,0 +1,149 @@ +'use strict'; + +// Test is based on the following editor draft: +// https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + +// Code using this helper should also include RTCPeerConnection-helper.js +// in the main HTML file + +// The following helper functions are called from RTCPeerConnection-helper.js: +// getTrackFromUserMedia +// exchangeOfferAnswer + +// Create a RTCDTMFSender using getUserMedia() +// Connect the PeerConnection to another PC and wait until it is +// properly connected, so that DTMF can be sent. +function createDtmfSender(pc = new RTCPeerConnection()) { + let dtmfSender; + return getTrackFromUserMedia('audio') + .then(([track, mediaStream]) => { + const sender = pc.addTrack(track, mediaStream); + dtmfSender = sender.dtmf; + assert_true(dtmfSender instanceof RTCDTMFSender, + 'Expect audio sender.dtmf to be set to a RTCDTMFSender'); + // Note: spec bug open - https://github.com/w3c/webrtc-pc/issues/1774 + // on whether sending should be possible before negotiation. + const pc2 = new RTCPeerConnection(); + Object.defineProperty(pc, 'otherPc', { value: pc2 }); + exchangeIceCandidates(pc, pc2); + return exchangeOfferAnswer(pc, pc2); + }).then(() => { + if (!('canInsertDTMF' in dtmfSender)) { + return Promise.resolve(); + } + // Wait until dtmfSender.canInsertDTMF becomes true. + // Up to 150 ms has been observed in test. Wait 1 second + // in steps of 10 ms. + // Note: Using a short timeout and rejected promise in order to + // make test return a clear error message on failure. + return new Promise((resolve, reject) => { + let counter = 0; + step_timeout(function checkCanInsertDTMF() { + if (dtmfSender.canInsertDTMF) { + resolve(); + } else { + if (counter >= 100) { + reject('Waited too long for canInsertDTMF'); + return; + } + ++counter; + step_timeout(checkCanInsertDTMF, 10); + } + }, 0); + }); + }).then(() => { + return dtmfSender; + }); +} + +/* + Create an RTCDTMFSender and test tonechange events on it. + testFunc + Test function that is going to manipulate the DTMFSender. + It will be called with: + t - the test object + sender - the created RTCDTMFSender + pc - the associated RTCPeerConnection as second argument. + toneChanges + Array of expected tonechange events fired. The elements + are array of 3 items: + expectedTone + The expected character in event.tone + expectedToneBuffer + The expected new value of dtmfSender.toneBuffer + expectedDuration + The rough time since beginning or last tonechange event + was fired. + desc + Test description. + */ +function test_tone_change_events(testFunc, toneChanges, desc) { + // Convert to cumulative time + let cumulativeTime = 0; + const cumulativeToneChanges = toneChanges.map(c => { + cumulativeTime += c[2]; + return [c[0], c[1], cumulativeTime]; + }); + + // Wait for same duration as last expected duration + 100ms + // before passing test in case there are new tone events fired, + // in which case the test should fail. + const lastWait = toneChanges.pop()[2] + 100; + + promise_test(async t => { + const pc = new RTCPeerConnection(); + const dtmfSender = await createDtmfSender(pc); + const start = Date.now(); + + const allEventsReceived = new Promise(resolve => { + const onToneChange = t.step_func(ev => { + assert_true(ev instanceof RTCDTMFToneChangeEvent, + 'Expect tone change event object to be an RTCDTMFToneChangeEvent'); + + const { tone } = ev; + assert_equals(typeof tone, 'string', + 'Expect event.tone to be the tone string'); + + assert_greater_than(cumulativeToneChanges.length, 0, + 'More tonechange event is fired than expected'); + + const [ + expectedTone, expectedToneBuffer, expectedTime + ] = cumulativeToneChanges.shift(); + + assert_equals(tone, expectedTone, + `Expect current event.tone to be ${expectedTone}`); + + assert_equals(dtmfSender.toneBuffer, expectedToneBuffer, + `Expect dtmfSender.toneBuffer to be updated to ${expectedToneBuffer}`); + + // We check that the cumulative delay is at least the expected one, but + // system load may cause random delays, so we do not put any + // realistic upper bound on the timing of the events. + assert_between_inclusive(Date.now() - start, expectedTime, + expectedTime + 4000, + `Expect tonechange event for "${tone}" to be fired approximately after ${expectedTime} milliseconds`); + if (cumulativeToneChanges.length === 0) { + resolve(); + } + }); + + dtmfSender.addEventListener('tonechange', onToneChange); + }); + + testFunc(t, dtmfSender, pc); + await allEventsReceived; + const wait = ms => new Promise(resolve => t.step_timeout(resolve, ms)); + await wait(lastWait); + }, desc); +} + +// Get the one and only tranceiver from pc.getTransceivers(). +// Assumes that there is only one tranceiver in pc. +function getTransceiver(pc) { + const transceivers = pc.getTransceivers(); + assert_equals(transceivers.length, 1, + 'Expect there to be only one tranceiver in pc'); + + return transceivers[0]; +} diff --git a/testing/web-platform/tests/webrtc/RTCDTMFSender-insertDTMF.https.html b/testing/web-platform/tests/webrtc/RTCDTMFSender-insertDTMF.https.html new file mode 100644 index 0000000000..71cfe70171 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCDTMFSender-insertDTMF.https.html @@ -0,0 +1,176 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCDTMFSender.prototype.insertDTMF</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script src="RTCDTMFSender-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js + // generateAnswer + + // The following helper functions are called from RTCDTMFSender-helper.js + // createDtmfSender + // test_tone_change_events + // getTransceiver + + /* + 7. Peer-to-peer DTMF + partial interface RTCRtpSender { + readonly attribute RTCDTMFSender? dtmf; + }; + + interface RTCDTMFSender : EventTarget { + void insertDTMF(DOMString tones, + optional unsigned long duration = 100, + optional unsigned long interToneGap = 70); + attribute EventHandler ontonechange; + readonly attribute DOMString toneBuffer; + }; + */ + + /* + 7.2. insertDTMF + The tones parameter is treated as a series of characters. + + The characters 0 through 9, A through D, #, and * generate the associated + DTMF tones. + + The characters a to d MUST be normalized to uppercase on entry and are + equivalent to A to D. + + As noted in [RTCWEB-AUDIO] Section 3, support for the characters 0 through 9, + A through D, #, and * are required. + + The character ',' MUST be supported, and indicates a delay of 2 seconds + before processing the next character in the tones parameter. + + All other characters (and only those other characters) MUST be considered + unrecognized. + */ + promise_test(async t => { + const dtmfSender = await createDtmfSender(); + dtmfSender.insertDTMF(''); + dtmfSender.insertDTMF('012345689'); + dtmfSender.insertDTMF('ABCD'); + dtmfSender.insertDTMF('abcd'); + dtmfSender.insertDTMF('#*'); + dtmfSender.insertDTMF(','); + dtmfSender.insertDTMF('0123456789ABCDabcd#*,'); + }, 'insertDTMF() should succeed if tones contains valid DTMF characters'); + + + /* + 7.2. insertDTMF + 6. If tones contains any unrecognized characters, throw an + InvalidCharacterError. + */ + promise_test(async t => { + const dtmfSender = await createDtmfSender(); + assert_throws_dom('InvalidCharacterError', () => + // 'F' is invalid + dtmfSender.insertDTMF('123FFABC')); + + assert_throws_dom('InvalidCharacterError', () => + // 'E' is invalid + dtmfSender.insertDTMF('E')); + + assert_throws_dom('InvalidCharacterError', () => + // ' ' is invalid + dtmfSender.insertDTMF('# *')); + }, 'insertDTMF() should throw InvalidCharacterError if tones contains invalid DTMF characters'); + + /* + 7.2. insertDTMF + 3. If transceiver.stopped is true, throw an InvalidStateError. + */ + test(t => { + const pc = new RTCPeerConnection(); + const transceiver = pc.addTransceiver('audio'); + const dtmfSender = transceiver.sender.dtmf; + + transceiver.stop(); + assert_throws_dom('InvalidStateError', () => dtmfSender.insertDTMF('')); + + }, 'insertDTMF() should throw InvalidStateError if transceiver is stopped'); + + /* + 7.2. insertDTMF + 4. If transceiver.currentDirection is recvonly or inactive, throw an InvalidStateError. + */ + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const transceiver = + caller.addTransceiver('audio', { direction: 'recvonly' }); + const dtmfSender = transceiver.sender.dtmf; + + const offer = await caller.createOffer(); + await caller.setLocalDescription(offer); + await callee.setRemoteDescription(offer); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + callee.addTrack(track, stream); + const answer = await callee.createAnswer(); + await callee.setLocalDescription(answer); + await caller.setRemoteDescription(answer); + assert_equals(transceiver.currentDirection, 'recvonly'); + assert_throws_dom('InvalidStateError', () => dtmfSender.insertDTMF('')); + }, 'insertDTMF() should throw InvalidStateError if transceiver.currentDirection is recvonly'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = + pc.addTransceiver('audio', { direction: 'inactive' }); + const dtmfSender = transceiver.sender.dtmf; + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await generateAnswer(offer); + await pc.setRemoteDescription(answer); + assert_equals(transceiver.currentDirection, 'inactive'); + assert_throws_dom('InvalidStateError', () => dtmfSender.insertDTMF('')); + }, 'insertDTMF() should throw InvalidStateError if transceiver.currentDirection is inactive'); + + /* + 7.2. insertDTMF + The characters a to d MUST be normalized to uppercase on entry and are + equivalent to A to D. + + 7. Set the object's toneBuffer attribute to tones. + */ + promise_test(async t => { + const dtmfSender = await createDtmfSender(); + dtmfSender.insertDTMF('123'); + assert_equals(dtmfSender.toneBuffer, '123'); + + dtmfSender.insertDTMF('ABC'); + assert_equals(dtmfSender.toneBuffer, 'ABC'); + + dtmfSender.insertDTMF('bcd'); + assert_equals(dtmfSender.toneBuffer, 'BCD'); + }, 'insertDTMF() should set toneBuffer to provided tones normalized, with old tones overridden'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const [track, mediaStream] = await getTrackFromUserMedia('audio'); + const sender = pc.addTrack(track, mediaStream); + await pc.setLocalDescription(await pc.createOffer()); + const dtmfSender = sender.dtmf; + pc.removeTrack(sender); + pc.close(); + assert_throws_dom('InvalidStateError', () => + dtmfSender.insertDTMF('123')); + }, 'insertDTMF() after remove and close should reject'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCDTMFSender-ontonechange-long.https.html b/testing/web-platform/tests/webrtc/RTCDTMFSender-ontonechange-long.https.html new file mode 100644 index 0000000000..852194d024 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCDTMFSender-ontonechange-long.https.html @@ -0,0 +1,50 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCDTMFSender.prototype.ontonechange (Long Timeout)</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script src="RTCDTMFSender-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCDTMFSender-helper.js + // test_tone_change_events + + /* + 7. Peer-to-peer DTMF + partial interface RTCRtpSender { + readonly attribute RTCDTMFSender? dtmf; + }; + + interface RTCDTMFSender : EventTarget { + void insertDTMF(DOMString tones, + optional unsigned long duration = 100, + optional unsigned long interToneGap = 70); + attribute EventHandler ontonechange; + readonly attribute DOMString toneBuffer; + }; + + [Constructor(DOMString type, RTCDTMFToneChangeEventInit eventInitDict)] + interface RTCDTMFToneChangeEvent : Event { + readonly attribute DOMString tone; + }; + */ + + /* + 7.2. insertDTMF + 8. If the value of the duration parameter is less than 40, set it to 40. + If, on the other hand, the value is greater than 6000, set it to 6000. + */ + test_tone_change_events((t, dtmfSender) => { + dtmfSender.insertDTMF('A', 8000, 70); + }, [ + ['A', '', 0], + ['', '', 6070] + ],'insertDTMF with duration greater than 6000 should be clamped to 6000'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCDTMFSender-ontonechange.https.html b/testing/web-platform/tests/webrtc/RTCDTMFSender-ontonechange.https.html new file mode 100644 index 0000000000..08dd6ada32 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCDTMFSender-ontonechange.https.html @@ -0,0 +1,294 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCDTMFSender.prototype.ontonechange</title> +<meta name="timeout" content="long"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script src="RTCDTMFSender-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js + // generateAnswer + + // The following helper functions are called from RTCDTMFSender-helper.js + // test_tone_change_events + // getTransceiver + + /* + 7. Peer-to-peer DTMF + partial interface RTCRtpSender { + readonly attribute RTCDTMFSender? dtmf; + }; + + interface RTCDTMFSender : EventTarget { + void insertDTMF(DOMString tones, + optional unsigned long duration = 100, + optional unsigned long interToneGap = 70); + attribute EventHandler ontonechange; + readonly attribute DOMString toneBuffer; + }; + + [Constructor(DOMString type, RTCDTMFToneChangeEventInit eventInitDict)] + interface RTCDTMFToneChangeEvent : Event { + readonly attribute DOMString tone; + }; + */ + + /* + 7.2. insertDTMF + 11. If a Playout task is scheduled to be run; abort these steps; otherwise queue + a task that runs the following steps (Playout task): + 3. If toneBuffer is an empty string, fire an event named tonechange with an + empty string at the RTCDTMFSender object and abort these steps. + 4. Remove the first character from toneBuffer and let that character be tone. + 6. Queue a task to be executed in duration + interToneGap ms from now that + runs the steps labelled Playout task. + 7. Fire an event named tonechange with a string consisting of tone at the + RTCDTMFSender object. + */ + test_tone_change_events((t, dtmfSender) => { + dtmfSender.insertDTMF('123'); + }, [ + ['1', '23', 0], + ['2', '3', 170], + ['3', '', 170], + ['', '', 170] + ], 'insertDTMF() with default duration and intertoneGap should fire tonechange events at the expected time'); + + test_tone_change_events((t, dtmfSender) => { + dtmfSender.insertDTMF('abc', 100, 70); + }, [ + ['A', 'BC', 0], + ['B', 'C', 170], + ['C', '', 170], + ['', '', 170] + ], 'insertDTMF() with explicit duration and intertoneGap should fire tonechange events at the expected time'); + + /* + 7.2. insertDTMF + 10. If toneBuffer is an empty string, abort these steps. + */ + async_test(t => { + createDtmfSender() + .then(dtmfSender => { + dtmfSender.addEventListener('tonechange', + t.unreached_func('Expect no tonechange event to be fired')); + + dtmfSender.insertDTMF('', 100, 70); + + t.step_timeout(t.step_func_done(), 300); + }) + .catch(t.step_func(err => { + assert_unreached(`Unexpected promise rejection: ${err}`); + })); + }, `insertDTMF('') should not fire any tonechange event, including for '' tone`); + + /* + 7.2. insertDTMF + 8. If the value of the duration parameter is less than 40, set it to 40. + If, on the other hand, the value is greater than 6000, set it to 6000. + */ + test_tone_change_events((t, dtmfSender) => { + dtmfSender.insertDTMF('ABC', 10, 70); + }, [ + ['A', 'BC', 0], + ['B', 'C', 110], + ['C', '', 110], + ['', '', 110] + ], 'insertDTMF() with duration less than 40 should be clamped to 40'); + + /* + 7.2. insertDTMF + 9. If the value of the interToneGap parameter is less than 30, set it to 30. + */ + test_tone_change_events((t, dtmfSender) => { + dtmfSender.insertDTMF('ABC', 100, 10); + }, [ + ['A', 'BC', 0], + ['B', 'C', 130], + ['C', '', 130], + ['', '', 130] + ], + 'insertDTMF() with interToneGap less than 30 should be clamped to 30'); + + /* + [w3c/webrtc-pc#1373] + This step is added to handle the "," character correctly. "," supposed to delay the next + tonechange event by 2000ms. + + 7.2. insertDTMF + 11.5. If tone is "," delay sending tones for 2000 ms on the associated RTP media + stream, and queue a task to be executed in 2000 ms from now that runs the + steps labelled Playout task. + */ + test_tone_change_events((t, dtmfSender) => { + dtmfSender.insertDTMF('A,B', 100, 70); + + }, [ + ['A', ',B', 0], + [',', 'B', 170], + ['B', '', 2000], + ['', '', 170] + ], 'insertDTMF with comma should delay next tonechange event for a constant 2000ms'); + + /* + 7.2. insertDTMF + 11.1. If transceiver.stopped is true, abort these steps. + */ + test_tone_change_events((t, dtmfSender, pc) => { + const transceiver = getTransceiver(pc); + dtmfSender.addEventListener('tonechange', ev => { + if(ev.tone === 'B') { + transceiver.stop(); + } + }); + + dtmfSender.insertDTMF('ABC', 100, 70); + }, [ + ['A', 'BC', 0], + ['B', 'C', 170] + ], 'insertDTMF() with transceiver stopped in the middle should stop future tonechange events from firing'); + + /* + 7.2. insertDTMF + 3. If a Playout task is scheduled to be run, abort these steps; + otherwise queue a task that runs the following steps (Playout task): + */ + test_tone_change_events((t, dtmfSender) => { + dtmfSender.addEventListener('tonechange', ev => { + if(ev.tone === 'B') { + dtmfSender.insertDTMF('12', 100, 70); + } + }); + + dtmfSender.insertDTMF('ABC', 100, 70); + }, [ + ['A', 'BC', 0], + ['B', 'C', 170], + ['1', '2', 170], + ['2', '', 170], + ['', '', 170] + ], 'Calling insertDTMF() in the middle of tonechange events should cause future tonechanges to be updated to new tones'); + + + /* + 7.2. insertDTMF + 3. If a Playout task is scheduled to be run, abort these steps; + otherwise queue a task that runs the following steps (Playout task): + */ + test_tone_change_events((t, dtmfSender) => { + dtmfSender.addEventListener('tonechange', ev => { + if(ev.tone === 'B') { + dtmfSender.insertDTMF('12', 100, 70); + dtmfSender.insertDTMF('34', 100, 70); + } + }); + + dtmfSender.insertDTMF('ABC', 100, 70); + }, [ + ['A', 'BC', 0], + ['B', 'C', 170], + ['3', '4', 170], + ['4', '', 170], + ['', '', 170] + ], 'Calling insertDTMF() multiple times in the middle of tonechange events should cause future tonechanges to be updated the last provided tones'); + + /* + 7.2. insertDTMF + 3. If a Playout task is scheduled to be run, abort these steps; + otherwise queue a task that runs the following steps (Playout task): + */ + test_tone_change_events((t, dtmfSender) => { + dtmfSender.addEventListener('tonechange', ev => { + if(ev.tone === 'B') { + dtmfSender.insertDTMF(''); + } + }); + + dtmfSender.insertDTMF('ABC', 100, 70); + }, [ + ['A', 'BC', 0], + ['B', 'C', 170], + ['', '', 170] + ], `Calling insertDTMF('') in the middle of tonechange events should stop future tonechange events from firing`); + + /* + 7.2. insertDTMF + 11.2. If transceiver.currentDirection is recvonly or inactive, abort these steps. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const dtmfSender = await createDtmfSender(pc); + const pc2 = pc.otherPc; + assert_true(pc2 instanceof RTCPeerConnection, + 'Expect pc2 to be a RTCPeerConnection'); + t.add_cleanup(() => pc2.close()); + const transceiver = pc.getTransceivers()[0]; + assert_equals(transceiver.sender.dtmf, dtmfSender); + + // Since setRemoteDescription happens in parallel with tonechange event, + // We use a flag and allow tonechange events to be fired as long as + // the promise returned by setRemoteDescription is not yet resolved. + let remoteDescriptionIsSet = false; + + // We only do basic tone verification and not check timing here + let expectedTones = ['A', 'B', 'C', 'D', '']; + + const firstTone = new Promise(resolve => { + const onToneChange = t.step_func(ev => { + assert_false(remoteDescriptionIsSet, + 'Expect no tonechange event to be fired after currentDirection is changed to recvonly'); + + const { tone } = ev; + const expectedTone = expectedTones.shift(); + assert_equals(tone, expectedTone, + `Expect fired event.tone to be ${expectedTone}`); + + if(tone === 'A') { + resolve(); + } + }); + dtmfSender.addEventListener('tonechange', onToneChange); + }); + + dtmfSender.insertDTMF('ABCD', 100, 70); + await firstTone; + + // Only change transceiver.direction after the first + // tonechange event, to make sure that tonechange is triggered + // then stopped + transceiver.direction = 'recvonly'; + await exchangeOfferAnswer(pc, pc2); + assert_equals(transceiver.currentDirection, 'inactive'); + remoteDescriptionIsSet = true; + + await new Promise(resolve => t.step_timeout(resolve, 300)); + }, `Setting transceiver.currentDirection to recvonly in the middle of tonechange events should stop future tonechange events from firing`); + + /* Section 7.3 - Tone change event */ + test(t => { + let ev = new RTCDTMFToneChangeEvent('tonechange', {'tone': '1'}); + assert_equals(ev.type, 'tonechange'); + assert_equals(ev.tone, '1'); + }, 'Tone change event constructor works'); + + test(t => { + let ev = new RTCDTMFToneChangeEvent('worngname', {}); + }, 'Tone change event with unexpected name should not crash'); + + test(t => { + const ev1 = new RTCDTMFToneChangeEvent('tonechange', {}); + assert_equals(ev1.tone, ''); + + assert_equals(RTCDTMFToneChangeEvent.constructor.length, 1); + const ev2 = new RTCDTMFToneChangeEvent('tonechange'); + assert_equals(ev2.tone, ''); + }, 'Tone change event init optional parameters'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCDataChannel-binaryType.window.js b/testing/web-platform/tests/webrtc/RTCDataChannel-binaryType.window.js new file mode 100644 index 0000000000..c63281bd51 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCDataChannel-binaryType.window.js @@ -0,0 +1,27 @@ +'use strict'; + +const validBinaryTypes = ['blob', 'arraybuffer']; +const invalidBinaryTypes = ['jellyfish', 'arraybuffer ', '', null, undefined]; + +for (const binaryType of validBinaryTypes) { + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const dc = pc.createDataChannel('test-binary-type'); + + dc.binaryType = binaryType; + assert_equals(dc.binaryType, binaryType, `dc.binaryType should be '${binaryType}'`); + }, `Setting binaryType to '${binaryType}' should succeed`); +} + +for (const binaryType of invalidBinaryTypes) { + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const dc = pc.createDataChannel('test-binary-type'); + + assert_throws_dom('SyntaxError', () => { + dc.binaryType = binaryType; + }); + }, `Setting invalid binaryType '${binaryType}' should throw SyntaxError`); +} diff --git a/testing/web-platform/tests/webrtc/RTCDataChannel-bufferedAmount.html b/testing/web-platform/tests/webrtc/RTCDataChannel-bufferedAmount.html new file mode 100644 index 0000000000..b1b793206c --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCDataChannel-bufferedAmount.html @@ -0,0 +1,287 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCDataChannel.prototype.bufferedAmount</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +// Test is based on the following revision: +// https://rawgit.com/w3c/webrtc-pc/1cc5bfc3ff18741033d804c4a71f7891242fb5b3/webrtc.html + +// The following helper functions are called from RTCPeerConnection-helper.js: +// createDataChannelPair +// awaitMessage + +/* + 6.2. RTCDataChannel + interface RTCDataChannel : EventTarget { + ... + readonly attribute unsigned long bufferedAmount; + void send(USVString data); + void send(Blob data); + void send(ArrayBuffer data); + void send(ArrayBufferView data); + }; + + bufferedAmount + The bufferedAmount attribute must return the number of bytes of application + data (UTF-8 text and binary data) that have been queued using send() but that, + as of the last time the event loop started executing a task, had not yet been + transmitted to the network. (This thus includes any text sent during the + execution of the current task, regardless of whether the user agent is able + to transmit text asynchronously with script execution.) This does not include + framing overhead incurred by the protocol, or buffering done by the operating + system or network hardware. The value of the [[BufferedAmount]] slot will only + increase with each call to the send() method as long as the [[ReadyState]] slot + is open; however, the slot does not reset to zero once the channel closes. When + the underlying data transport sends data from its queue, the user agent MUST + queue a task that reduces [[BufferedAmount]] with the number of bytes that was + sent. + + + [WebMessaging] + interface MessageEvent : Event { + readonly attribute any data; + ... + }; + */ + +// Simple ASCII encoded string +const helloString = 'hello'; +// ASCII encoded buffer representation of the string +const helloBuffer = Uint8Array.of(0x68, 0x65, 0x6c, 0x6c, 0x6f); +const helloBlob = new Blob([helloBuffer]); + +const emptyBuffer = Uint8Array.of(); +const emptyBlob = new Blob([emptyBuffer]); + +// Unicode string with multiple code units +const unicodeString = 'äļįä― åĨ―'; +// UTF-8 encoded buffer representation of the string +const unicodeBuffer = Uint8Array.of( + 0xe4, 0xb8, 0x96, 0xe7, 0x95, 0x8c, + 0xe4, 0xbd, 0xa0, 0xe5, 0xa5, 0xbd); + +for (const options of [{}, {negotiated: true, id: 0}]) { + const mode = `${options.negotiated? "negotiated " : ""}datachannel`; + + /* + Ensure .bufferedAmount is 0 initially for both sides. + */ + promise_test(async (t) => { + const [dc1, dc2] = await createDataChannelPair(t, options); + + assert_equals(dc1.bufferedAmount, 0, 'Expect bufferedAmount to be 0'); + assert_equals(dc2.bufferedAmount, 0, 'Expect bufferedAmount to be 0'); + }, `${mode} bufferedAmount initial value should be 0 for both peers`); + + /* + 6.2. send() + 3. Execute the sub step that corresponds to the type of the methods argument: + + string object + Let data be the object and increase the bufferedAmount attribute + by the number of bytes needed to express data as UTF-8. + */ + promise_test(async (t) => { + const [dc1, dc2] = await createDataChannelPair(t, options); + + dc1.send(unicodeString); + assert_equals(dc1.bufferedAmount, unicodeBuffer.byteLength, + 'Expect bufferedAmount to be the byte length of the unicode string'); + + await awaitMessage(dc2); + assert_equals(dc1.bufferedAmount, 0, + 'Expect sender bufferedAmount to be reduced after message is sent'); + }, `${mode} bufferedAmount should increase to byte length of encoded` + + `unicode string sent`); + + promise_test(async (t) => { + const [dc1, dc2] = await createDataChannelPair(t, options); + + dc1.send(""); + assert_equals(dc1.bufferedAmount, 0, + 'Expect bufferedAmount to stay at zero after sending empty string'); + + await awaitMessage(dc2); + assert_equals(dc1.bufferedAmount, 0, 'Expect sender bufferedAmount unchanged'); + }, `${mode} bufferedAmount should stay at zero for empty string sent`); + + /* + 6.2. send() + 3. Execute the sub step that corresponds to the type of the methods argument: + ArrayBuffer object + Let data be the data stored in the buffer described by the ArrayBuffer + object and increase the bufferedAmount attribute by the length of the + ArrayBuffer in bytes. + */ + promise_test(async (t) => { + const [dc1, dc2] = await createDataChannelPair(t, options); + + dc1.send(helloBuffer.buffer); + assert_equals(dc1.bufferedAmount, helloBuffer.byteLength, + 'Expect bufferedAmount to increase to byte length of sent buffer'); + + await awaitMessage(dc2); + assert_equals(dc1.bufferedAmount, 0, + 'Expect sender bufferedAmount to be reduced after message is sent'); + }, `${mode} bufferedAmount should increase to byte length of buffer sent`); + + promise_test(async (t) => { + const [dc1, dc2] = await createDataChannelPair(t, options); + + dc1.send(emptyBuffer.buffer); + assert_equals(dc1.bufferedAmount, 0, + 'Expect bufferedAmount to stay at zero after sending empty buffer'); + + await awaitMessage(dc2); + assert_equals(dc1.bufferedAmount, 0, + 'Expect sender bufferedAmount unchanged'); + }, `${mode} bufferedAmount should stay at zero for empty buffer sent`); + + /* + 6.2. send() + 3. Execute the sub step that corresponds to the type of the methods argument: + Blob object + Let data be the raw data represented by the Blob object and increase + the bufferedAmount attribute by the size of data, in bytes. + */ + promise_test(async (t) => { + const [dc1, dc2] = await createDataChannelPair(t, options); + + dc1.send(helloBlob); + assert_equals(dc1.bufferedAmount, helloBlob.size, + 'Expect bufferedAmount to increase to size of sent blob'); + + await awaitMessage(dc2); + assert_equals(dc1.bufferedAmount, 0, + 'Expect sender bufferedAmount to be reduced after message is sent'); + }, `${mode} bufferedAmount should increase to size of blob sent`); + + promise_test(async (t) => { + const [dc1, dc2] = await createDataChannelPair(t, options); + + dc1.send(emptyBlob); + assert_equals(dc1.bufferedAmount, 0, + 'Expect bufferedAmount to stay at zero after sending empty blob'); + + await awaitMessage(dc2); + assert_equals(dc1.bufferedAmount, 0, + 'Expect sender bufferedAmount unchanged'); + }, `${mode} bufferedAmount should stay at zero for empty blob sent`); + + // Test sending 3 messages: helloBuffer, unicodeString, helloBlob + promise_test(async (t) => { + const resolver = new Resolver(); + let messageCount = 0; + + const [dc1, dc2] = await createDataChannelPair(t, options); + dc2.onmessage = t.step_func(() => { + if (++messageCount === 3) { + assert_equals(dc1.bufferedAmount, 0, + 'Expect sender bufferedAmount to be reduced after message is sent'); + resolver.resolve(); + } + }); + + dc1.send(helloBuffer); + assert_equals(dc1.bufferedAmount, helloString.length, + 'Expect bufferedAmount to be the total length of all messages queued to send'); + + dc1.send(unicodeString); + assert_equals(dc1.bufferedAmount, + helloString.length + unicodeBuffer.byteLength, + 'Expect bufferedAmount to be the total length of all messages queued to send'); + + dc1.send(helloBlob); + assert_equals(dc1.bufferedAmount, + helloString.length*2 + unicodeBuffer.byteLength, + 'Expect bufferedAmount to be the total length of all messages queued to send'); + + await resolver; + }, `${mode} bufferedAmount should increase by byte length for each message sent`); + + promise_test(async (t) => { + const [dc1] = await createDataChannelPair(t, options); + + dc1.send(helloBuffer.buffer); + assert_equals(dc1.bufferedAmount, helloBuffer.byteLength, + 'Expect bufferedAmount to increase to byte length of sent buffer'); + + dc1.close(); + assert_equals(dc1.bufferedAmount, helloBuffer.byteLength, + 'Expect bufferedAmount to not decrease immediately after closing the channel'); + }, `${mode} bufferedAmount should not decrease immediately after initiating closure`); + + promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const [dc1] = await createDataChannelPair(t, options, pc1); + + dc1.send(helloBuffer.buffer); + assert_equals(dc1.bufferedAmount, helloBuffer.byteLength, + 'Expect bufferedAmount to increase to byte length of sent buffer'); + + pc1.close(); + assert_equals(dc1.bufferedAmount, helloBuffer.byteLength, + 'Expect bufferedAmount to not decrease after closing the peer connection'); + }, `${mode} bufferedAmount should not decrease after closing the peer connection`); + + promise_test(async t => { + const [channel1, channel2] = await createDataChannelPair(t, options); + channel1.addEventListener('bufferedamountlow', t.step_func_done(() => { + assert_true(channel1.bufferedAmount <= channel1.bufferedAmountLowThreshold); + })); + const eventWatcher = new EventWatcher(t, channel1, ['bufferedamountlow']); + channel1.send(helloString); + await eventWatcher.wait_for(['bufferedamountlow']); + }, `${mode} bufferedamountlow event fires after send() is complete`); + + promise_test(async t => { + const [channel1, channel2] = await createDataChannelPair(t, options); + channel1.send(helloString); + assert_equals(channel1.bufferedAmount, helloString.length); + await awaitMessage(channel2); + assert_equals(channel1.bufferedAmount, 0); + }, `${mode} bufferedamount is data.length on send(data)`); + + promise_test(async t => { + const [channel1, channel2] = await createDataChannelPair(t, options); + channel1.send(helloString); + assert_equals(channel1.bufferedAmount, helloString.length); + assert_equals(channel1.bufferedAmount, helloString.length); + }, `${mode} bufferedamount returns the same amount if no more data is`); + + promise_test(async t => { + const [channel1, channel2] = await createDataChannelPair(t, options); + let eventFireCount = 0; + channel1.addEventListener('bufferedamountlow', t.step_func(() => { + assert_true(channel1.bufferedAmount <= channel1.bufferedAmountLowThreshold); + assert_equals(++eventFireCount, 1); + })); + const eventWatcher = new EventWatcher(t, channel1, ['bufferedamountlow']); + channel1.send(helloString); + assert_equals(channel1.bufferedAmount, helloString.length); + channel1.send(helloString); + assert_equals(channel1.bufferedAmount, 2 * helloString.length); + await eventWatcher.wait_for(['bufferedamountlow']); + }, `${mode} bufferedamountlow event fires only once after multiple` + + ` consecutive send() calls`); + + promise_test(async t => { + const [channel1, channel2] = await createDataChannelPair(t, options); + const eventWatcher = new EventWatcher(t, channel1, ['bufferedamountlow']); + channel1.send(helloString); + assert_equals(channel1.bufferedAmount, helloString.length); + await eventWatcher.wait_for(['bufferedamountlow']); + assert_equals(await awaitMessage(channel2), helloString); + channel1.send(helloString); + assert_equals(channel1.bufferedAmount, helloString.length); + await eventWatcher.wait_for(['bufferedamountlow']); + assert_equals(await awaitMessage(channel2), helloString); + }, `${mode} bufferedamountlow event fires after each sent message`); +} +</script> diff --git a/testing/web-platform/tests/webrtc/RTCDataChannel-close.html b/testing/web-platform/tests/webrtc/RTCDataChannel-close.html new file mode 100644 index 0000000000..64534fc507 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCDataChannel-close.html @@ -0,0 +1,180 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCDataChannel.prototype.close</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +for (const options of [{}, {negotiated: true, id: 0}]) { + const mode = `${options.negotiated? "negotiated " : ""}datachannel`; + + promise_test(async t => { + const [channel1, channel2] = await createDataChannelPair(t, options); + const haveClosed = new Promise(r => channel2.onclose = r); + let closingSeen = false; + channel1.onclosing = t.unreached_func(); + channel2.onclosing = () => { + assert_equals(channel2.readyState, 'closing'); + closingSeen = true; + }; + channel2.addEventListener('error', t.unreached_func()); + channel1.close(); + await haveClosed; + assert_equals(channel2.readyState, 'closed'); + assert_true(closingSeen, 'Closing event was seen'); + }, `Close ${mode} causes onclosing and onclose to be called`); + + promise_test(async t => { + // This is the same test as above, but using addEventListener + // rather than the "onclose" attribute. + const [channel1, channel2] = await createDataChannelPair(t, options); + const haveClosed = new Promise(r => channel2.addEventListener('close', r)); + let closingSeen = false; + channel1.addEventListener('closing', t.unreached_func()); + channel2.addEventListener('closing', () => { + assert_equals(channel2.readyState, 'closing'); + closingSeen = true; + }); + channel2.addEventListener('error', t.unreached_func()); + channel1.close(); + await haveClosed; + assert_equals(channel2.readyState, 'closed'); + assert_true(closingSeen, 'Closing event was seen'); + }, `Close ${mode} causes closing and close event to be called`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const [channel1, channel2] = await createDataChannelPair(t, options, pc1); + const events = []; + let error = null; + channel2.addEventListener('error', t.step_func(event => { + events.push('error'); + assert_true(event instanceof RTCErrorEvent); + error = event.error; + })); + const haveClosed = new Promise(r => channel2.addEventListener('close', () => { + events.push('close'); + r(); + })); + pc1.close(); + await haveClosed; + // Error should fire before close. + assert_array_equals(events, ['error', 'close']); + assert_true(error instanceof RTCError); + assert_equals(error.name, 'OperationError'); + assert_equals(error.errorDetail, 'sctp-failure'); + // Expects the sctpErrorCode is either null or 12 (User-Initiated Abort) as it is + // optional in the SCTP specification. + assert_in_array(error.sctpCauseCode, [null, 12]); + }, `Close peerconnection causes close event and error to be called on ${mode}`); + + promise_test(async t => { + let pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + let [channel1, channel2] = await createDataChannelPair(t, options, pc1); + // The expected sequence of events when closing a DC is that + // channel1 goes to closing, channel2 fires onclose, and when + // the close is confirmed, channel1 fires onclose. + // After that, no more events should fire. + channel1.onerror = t.unreached_func(); + let close2Handler = new Promise(resolve => { + channel2.onclose = event => { + resolve(); + }; + }); + let close1Handler = new Promise(resolve => { + channel1.onclose = event => { + resolve(); + }; + }); + channel1.close(); + await close2Handler; + await close1Handler; + channel1.onclose = t.unreached_func(); + channel2.onclose = t.unreached_func(); + channel2.onerror = t.unreached_func(); + pc1.close(); + await new Promise(resolve => t.step_timeout(resolve, 10)); + }, `Close peerconnection after ${mode} close causes no events`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc1.createDataChannel('not-counted', options); + const tokenDataChannel = new Promise(resolve => { + pc2.ondatachannel = resolve; + }); + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + if (!options.negotiated) { + await tokenDataChannel; + } + let closeExpectedCount = 0; + let errorExpectedCount = 0; + let resolveCountIsZero; + let waitForCountIsZero = new Promise(resolve => { + resolveCountIsZero = resolve; + }); + for (let i = 1; i <= 10; i++) { + if ('id' in options) { + options.id = i; + } + pc1.createDataChannel('', options); + if (options.negotiated) { + const channel = pc2.createDataChannel('', options); + channel.addEventListener('error', t.step_func(event => { + assert_true(event instanceof RTCErrorEvent, 'error event ' + event); + errorExpectedCount -= 1; + })); + channel.addEventListener('close', t.step_func(event => { + closeExpectedCount -= 1; + if (closeExpectedCount == 0) { + resolveCountIsZero(); + } + })); + } else { + await new Promise(resolve => { + pc2.ondatachannel = ({channel}) => { + channel.addEventListener('error', t.step_func(event => { + assert_true(event instanceof RTCErrorEvent); + errorExpectedCount -= 1; + })); + channel.addEventListener('close', t.step_func(event => { + closeExpectedCount -= 1; + if (closeExpectedCount == 0) { + resolveCountIsZero(); + } + })); + resolve(); + } + }); + } + ++closeExpectedCount; + ++errorExpectedCount; + } + assert_equals(closeExpectedCount, 10); + // We have to wait until SCTP is connected before we close, otherwise + // there will be no signal. + // The state is not available under Plan B, and unreliable on negotiated + // channels. + // TODO(bugs.webrtc.org/12259): Remove dependency on "negotiated" + if (pc1.sctp && !options.negotiated) { + waitForState(pc1.sctp, 'connected'); + } else { + // Under plan B, we don't have a dtls transport to wait on, so just + // wait a bit. + await new Promise(resolve => t.step_timeout(resolve, 100)); + } + pc1.close(); + await waitForCountIsZero; + assert_equals(closeExpectedCount, 0); + assert_equals(errorExpectedCount, 0); + }, `Close peerconnection causes close event and error on many channels, ${mode}`); +} +</script> diff --git a/testing/web-platform/tests/webrtc/RTCDataChannel-iceRestart.html b/testing/web-platform/tests/webrtc/RTCDataChannel-iceRestart.html new file mode 100644 index 0000000000..1aec50a587 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCDataChannel-iceRestart.html @@ -0,0 +1,76 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCDataChannel interactions with ICE restart</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +async function checkCanPassData(channel1, channel2) { + channel1.send('hello'); + const message = await awaitMessage(channel2); + assert_equals(message, 'hello'); +} + +async function pingPongData(channel1, channel2, size=1) { + channel1.send('hello'); + const request = await awaitMessage(channel2); + assert_equals(request, 'hello'); + const response = 'x'.repeat(size); + channel2.send(response); + const responseReceived = await awaitMessage(channel1); + assert_equals(response, responseReceived); +} + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const [channel1, channel2] = await createDataChannelPair(t, {}, pc1, pc2); + channel2.addEventListener('error', t.unreached_func()); + channel2.addEventListener('error', t.unreached_func()); + + await checkCanPassData(channel1, channel2); + await checkCanPassData(channel2, channel1); + + pc1.restartIce(); + await exchangeOfferAnswer(pc1, pc2); + + await checkCanPassData(channel1, channel2); + await checkCanPassData(channel2, channel1); + channel1.close(); + channel2.close(); +}, `Data channel remains usable after ICE restart`); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const [channel1, channel2] = await createDataChannelPair(t, {}, pc1, pc2); + channel2.addEventListener('error', t.unreached_func()); + channel2.addEventListener('error', t.unreached_func()); + + await pingPongData(channel1, channel2); + pc1.restartIce(); + + await pc1.setLocalDescription(); + await pingPongData(channel1, channel2); + await pc2.setRemoteDescription(pc1.localDescription); + await pingPongData(channel1, channel2); + await pc2.setLocalDescription(await pc2.createAnswer()); + await pingPongData(channel1, channel2); + await pc1.setRemoteDescription(pc2.localDescription); + await pingPongData(channel1, channel2); + channel1.close(); + channel2.close(); +}, `Data channel remains usable at each step of an ICE restart`); + + + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCDataChannel-id.html b/testing/web-platform/tests/webrtc/RTCDataChannel-id.html new file mode 100644 index 0000000000..10dc5eacb9 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCDataChannel-id.html @@ -0,0 +1,345 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCDataChannel id attribute</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +// Test is based on the following revision: +// https://rawgit.com/w3c/webrtc-pc/1cc5bfc3ff18741033d804c4a71f7891242fb5b3/webrtc.html + +// This is the maximum number of streams, NOT the maximum stream ID (which is 65534) +// See: https://tools.ietf.org/html/draft-ietf-rtcweb-data-channel-13#section-6.2 +const nStreams = 65535; + +/* + 6.1. + 21. If the [[DataChannelId]] slot is null (due to no ID being passed into + createDataChannel, or [[Negotiated]] being false), and the DTLS role of the SCTP + transport has already been negotiated, then initialize [[DataChannelId]] to a value + generated by the user agent, according to [RTCWEB-DATA-PROTOCOL] [...] + */ +promise_test(async (t) => { + const pc = new RTCPeerConnection; + t.add_cleanup(() => pc.close()); + + const dc1 = pc.createDataChannel(''); + const ids = new UniqueSet(); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + // Turn our own offer SDP into valid answer SDP by setting the DTLS role to + // "active". + const answer = { + type: 'answer', + sdp: pc.localDescription.sdp.replace('actpass', 'active') + }; + await pc.setRemoteDescription(answer); + + // Since the remote description had an 'active' DTLS role, we're the server + // and should use odd data channel IDs, according to rtcweb-data-channel. + assert_equals(dc1.id % 2, 1, + `Channel created by the DTLS server role must be odd (was ${dc1.id})`); + const dc2 = pc.createDataChannel('another'); + assert_equals(dc2.id % 2, 1, + `Channel created by the DTLS server role must be odd (was ${dc2.id})`); + + // Ensure IDs are unique + ids.add(dc1.id, `Channel ID ${dc1.id} should be unique`); + ids.add(dc2.id, `Channel ID ${dc2.id} should be unique`); +}, 'DTLS client uses odd data channel IDs'); + +promise_test(async (t) => { + const pc = new RTCPeerConnection; + t.add_cleanup(() => pc.close()); + + const dc1 = pc.createDataChannel(''); + const ids = new UniqueSet(); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + // Turn our own offer SDP into valid answer SDP by setting the DTLS role to + // 'passive'. + const answer = { + type: 'answer', + sdp: pc.localDescription.sdp.replace('actpass', 'passive') + }; + await pc.setRemoteDescription(answer); + + // Since the remote description had a 'passive' DTLS role, we're the client + // and should use even data channel IDs, according to rtcweb-data-channel. + assert_equals(dc1.id % 2, 0, + `Channel created by the DTLS client role must be even (was ${dc1.id})`); + const dc2 = pc.createDataChannel('another'); + assert_equals(dc2.id % 2, 0, + `Channel created by the DTLS client role must be even (was ${dc1.id})`); + + // Ensure IDs are unique + ids.add(dc1.id, `Channel ID ${dc1.id} should be unique`); + ids.add(dc2.id, `Channel ID ${dc2.id} should be unique`); +}, 'DTLS server uses even data channel IDs'); + +/* + Checks that the id is ignored if "negotiated" is false. + See section 6.1, createDataChannel step 13. + */ +promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const dc1 = pc1.createDataChannel('', { + negotiated: false, + id: 42 + }); + dc1.onopen = t.step_func(() => { + dc1.send(':('); + }); + + const dc2 = pc2.createDataChannel('', { + negotiated: false, + id: 42 + }); + // ID should be null prior to negotiation. + assert_equals(dc1.id, null); + assert_equals(dc2.id, null); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + // We should now have 2 datachannels with different IDs. + // At least one of the datachannels should not be 42. + // If one has the value 42, it's an accident; if both have, + // they are the same datachannel, and it's a bug. + assert_false(dc1.id == 42 && dc2.id == 42); +}, 'In-band negotiation with a specific ID should not work'); + +/* + Check if the implementation still follows the odd/even role correctly if we annoy it with + negotiated channels not following that rule. + + Note: This test assumes that the implementation can handle a minimum of 40 data channels. + */ +promise_test(async (t) => { + // Takes the DTLS server role + const pc1 = new RTCPeerConnection(); + // Takes the DTLS client role + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + exchangeIceCandidates(pc1, pc2); + const dcs = []; + const negotiatedDcs = []; + const ids = new UniqueSet(); + + // Create 10 DCEP-negotiated channels with pc1 + // Note: These should not have any associated valid ID at this point + for (let i = 0; i < 10; ++i) { + const dc = pc1.createDataChannel('before-connection'); + assert_equals(dc.id, null, 'Channel id must be null before DTLS role has been determined'); + dcs.push(dc); + } + + // Create 10 negotiated channels with pc1 violating the odd/even rule + for (let id = 0; id < 20; id += 2) { + const dc = pc1.createDataChannel(`negotiated-not-odd-${id}-before-connection`, { + negotiated: true, + id: id, + }); + assert_equals(dc.id, id, 'Channel id must be set before DTLS role has been determined when negotiated is true'); + negotiatedDcs.push([dc, id]); + ids.add(dc.id, `Channel ID ${dc.id} should be unique`); + } + + await exchangeOfferAnswer(pc1, pc2, { + offer: (offer) => { + // Ensure pc1 takes the server role + assert_true(offer.sdp.includes('actpass') || offer.sdp.includes('passive'), + 'pc1 must take the DTLS server role'); + return offer; + }, + answer: (answer) => { + // Ensure pc2 takes the client role + // Note: It very likely will choose 'active' itself + answer.sdp = answer.sdp.replace('actpass', 'active'); + assert_true(answer.sdp.includes('active'), 'pc2 must take the DTLS client role'); + return answer; + }, + }); + + for (const dc of dcs) { + assert_equals(dc.id % 2, 1, + `Channel created by the DTLS server role must be odd (was ${dc.id})`); + ids.add(dc.id, `Channel ID ${dc.id} should be unique`); + } + + // Create 10 channels with pc1 + for (let i = 0; i < 10; ++i) { + const dc = pc1.createDataChannel('after-connection'); + assert_equals(dc.id % 2, 1, + `Channel created by the DTLS server role must be odd (was ${dc.id})`); + dcs.push(dc); + ids.add(dc.id, `Channel ID ${dc.id} should be unique`); + } + + // Create 10 negotiated channels with pc1 violating the odd/even rule + for (let i = 0; i < 10; ++i) { + // Generate a valid even ID that has not been taken, yet. + let id = 20; + while (ids.has(id)) { + id += 2; + } + const dc = pc1.createDataChannel(`negotiated-not-odd-${i}-after-connection`, { + negotiated: true, + id: id, + }); + negotiatedDcs.push([dc, id]); + ids.add(dc.id, `Channel ID ${dc.id} should be unique`); + } + + // Since we've added new channels, let's check again that the odd/even role is not violated + for (const dc of dcs) { + assert_equals(dc.id % 2, 1, + `Channel created by the DTLS server role must be odd (was ${dc.id})`); + } + + // Let's also make sure the negotiated channels have kept their ID + for (const [dc, id] of negotiatedDcs) { + assert_equals(dc.id, id, 'Negotiated channels should keep their assigned ID'); + } +}, 'Odd/even role should not be violated when mixing with negotiated channels'); + +/* + Create 32768 (client), 32767 (server) channels to make sure all ids are exhausted AFTER + establishing a peer connection. + + 6.1. createDataChannel + 21. If the [[DataChannelId]] slot is null (due to no ID being passed into + createDataChannel, or [[Negotiated]] being false), and the DTLS role of the SCTP + transport has already been negotiated, then initialize [[DataChannelId]] to a value + generated by the user agent, according to [RTCWEB-DATA-PROTOCOL], and skip + to the next step. If no available ID could be generated, or if the value of the + [[DataChannelId]] slot is being used by an existing RTCDataChannel, throw an + OperationError exception. + */ +/* + TODO: Improve test coverage for RTCSctpTransport.maxChannels. + TODO: Improve test coverage for exhausting channel cases. + */ + +/* + Create 32768 (client), 32767 (server) channels to make sure all ids are exhausted BEFORE + establishing a peer connection. + + Be aware that late channel id assignment can currently fail in many places not covered by the + spec, see: https://github.com/w3c/webrtc-pc/issues/1818 + + 4.4.1.6. + 2.2.6. If description negotiates the DTLS role of the SCTP transport, and there is an + RTCDataChannel with a null id, then generate an ID according to [RTCWEB-DATA-PROTOCOL]. + If no available ID could be generated, then run the following steps: + 1. Let channel be the RTCDataChannel object for which an ID could not be generated. + 2. Set channel's [[ReadyState]] slot to "closed". + 3. Fire an event named error with an OperationError exception at channel. + 4. Fire a simple event named close at channel. + */ +/* TEST DISABLED - it takes so long, it times out. +promise_test(async (t) => { + const resolver = new Resolver(); + // Takes the DTLS server role + const pc1 = new RTCPeerConnection(); + // Takes the DTLS client role + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + exchangeIceCandidates(pc1, pc2); + const dcs = []; + const ids = new UniqueSet(); + let nExpected = 0; + let nActualCloses = 0; + let nActualErrors = 0; + + const maybeDone = t.step_func(() => { + if (nExpected === nActualCloses && nExpected === nActualErrors) { + resolver.resolve(); + } + }); + + // Create 65535+2 channels (since 65535 streams is a SHOULD, we may have less than that.) + // Create two extra channels to possibly trigger the steps in the description. + // + // Note: Following the spec strictly would assume that this cannot fail. But in reality it will + // fail because the implementation knows how many streams it supports. What it doesn't + // know is how many streams the other peer supports (e.g. what will be negotiated). + for (let i = 0; i < (nStreams + 2); ++i) { + let dc; + try { + const pc = i % 2 === 1 ? pc1 : pc2; + dc = pc.createDataChannel('this is going to be fun'); + dc.onclose = t.step_func(() => { + ++nActualCloses; + maybeDone(); + }); + dc.onerror = t.step_func((e) => { + assert_true(e instanceof RTCError, 'Expect error object to be instance of RTCError'); + assert_equals(e.error, 'sctp-failure', "Expect error to be of type 'sctp-failure'"); + ++nActualErrors; + maybeDone(); + }); + } catch (e) { + assert_equals(e.name, 'OperationError', 'Fail on creation should throw OperationError'); + break; + } + assert_equals(dc.id, null, 'Channel id must be null before DTLS role has been determined'); + assert_not_equals(dc.readyState, 'closed', + 'Channel may not be closed before connection establishment'); + dcs.push([dc, i % 2 === 1]); + } + + await exchangeOfferAnswer(pc1, pc2, { + offer: (offer) => { + // Ensure pc1 takes the server role + assert_true(offer.sdp.includes('actpass') || offer.sdp.includes('passive'), + 'pc1 must take the DTLS server role'); + return offer; + }, + answer: (answer) => { + // Ensure pc2 takes the client role + // Note: It very likely will choose 'active' itself + answer.sdp = answer.sdp.replace('actpass', 'active'); + assert_true(answer.sdp.includes('active'), 'pc2 must take the DTLS client role'); + return answer; + }, + }); + + // Since the spec does not define a specific order to which channels may fail if an ID could + // not be generated, any of the channels may be affected by the steps of the description. + for (const [dc, odd] of dcs) { + if (dc.readyState !== 'closed') { + assert_equals(dc.id % 2, odd ? 1 : 0, + `Channels created by the DTLS ${odd ? 'server' : 'client'} role must be + ${odd ? 'odd' : 'even'} (was ${dc.id})`); + ids.add(dc.id, `Channel ID ${dc.id} should be unique`); + } else { + ++nExpected; + } + } + + // Try creating one further channel on both sides. The attempt should fail since all IDs are + // taken. If one ID is available, the implementation probably miscounts (or I did in the test). + assert_throws_dom('OperationError', () => + pc1.createDataChannel('this is too exhausting!')); + assert_throws_dom('OperationError', () => + pc2.createDataChannel('this is too exhausting!')); + + maybeDone(); + await resolver; +}, 'Channel ID exhaustion handling (before and after connection establishment)'); + +END DISABLED TEST */ + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCDataChannel-send-blob-order.html b/testing/web-platform/tests/webrtc/RTCDataChannel-send-blob-order.html new file mode 100644 index 0000000000..3fcf116bc8 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCDataChannel-send-blob-order.html @@ -0,0 +1,31 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCDataChannel.prototype.send for blobs</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + +for (const options of [{}, {negotiated: true, id: 0}]) { + const mode = `${options.negotiated? "Negotiated d" : "D"}atachannel`; + + promise_test(async t => { + const data1 = new Blob(['blob']); + const data1Size = data1.size; + const data2 = new ArrayBuffer(8); + const data2Size = data2.byteLength; + + const [channel1, channel2] = await createDataChannelPair(t, options); + channel2.binaryType = "arraybuffer"; + + channel1.send(data1); + channel1.send(data2); + + let e = await new Promise(r => channel2.onmessage = r); + assert_equals(e.data.byteLength, data1Size); + + e = await new Promise(r => channel2.onmessage = r); + assert_equals(e.data.byteLength, data2Size); + }, `${mode} should send data following the order of the send call`); +} +</script> diff --git a/testing/web-platform/tests/webrtc/RTCDataChannel-send.html b/testing/web-platform/tests/webrtc/RTCDataChannel-send.html new file mode 100644 index 0000000000..70cdf8657f --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCDataChannel-send.html @@ -0,0 +1,336 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCDataChannel.prototype.send</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +// Test is based on the following editor draft: +// https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + +// The following helper functions are called from RTCPeerConnection-helper.js: +// createDataChannelPair +// awaitMessage +// blobToArrayBuffer +// assert_equals_typed_array + +/* + 6.2. RTCDataChannel + interface RTCDataChannel : EventTarget { + ... + readonly attribute RTCDataChannelState readyState; + readonly attribute unsigned long bufferedAmount; + attribute EventHandler onmessage; + attribute DOMString binaryType; + + void send(USVString data); + void send(Blob data); + void send(ArrayBuffer data); + void send(ArrayBufferView data); + }; + */ + +// Simple ASCII encoded string +const helloString = 'hello'; +const emptyString = ''; +// ASCII encoded buffer representation of the string +const helloBuffer = Uint8Array.of(0x68, 0x65, 0x6c, 0x6c, 0x6f); +const emptyBuffer = new Uint8Array(); +const helloBlob = new Blob([helloBuffer]); + +// Unicode string with multiple code units +const unicodeString = 'äļįä― åĨ―'; +// UTF-8 encoded buffer representation of the string +const unicodeBuffer = Uint8Array.of( + 0xe4, 0xb8, 0x96, 0xe7, 0x95, 0x8c, + 0xe4, 0xbd, 0xa0, 0xe5, 0xa5, 0xbd); + +/* + 6.2. send() + 2. If channel's readyState attribute is connecting, throw an InvalidStateError. + */ +test(t => { + const pc = new RTCPeerConnection(); + const channel = pc.createDataChannel('test'); + assert_equals(channel.readyState, 'connecting'); + assert_throws_dom('InvalidStateError', () => channel.send(helloString)); +}, 'Calling send() when data channel is in connecting state should throw InvalidStateError'); + +for (const options of [{}, {negotiated: true, id: 0}]) { + const mode = `${options.negotiated? "Negotiated d" : "D"}atachannel`; + + /* + 6.2. send() + 3. Execute the sub step that corresponds to the type of the methods argument: + + string object + Let data be the object and increase the bufferedAmount attribute + by the number of bytes needed to express data as UTF-8. + + [WebSocket] + 5. Feedback from the protocol + When a WebSocket message has been received + 4. If type indicates that the data is Text, then initialize event's data + attribute to data. + */ + promise_test(t => { + return createDataChannelPair(t, options) + .then(([channel1, channel2]) => { + channel1.send(helloString); + return awaitMessage(channel2) + }).then(message => { + assert_equals(typeof message, 'string', + 'Expect message to be a string'); + + assert_equals(message, helloString); + }); + }, `${mode} should be able to send simple string and receive as string`); + + promise_test(t => { + return createDataChannelPair(t, options) + .then(([channel1, channel2]) => { + channel1.send(unicodeString); + return awaitMessage(channel2) + }).then(message => { + assert_equals(typeof message, 'string', + 'Expect message to be a string'); + + assert_equals(message, unicodeString); + }); + }, `${mode} should be able to send unicode string and receive as unicode string`); + promise_test(t => { + return createDataChannelPair(t, options) + .then(([channel1, channel2]) => { + channel2.binaryType = 'arraybuffer'; + channel1.send(helloString); + return awaitMessage(channel2); + }).then(message => { + assert_equals(typeof message, 'string', + 'Expect message to be a string'); + + assert_equals(message, helloString); + }); + }, `${mode} should ignore binaryType and always receive string message as string`); + promise_test(t => { + return createDataChannelPair(t, options) + .then(([channel1, channel2]) => { + channel1.send(emptyString); + // Send a non-empty string in case the implementation ignores empty messages + channel1.send(helloString); + return awaitMessage(channel2) + }).then(message => { + assert_equals(typeof message, 'string', + 'Expect message to be a string'); + + assert_equals(message, emptyString); + }); + }, `${mode} should be able to send an empty string and receive an empty string`); + + /* + 6.2. send() + 3. Execute the sub step that corresponds to the type of the methods argument: + ArrayBufferView object + Let data be the data stored in the section of the buffer described + by the ArrayBuffer object that the ArrayBufferView object references + and increase the bufferedAmount attribute by the length of the + ArrayBufferView in bytes. + + [WebSocket] + 5. Feedback from the protocol + When a WebSocket message has been received + 4. If binaryType is set to "arraybuffer", then initialize event's data + attribute to a new read-only ArrayBuffer object whose contents are data. + + [WebIDL] + 4.1. ArrayBufferView + typedef (Int8Array or Int16Array or Int32Array or + Uint8Array or Uint16Array or Uint32Array or Uint8ClampedArray or + Float32Array or Float64Array or DataView) ArrayBufferView; + */ + promise_test(t => { + return createDataChannelPair(t, options) + .then(([channel1, channel2]) => { + channel2.binaryType = 'arraybuffer'; + channel1.send(helloBuffer); + return awaitMessage(channel2) + }).then(messageBuffer => { + assert_true(messageBuffer instanceof ArrayBuffer, + 'Expect messageBuffer to be an ArrayBuffer'); + + assert_equals_typed_array(messageBuffer, helloBuffer.buffer); + }); + }, `${mode} should be able to send Uint8Array message and receive as ArrayBuffer`); + + /* + 6.2. send() + 3. Execute the sub step that corresponds to the type of the methods argument: + ArrayBuffer object + Let data be the data stored in the buffer described by the ArrayBuffer + object and increase the bufferedAmount attribute by the length of the + ArrayBuffer in bytes. + */ + promise_test(t => { + return createDataChannelPair(t, options) + .then(([channel1, channel2]) => { + channel2.binaryType = 'arraybuffer'; + channel1.send(helloBuffer.buffer); + return awaitMessage(channel2) + }).then(messageBuffer => { + assert_true(messageBuffer instanceof ArrayBuffer, + 'Expect messageBuffer to be an ArrayBuffer'); + + assert_equals_typed_array(messageBuffer, helloBuffer.buffer); + }); + }, `${mode} should be able to send ArrayBuffer message and receive as ArrayBuffer`); + + promise_test(t => { + return createDataChannelPair(t, options) + .then(([channel1, channel2]) => { + channel2.binaryType = 'arraybuffer'; + channel1.send(emptyBuffer.buffer); + // Send a non-empty buffer in case the implementation ignores empty messages + channel1.send(helloBuffer.buffer); + return awaitMessage(channel2) + }).then(messageBuffer => { + assert_true(messageBuffer instanceof ArrayBuffer, + 'Expect messageBuffer to be an ArrayBuffer'); + + assert_equals_typed_array(messageBuffer, emptyBuffer.buffer); + }); + }, `${mode} should be able to send an empty ArrayBuffer message and receive as ArrayBuffer`); + + /* + 6.2. send() + 3. Execute the sub step that corresponds to the type of the methods argument: + Blob object + Let data be the raw data represented by the Blob object and increase + the bufferedAmount attribute by the size of data, in bytes. + */ + promise_test(t => { + return createDataChannelPair(t, options) + .then(([channel1, channel2]) => { + channel2.binaryType = 'arraybuffer'; + channel1.send(helloBlob); + return awaitMessage(channel2); + }).then(messageBuffer => { + assert_true(messageBuffer instanceof ArrayBuffer, + 'Expect messageBuffer to be an ArrayBuffer'); + + assert_equals_typed_array(messageBuffer, helloBuffer.buffer); + }); + }, `${mode} should be able to send Blob message and receive as ArrayBuffer`); + + /* + [WebSocket] + 5. Feedback from the protocol + When a WebSocket message has been received + 4. If binaryType is set to "blob", then initialize event's data attribute + to a new Blob object that represents data as its raw data. + */ + promise_test(t => { + return createDataChannelPair(t, options) + .then(([channel1, channel2]) => { + channel2.binaryType = 'blob'; + channel1.send(helloBuffer); + return awaitMessage(channel2); + }) + .then(messageBlob => { + assert_true(messageBlob instanceof Blob, + 'Expect received messageBlob to be a Blob'); + + return blobToArrayBuffer(messageBlob); + }).then(messageBuffer => { + assert_true(messageBuffer instanceof ArrayBuffer, + 'Expect messageBuffer to be an ArrayBuffer'); + + assert_equals_typed_array(messageBuffer, helloBuffer.buffer); + }); + }, `${mode} should be able to send ArrayBuffer message and receive as Blob`); + + /* + 6.2. RTCDataChannel + binaryType + The binaryType attribute must, on getting, return the value to which it was + last set. On setting, the user agent must set the IDL attribute to the new + value. When a RTCDataChannel object is created, the binaryType attribute must + be initialized to the string "blob". + */ + promise_test(t => { + return createDataChannelPair(t, options) + .then(([channel1, channel2]) => { + assert_equals(channel2.binaryType, 'blob', + 'Expect initial binaryType value to be blob'); + + channel1.send(helloBuffer); + return awaitMessage(channel2); + }) + .then(messageBlob => { + assert_true(messageBlob instanceof Blob, + 'Expect received messageBlob to be a Blob'); + + return blobToArrayBuffer(messageBlob); + }).then(messageBuffer => { + assert_true(messageBuffer instanceof ArrayBuffer, + 'Expect messageBuffer to be an ArrayBuffer'); + + assert_equals_typed_array(messageBuffer, helloBuffer.buffer); + }); + }, `${mode} binaryType should receive message as Blob by default`); + + // Test sending 3 messages: helloBuffer, unicodeString, helloBlob + async_test(t => { + const receivedMessages = []; + + const onMessage = t.step_func(event => { + const { data } = event; + receivedMessages.push(data); + + if(receivedMessages.length === 3) { + assert_equals_typed_array(receivedMessages[0], helloBuffer.buffer); + assert_equals(receivedMessages[1], unicodeString); + assert_equals_typed_array(receivedMessages[2], helloBuffer.buffer); + + t.done(); + } + }); + + createDataChannelPair(t, options) + .then(([channel1, channel2]) => { + channel2.binaryType = 'arraybuffer'; + channel2.addEventListener('message', onMessage); + + channel1.send(helloBuffer); + channel1.send(unicodeString); + channel1.send(helloBlob); + + }).catch(t.step_func(err => + assert_unreached(`Unexpected promise rejection: ${err}`))); + }, `${mode} sending multiple messages with different types should succeed and be received`); + + /* + [Deferred] + 6.2. RTCDataChannel + The send() method is being amended in w3c/webrtc-pc#1209 to throw error instead + of closing data channel when buffer is full + + send() + 4. If channel's underlying data transport is not established yet, or if the + closing procedure has started, then abort these steps. + 5. Attempt to send data on channel's underlying data transport; if the data + cannot be sent, e.g. because it would need to be buffered but the buffer + is full, the user agent must abruptly close channel's underlying data + transport with an error. + + test(t => { + const pc = new RTCPeerConnection(); + const channel = pc.createDataChannel('test'); + channel.close(); + assert_equals(channel.readyState, 'closing'); + channel.send(helloString); + }, 'Calling send() when data channel is in closing state should succeed'); + */ +} +</script> diff --git a/testing/web-platform/tests/webrtc/RTCDataChannelEvent-constructor.html b/testing/web-platform/tests/webrtc/RTCDataChannelEvent-constructor.html new file mode 100644 index 0000000000..265943ae56 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCDataChannelEvent-constructor.html @@ -0,0 +1,41 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>RTCDataChannelEvent constructor</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +// Test is based on the following revision: +// https://rawgit.com/w3c/webrtc-pc/1cc5bfc3ff18741033d804c4a71f7891242fb5b3/webrtc.html + +test(function() { + assert_equals(RTCDataChannelEvent.length, 2); + assert_throws_js( + TypeError, + function() { new RTCDataChannelEvent('type'); } + ); +}, 'RTCDataChannelEvent constructor without a required argument.'); + +test(function() { + assert_throws_js( + TypeError, + function() { new RTCDataChannelEvent('type', { channel: null }); } + ); +}, 'RTCDataChannelEvent constructor with channel passed as null.'); + +test(function() { + assert_throws_js( + TypeError, + function() { new RTCDataChannelEvent('type', { channel: undefined }); } + ); +}, 'RTCDataChannelEvent constructor with a channel passed as undefined.'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const dc = pc.createDataChannel(''); + const event = new RTCDataChannelEvent('type', { channel: dc }); + assert_true(event instanceof RTCDataChannelEvent); + assert_equals(event.channel, dc); +}, 'RTCDataChannelEvent constructor with full arguments.'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCDtlsTransport-getRemoteCertificates.html b/testing/web-platform/tests/webrtc/RTCDtlsTransport-getRemoteCertificates.html new file mode 100644 index 0000000000..899e603cbe --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCDtlsTransport-getRemoteCertificates.html @@ -0,0 +1,97 @@ +<!doctype html> +<meta charset="utf-8"> +<title>RTCDtlsTransport.prototype.getRemoteCertificates</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // The following helper functions are called from RTCPeerConnection-helper.js: + // exchangeIceCandidates + // exchangeOfferAnswer + + /* + 5.5. RTCDtlsTransport Interface + interface RTCDtlsTransport : EventTarget { + readonly attribute RTCDtlsTransportState state; + sequence<ArrayBuffer> getRemoteCertificates(); + attribute EventHandler onstatechange; + attribute EventHandler onerror; + ... + }; + + enum RTCDtlsTransportState { + "new", + "connecting", + "connected", + "closed", + "failed" + }; + + getRemoteCertificates + Returns the certificate chain in use by the remote side, with each certificate + encoded in binary Distinguished Encoding Rules (DER) [X690]. + getRemoteCertificates() will return an empty list prior to selection of the + remote certificate, which will be completed by the time RTCDtlsTransportState + transitions to "connected". + */ + async_test(t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTrack(trackFactories.audio()); + exchangeIceCandidates(pc1, pc2); + + exchangeOfferAnswer(pc1, pc2) + .then(t.step_func(() => { + const dtlsTransport1 = pc1.getSenders()[0].transport; + const dtlsTransport2 = pc2.getReceivers()[0].transport; + + const testedTransports = new Set(); + + // Callback function that test the respective DTLS transports + // when they become connected. + const onConnected = t.step_func(dtlsTransport => { + const certs = dtlsTransport.getRemoteCertificates(); + + assert_greater_than(certs.length, 0, + 'Expect DTLS transport to have at least one remote certificate when connected'); + + for(const cert of certs) { + assert_true(cert instanceof ArrayBuffer, + 'Expect certificate elements be instance of ArrayBuffer'); + } + + testedTransports.add(dtlsTransport); + + // End the test if both dtlsTransports are tested. + if(testedTransports.has(dtlsTransport1) && testedTransports.has(dtlsTransport2)) { + t.done(); + } + }) + + for(const dtlsTransport of [dtlsTransport1, dtlsTransport2]) { + if(dtlsTransport.state === 'connected') { + onConnected(dtlsTransport); + } else { + assert_array_equals(dtlsTransport.getRemoteCertificates(), [], + 'Expect DTLS certificates be initially empty until become connected'); + + dtlsTransport.addEventListener('statechange', t.step_func(() => { + if(dtlsTransport.state === 'connected') { + onConnected(dtlsTransport); + } + })); + + dtlsTransport.addEventListener('error', t.step_func(err => { + assert_unreached(`Unexpected error during DTLS handshake: ${err}`); + })); + } + } + })); + }); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCDtlsTransport-state.html b/testing/web-platform/tests/webrtc/RTCDtlsTransport-state.html new file mode 100644 index 0000000000..ca49fcc95f --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCDtlsTransport-state.html @@ -0,0 +1,142 @@ +<!doctype html> +<meta charset="utf-8"> +<title>RTCDtlsTransport</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +// The following helper functions are called from RTCPeerConnection-helper.js: +// exchangeIceCandidates +// exchangeOfferAnswer +// trackFactories.audio() + +/* + 5.5. RTCDtlsTransport Interface + interface RTCDtlsTransport : EventTarget { + readonly attribute RTCDtlsTransportState state; + sequence<ArrayBuffer> getRemoteCertificates(); + attribute EventHandler onstatechange; + attribute EventHandler onerror; + ... + }; + + enum RTCDtlsTransportState { + "new", + "connecting", + "connected", + "closed", + "failed" + }; + +*/ +function resolveWhen(t, dtlstransport, state) { + return new Promise((resolve, reject) => { + if (dtlstransport.state == state) { resolve(); } + dtlstransport.addEventListener('statechange', t.step_func(e => { + if (dtlstransport.state == state) { + resolve(); + } + })); + }); +} + + +async function setupConnections(t) { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTrack(trackFactories.audio()); + const channels = exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + return [pc1, pc2]; +} + +promise_test(async t => { + const [pc1, pc2] = await setupConnections(t); + const dtlsTransport1 = pc1.getTransceivers()[0].sender.transport; + const dtlsTransport2 = pc2.getTransceivers()[0].sender.transport; + assert_true(dtlsTransport1 instanceof RTCDtlsTransport); + assert_true(dtlsTransport2 instanceof RTCDtlsTransport); + await Promise.all([resolveWhen(t, dtlsTransport1, 'connected'), + resolveWhen(t, dtlsTransport2, 'connected')]); +}, 'DTLS transport goes to connected state'); + +promise_test(async t => { + const [pc1, pc2] = await setupConnections(t); + + const dtlsTransport1 = pc1.getTransceivers()[0].sender.transport; + const dtlsTransport2 = pc2.getTransceivers()[0].sender.transport; + await Promise.all([resolveWhen(t, dtlsTransport1, 'connected'), + resolveWhen(t, dtlsTransport2, 'connected')]); + pc1.close(); + assert_equals(dtlsTransport1.state, 'closed'); +}, 'close() causes the local transport to close immediately'); + +promise_test(async t => { + const [pc1, pc2] = await setupConnections(t); + const dtlsTransport1 = pc1.getTransceivers()[0].sender.transport; + const dtlsTransport2 = pc2.getTransceivers()[0].sender.transport; + await Promise.all([resolveWhen(t, dtlsTransport1, 'connected'), + resolveWhen(t, dtlsTransport2, 'connected')]); + pc1.close(); + await resolveWhen(t, dtlsTransport2, 'closed'); +}, 'close() causes the other end\'s DTLS transport to close'); + +promise_test(async t => { + const config = {bundlePolicy: "max-bundle"}; + const pc1 = new RTCPeerConnection(config); + const pc2 = new RTCPeerConnection(config); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.onicecandidate = e => pc2.addIceCandidate(e.candidate); + pc2.onicecandidate = e => pc1.addIceCandidate(e.candidate); + + pc1.addTransceiver("video"); + pc1.addTransceiver("audio"); + await pc1.setLocalDescription(await pc1.createOffer()); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(await pc2.createAnswer()); + await pc1.setRemoteDescription(pc2.localDescription); + + const [videoTc, audioTc] = pc1.getTransceivers(); + const [videoTp, audioTp] = + pc1.getTransceivers().map(tc => tc.sender.transport); + + const [videoPc2Tp, audioPc2Tp] = + pc2.getTransceivers().map(tc => tc.sender.transport); + + assert_equals(pc1.getTransceivers().length, 2, 'pc1 transceiver count'); + assert_equals(pc2.getTransceivers().length, 2, 'pc2 transceiver count'); + assert_equals(videoTc.sender.transport, videoTc.receiver.transport); + assert_equals(videoTc.sender.transport, audioTc.sender.transport); + + await Promise.all([resolveWhen(t, videoTp, 'connected'), + resolveWhen(t, videoPc2Tp, 'connected')]); + + assert_equals(audioTc.sender, pc1.getSenders()[1]); + + let stoppedTransceiver = pc1.getTransceivers()[0]; + assert_equals(stoppedTransceiver, videoTc); // sanity + let onended = new Promise(resolve => { + stoppedTransceiver.receiver.track.onended = resolve; + }); + stoppedTransceiver.stop(); + await onended; + + assert_equals( + pc1.getReceivers().length, 1, + 'getReceivers does not expose a receiver of a stopped transceiver'); + assert_equals( + pc1.getSenders().length, 1, + 'getSenders does not expose a sender of a stopped transceiver'); + assert_equals(audioTc.sender, pc1.getSenders()[0]); // sanity + assert_equals(audioTc.sender.transport, audioTp); // sanity + assert_equals(audioTp.state, 'connected'); +}, 'stop bundled transceiver retains dtls transport state'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCError.html b/testing/web-platform/tests/webrtc/RTCError.html new file mode 100644 index 0000000000..bcc5749bf7 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCError.html @@ -0,0 +1,89 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCError and RTCErrorInit</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +test(() => { + const error = new RTCError({errorDetail:'data-channel-failure'}, 'message'); + assert_equals(error.message, 'message'); + assert_equals(error.errorDetail, 'data-channel-failure'); +}, 'RTCError constructor with errorDetail and message'); + +test(() => { + const error = new RTCError({errorDetail:'data-channel-failure'}); + assert_equals(error.message, ''); +}, 'RTCError constructor\'s message argument is optional'); + +test(() => { + assert_throws_js(TypeError, () => { + new RTCError(); + }); + assert_throws_js(TypeError, () => { + new RTCError({}); // {errorDetail} is missing. + }); +}, 'RTCError constructor throws TypeError if arguments are missing'); + +test(() => { + assert_throws_js(TypeError, () => { + new RTCError({errorDetail:'invalid-error-detail'}, 'message'); + }); +}, 'RTCError constructor throws TypeError if the errorDetail is invalid'); + +test(() => { + const error = new RTCError({errorDetail:'data-channel-failure'}, 'message'); + assert_equals(error.name, 'OperationError'); +}, 'RTCError.name is \'OperationError\''); + +test(() => { + const error = new RTCError({errorDetail:'data-channel-failure'}, 'message'); + assert_equals(error.code, 0); +}, 'RTCError.code is 0'); + +test(() => { + const error = new RTCError({errorDetail:'data-channel-failure'}, 'message'); + assert_throws_js(TypeError, () => { + error.errorDetail = 'dtls-failure'; + }); +}, 'RTCError.errorDetail is readonly.'); + +test(() => { + // Infers what are valid RTCErrorInit objects by passing them to the RTCError + // constructor. + assert_throws_js(TypeError, () => { + new RTCError({}, 'message'); + }); + new RTCError({errorDetail:'data-channel-failure'}, 'message'); +}, 'RTCErrorInit.errorDetail is the only required attribute'); + +// All of these are number types (long or unsigned long). +const nullableAttributes = ['sdpLineNumber', + 'httpRequestStatusCode', + 'sctpCauseCode', + 'receivedAlert', + 'sentAlert']; + +nullableAttributes.forEach(attribute => { + test(() => { + const error = new RTCError({errorDetail:'data-channel-failure'}, 'message'); + assert_equals(error[attribute], null); + }, 'RTCError.' + attribute + ' is null by default'); + + test(() => { + const error = new RTCError( + {errorDetail:'data-channel-failure', [attribute]: 0}, 'message'); + assert_equals(error[attribute], 0); + }, 'RTCError.' + attribute + ' is settable by constructor'); + + test(() => { + const error = new RTCError({errorDetail:'data-channel-failure'}, 'message'); + assert_throws_js(TypeError, () => { + error[attribute] = 42; + }); + }, 'RTCError.' + attribute + ' is readonly'); +}); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCIceCandidate-constructor.html b/testing/web-platform/tests/webrtc/RTCIceCandidate-constructor.html new file mode 100644 index 0000000000..66d6962079 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCIceCandidate-constructor.html @@ -0,0 +1,234 @@ +<!doctype html> +<title>RTCIceCandidate constructor</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> + 'use strict'; + + const candidateString = 'candidate:1905690388 1 udp 2113937151 192.168.0.1 58041 typ host generation 0 ufrag thC8 network-cost 50'; + const candidateString2 = 'candidate:435653019 2 tcp 1845501695 192.168.0.196 4444 typ srflx raddr www.example.com rport 22222 tcptype active'; + const arbitraryString = '<arbitrary string[0] content>;'; + + test(t => { + // The argument for RTCIceCandidateInit is optional (w3c/webrtc-pc #1153 #1166), + // but the constructor throws because both sdpMid and sdpMLineIndex are null by default. + // Note that current browsers pass this test but may throw TypeError for + // different reason, i.e. they don't accept empty argument. + // Further tests below are used to differentiate the errors. + assert_throws_js(TypeError, () => new RTCIceCandidate()); + }, 'new RTCIceCandidate()'); + + test(t => { + // All fields in RTCIceCandidateInit are optional, + // but the constructor throws because both sdpMid and sdpMLineIndex are null by default. + // Note that current browsers pass this test but may throw TypeError for + // different reason, i.e. they don't allow undefined candidate string. + // Further tests below are used to differentiate the errors. + assert_throws_js(TypeError, () => new RTCIceCandidate({})); + }, 'new RTCIceCandidate({})'); + + test(t => { + // Checks that manually filling the default values for RTCIceCandidateInit + // still throws because both sdpMid and sdpMLineIndex are null + assert_throws_js(TypeError, + () => new RTCIceCandidate({ + candidate: '', + sdpMid: null, + sdpMLineIndex: null, + usernameFragment: undefined + })); + }, 'new RTCIceCandidate({ ... }) with manually filled default values'); + + test(t => { + // Checks that explicitly setting both sdpMid and sdpMLineIndex null should throw + assert_throws_js(TypeError, + () => new RTCIceCandidate({ + sdpMid: null, + sdpMLineIndex: null + })); + }, 'new RTCIceCandidate({ sdpMid: null, sdpMLineIndex: null })'); + + test(t => { + // Throws because both sdpMid and sdpMLineIndex are null by default + assert_throws_js(TypeError, + () => new RTCIceCandidate({ + candidate: '' + })); + }, `new RTCIceCandidate({ candidate: '' })`); + + test(t => { + // Throws because the candidate field is not nullable + assert_throws_js(TypeError, + () => new RTCIceCandidate({ + candidate: null + })); + }, `new RTCIceCandidate({ candidate: null })`); + + test(t => { + // Throws because both sdpMid and sdpMLineIndex are null by default + assert_throws_js(TypeError, + () => new RTCIceCandidate({ + candidate: candidateString + })); + }, 'new RTCIceCandidate({ ... }) with valid candidate string only'); + + test(t => { + const candidate = new RTCIceCandidate({ sdpMid: 'audio' }); + + assert_equals(candidate.candidate, '', 'candidate'); + assert_equals(candidate.sdpMid, 'audio', 'sdpMid'); + assert_equals(candidate.sdpMLineIndex, null, 'sdpMLineIndex'); + assert_equals(candidate.usernameFragment, null, 'usernameFragment'); + }, `new RTCIceCandidate({ sdpMid: 'audio' })`); + + test(t => { + const candidate = new RTCIceCandidate({ sdpMLineIndex: 0 }); + + assert_equals(candidate.candidate, '', 'candidate'); + assert_equals(candidate.sdpMid, null, 'sdpMid'); + assert_equals(candidate.sdpMLineIndex, 0, 'sdpMLineIndex'); + assert_equals(candidate.usernameFragment, null, 'usernameFragment'); + }, 'new RTCIceCandidate({ sdpMLineIndex: 0 })'); + + test(t => { + const candidate = new RTCIceCandidate({ + sdpMid: 'audio', + sdpMLineIndex: 0 + }); + + assert_equals(candidate.candidate, '', 'candidate'); + assert_equals(candidate.sdpMid, 'audio', 'sdpMid'); + assert_equals(candidate.sdpMLineIndex, 0, 'sdpMLineIndex'); + assert_equals(candidate.usernameFragment, null, 'usernameFragment'); + }, `new RTCIceCandidate({ sdpMid: 'audio', sdpMLineIndex: 0 })`); + + test(t => { + const candidate = new RTCIceCandidate({ + candidate: '', + sdpMid: 'audio' + }); + + assert_equals(candidate.candidate, '', 'candidate'); + assert_equals(candidate.sdpMid, 'audio', 'sdpMid'); + assert_equals(candidate.sdpMLineIndex, null, 'sdpMLineIndex'); + assert_equals(candidate.usernameFragment, null, 'usernameFragment'); + }, `new RTCIceCandidate({ candidate: '', sdpMid: 'audio' }`); + + test(t => { + const candidate = new RTCIceCandidate({ + candidate: '', + sdpMLineIndex: 0 + }); + + assert_equals(candidate.candidate, '', 'candidate'); + assert_equals(candidate.sdpMid, null, 'sdpMid', 'sdpMid'); + assert_equals(candidate.sdpMLineIndex, 0, 'sdpMLineIndex'); + assert_equals(candidate.usernameFragment, null, 'usernameFragment'); + }, `new RTCIceCandidate({ candidate: '', sdpMLineIndex: 0 }`); + + test(t => { + const candidate = new RTCIceCandidate({ + candidate: candidateString, + sdpMid: 'audio' + }); + + assert_equals(candidate.candidate, candidateString, 'candidate'); + assert_equals(candidate.sdpMid, 'audio', 'sdpMid'); + assert_equals(candidate.sdpMLineIndex, null, 'sdpMLineIndex'); + assert_equals(candidate.usernameFragment, null, 'usernameFragment'); + }, 'new RTCIceCandidate({ ... }) with valid candidate string and sdpMid'); + + test(t =>{ + // candidate string is not validated in RTCIceCandidate + const candidate = new RTCIceCandidate({ + candidate: arbitraryString, + sdpMid: 'audio' + }); + + assert_equals(candidate.candidate, arbitraryString, 'candidate'); + assert_equals(candidate.sdpMid, 'audio', 'sdpMid'); + assert_equals(candidate.sdpMLineIndex, null, 'sdpMLineIndex'); + assert_equals(candidate.usernameFragment, null, 'usernameFragment'); + }, 'new RTCIceCandidate({ ... }) with invalid candidate string and sdpMid'); + + test(t => { + const candidate = new RTCIceCandidate({ + candidate: candidateString, + sdpMid: 'video', + sdpMLineIndex: 1, + usernameFragment: 'test' + }); + + assert_equals(candidate.candidate, candidateString, 'candidate'); + assert_equals(candidate.sdpMid, 'video', 'sdpMid'); + assert_equals(candidate.sdpMLineIndex, 1, 'sdpMLineIndex'); + assert_equals(candidate.usernameFragment, 'test', 'usernameFragment'); + + // The following fields should match those in the candidate field + assert_equals(candidate.foundation, '1905690388', 'foundation'); + assert_equals(candidate.component, 'rtp', 'component'); + assert_equals(candidate.priority, 2113937151, 'priority'); + assert_equals(candidate.address, '192.168.0.1', 'address'); + assert_equals(candidate.protocol, 'udp', 'protocol'); + assert_equals(candidate.port, 58041, 'port'); + assert_equals(candidate.type, 'host', 'type'); + assert_equals(candidate.tcpType, null, 'tcpType'); + assert_equals(candidate.relatedAddress, null, 'relatedAddress'); + assert_equals(candidate.relatedPort, null, 'relatedPort'); + }, 'new RTCIceCandidate({ ... }) with nondefault values for all fields'); + + test(t => { + const candidate = new RTCIceCandidate({ + candidate: candidateString2, + sdpMid: 'video', + sdpMLineIndex: 1, + usernameFragment: 'user1' + }); + + assert_equals(candidate.candidate, candidateString2, 'candidate'); + assert_equals(candidate.sdpMid, 'video', 'sdpMid'); + assert_equals(candidate.sdpMLineIndex, 1, 'sdpMLineIndex'); + assert_equals(candidate.usernameFragment, 'user1', 'usernameFragment'); + + // The following fields should match those in the candidate field + assert_equals(candidate.foundation, '435653019', 'foundation'); + assert_equals(candidate.component, 'rtcp', 'component'); + assert_equals(candidate.priority, 1845501695, 'priority'); + assert_equals(candidate.address, '192.168.0.196', 'address'); + assert_equals(candidate.protocol, 'tcp', 'protocol'); + assert_equals(candidate.port, 4444, 'port'); + assert_equals(candidate.type, 'srflx', 'type'); + assert_equals(candidate.tcpType, 'active', 'tcpType'); + assert_equals(candidate.relatedAddress, 'www.example.com', 'relatedAddress'); + assert_equals(candidate.relatedPort, 22222, 'relatedPort'); + }, 'new RTCIceCandidate({ ... }) with nondefault values for all fields, tcp candidate'); + + test(t => { + // sdpMid is not validated in RTCIceCandidate + const candidate = new RTCIceCandidate({ + sdpMid: arbitraryString + }); + + assert_equals(candidate.candidate, '', 'candidate'); + assert_equals(candidate.sdpMid, arbitraryString, 'sdpMid'); + assert_equals(candidate.sdpMLineIndex, null, 'sdpMLineIndex'); + assert_equals(candidate.usernameFragment, null, 'usernameFragment'); + }, 'new RTCIceCandidate({ ... }) with invalid sdpMid'); + + + test(t => { + // Some arbitrary large out of bound line index that practically + // do not reference any m= line in SDP. + // However sdpMLineIndex is not validated in RTCIceCandidate + // and it has no knowledge of the SDP it is associated with. + const candidate = new RTCIceCandidate({ + sdpMLineIndex: 65535 + }); + + assert_equals(candidate.candidate, '', 'candidate'); + assert_equals(candidate.sdpMid, null, 'sdpMid'); + assert_equals(candidate.sdpMLineIndex, 65535, 'sdpMLineIndex'); + assert_equals(candidate.usernameFragment, null, 'usernameFragment'); + }, 'new RTCIceCandidate({ ... }) with invalid sdpMLineIndex'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCIceConnectionState-candidate-pair.https.html b/testing/web-platform/tests/webrtc/RTCIceConnectionState-candidate-pair.https.html new file mode 100644 index 0000000000..3b2c253401 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCIceConnectionState-candidate-pair.https.html @@ -0,0 +1,33 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCIceConnectionState and RTCIceCandidatePair</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream = await getNoiseStream({audio:true}); + const [track] = stream.getTracks(); + caller.addTrack(track, stream); + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + await listenToIceConnected(caller); + + const report = await caller.getStats(); + let succeededPairFound = false; + report.forEach(stats => { + if (stats.type == 'candidate-pair' && stats.state == 'succeeded') + succeededPairFound = true; + }); + assert_true(succeededPairFound, 'A succeeded candidate-pair should exist'); +}, 'On ICE connected, getStats() contains a connected candidate-pair'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCIceTransport.html b/testing/web-platform/tests/webrtc/RTCIceTransport.html new file mode 100644 index 0000000000..fe12c384e5 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCIceTransport.html @@ -0,0 +1,193 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCIceTransport</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // createDataChannelPair + // awaitMessage + + /* + 5.6. RTCIceTransport Interface + interface RTCIceTransport { + readonly attribute RTCIceRole role; + readonly attribute RTCIceComponent component; + readonly attribute RTCIceTransportState state; + readonly attribute RTCIceGathererState gatheringState; + sequence<RTCIceCandidate> getLocalCandidates(); + sequence<RTCIceCandidate> getRemoteCandidates(); + RTCIceCandidatePair? getSelectedCandidatePair(); + RTCIceParameters? getLocalParameters(); + RTCIceParameters? getRemoteParameters(); + ... + }; + + getLocalCandidates + Returns a sequence describing the local ICE candidates gathered for this + RTCIceTransport and sent in onicecandidate + + getRemoteCandidates + Returns a sequence describing the remote ICE candidates received by this + RTCIceTransport via addIceCandidate() + + getSelectedCandidatePair + Returns the selected candidate pair on which packets are sent, or null if + there is no such pair. + + getLocalParameters + Returns the local ICE parameters received by this RTCIceTransport via + setLocalDescription , or null if the parameters have not yet been received. + + getRemoteParameters + Returns the remote ICE parameters received by this RTCIceTransport via + setRemoteDescription or null if the parameters have not yet been received. + */ + function getIceTransportFromSctp(pc) { + const sctpTransport = pc.sctp; + assert_true(sctpTransport instanceof RTCSctpTransport, + 'Expect pc.sctp to be instantiated from RTCSctpTransport'); + + const dtlsTransport = sctpTransport.transport; + assert_true(dtlsTransport instanceof RTCDtlsTransport, + 'Expect sctp.transport to be an RTCDtlsTransport'); + + const iceTransport = dtlsTransport.iceTransport; + assert_true(iceTransport instanceof RTCIceTransport, + 'Expect dtlsTransport.transport to be an RTCIceTransport'); + + return iceTransport; + } + + function validateCandidates(candidates) { + assert_greater_than(candidates.length, 0, + 'Expect at least one ICE candidate returned from get*Candidates()'); + + for(const candidate of candidates) { + assert_true(candidate instanceof RTCIceCandidate, + 'Expect candidate elements to be instance of RTCIceCandidate'); + } + } + + function validateCandidateParameter(param) { + assert_not_equals(param, null, + 'Expect candidate parameter to be non-null after data channels are connected'); + + assert_equals(typeof param.usernameFragment, 'string', + 'Expect param.usernameFragment to be set with string value'); + assert_equals(typeof param.password, 'string', + 'Expect param.password to be set with string value'); + } + + function validateConnectedIceTransport(iceTransport) { + const { state, gatheringState, role, component } = iceTransport; + + assert_true(role === 'controlling' || role === 'controlled', + 'Expect RTCIceRole to be either controlling or controlled, found ' + role); + + assert_true(component === 'rtp' || component === 'rtcp', + 'Expect RTCIceComponent to be either rtp or rtcp'); + + assert_true(state === 'connected' || state === 'completed', + 'Expect ICE transport to be in connected or completed state after data channels are connected'); + + assert_true(gatheringState === 'gathering' || gatheringState === 'completed', + 'Expect ICE transport to be in gathering or completed gatheringState after data channels are connected'); + + validateCandidates(iceTransport.getLocalCandidates()); + validateCandidates(iceTransport.getRemoteCandidates()); + + const candidatePair = iceTransport.getSelectedCandidatePair(); + assert_not_equals(candidatePair, null, + 'Expect selected candidate pair to be non-null after ICE transport is connected'); + + assert_true(candidatePair.local instanceof RTCIceCandidate, + 'Expect candidatePair.local to be instance of RTCIceCandidate'); + + assert_true(candidatePair.remote instanceof RTCIceCandidate, + 'Expect candidatePair.remote to be instance of RTCIceCandidate'); + + validateCandidateParameter(iceTransport.getLocalParameters()); + validateCandidateParameter(iceTransport.getRemoteParameters()); + } + + promise_test(t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + return createDataChannelPair(t, {}, pc1, pc2) + .then(([channel1, channel2]) => { + // Send a ping message and wait for it just to make sure + // that the connection is fully working before testing + channel1.send('ping'); + return awaitMessage(channel2); + }) + .then(() => { + const iceTransport1 = getIceTransportFromSctp(pc1); + const iceTransport2 = getIceTransportFromSctp(pc2); + + validateConnectedIceTransport(iceTransport1); + validateConnectedIceTransport(iceTransport2); + + assert_equals( + iceTransport1.getLocalCandidates().length, + iceTransport2.getRemoteCandidates().length, + `Expect iceTransport1 to have same number of local candidate as iceTransport2's remote candidates`); + + assert_equals( + iceTransport1.getRemoteCandidates().length, + iceTransport2.getLocalCandidates().length, + `Expect iceTransport1 to have same number of remote candidate as iceTransport2's local candidates`); + + const candidatePair1 = iceTransport1.getSelectedCandidatePair(); + const candidatePair2 = iceTransport2.getSelectedCandidatePair(); + + assert_equals(candidatePair1.local.candidate, candidatePair2.remote.candidate, + 'Expect selected local candidate of one pc is the selected remote candidate or another'); + + assert_equals(candidatePair1.remote.candidate, candidatePair2.local.candidate, + 'Expect selected local candidate of one pc is the selected remote candidate or another'); + + assert_equals(iceTransport1.role, 'controlling', + `Expect offerer's iceTransport to take the controlling role`); + + assert_equals(iceTransport2.role, 'controlled', + `Expect answerer's iceTransport to take the controlled role`); + }); + }, 'Two connected iceTransports should has matching local/remote candidates returned'); + + promise_test(t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc1.createDataChannel(''); + + // setRemoteDescription(answer) without the other peer + // setting answer it's localDescription + return pc1.createOffer() + .then(offer => + pc1.setLocalDescription(offer) + .then(() => pc2.setRemoteDescription(offer)) + .then(() => pc2.createAnswer())) + .then(answer => pc1.setRemoteDescription(answer)) + .then(() => { + const iceTransport = getIceTransportFromSctp(pc1); + + assert_array_equals(iceTransport.getRemoteCandidates(), [], + 'Expect iceTransport to not have any remote candidate'); + + assert_equals(iceTransport.getSelectedCandidatePair(), null, + 'Expect selectedCandidatePair to be null'); + }); + }, 'Unconnected iceTransport should have empty remote candidates and selected pair'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-GC.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-GC.https.html new file mode 100644 index 0000000000..76ae3087e4 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-GC.https.html @@ -0,0 +1,92 @@ +<!doctype html> +<html> +<head> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/common/gc.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +</head> +<body> +<script> +'use strict'; + +// Check that RTCPeerConnection is not collected by GC while displaying video. + +promise_test(async t => { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 160; + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "blue"; + const drawCanvas = () => { + ctx.fillRect(0, 0, canvas.width, canvas.height); + }; + + let pc1 = new RTCPeerConnection(); + let pc2 = new RTCPeerConnection(); + + // Attach video to pc1. + const [inputTrack] = canvas.captureStream().getTracks(); + pc1.addTrack(inputTrack); + + const destVideo = document.createElement('video'); + destVideo.autoplay = true; + const onVideoChange = async () => { + const start = performance.now(); + const width = destVideo.videoWidth; + const height = destVideo.videoHeight; + const resizeEvent = new Promise(r => destVideo.onresize = r); + while (destVideo.videoWidth == width && destVideo.videoHeight == height) { + if (performance.now() - start > 5000) { + throw new Error("Timeout waiting for video size change"); + } + drawCanvas(); + await Promise.race([ + resizeEvent, + new Promise(r => requestAnimationFrame(r)), + ]); + } + }; + + // Setup cleanup. We cannot keep references to pc1 or pc2 so do a best-effort with GC. + t.add_cleanup(async () => { + inputTrack.stop(); + destVideo.srcObject = null; + await garbageCollect(); + }); + + // Setup pc1->pc2. + let haveTrackEvent = new Promise(r => pc2.ontrack = r); + exchangeIceCandidates(pc1, pc2); + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + + // Display pc2 received track in video element. + const loadedMetadata = new Promise(r => destVideo.onloadedmetadata = r); + destVideo.srcObject = new MediaStream([(await haveTrackEvent).track]); + + // Wait for video on the other side. + await onVideoChange(); + const color = getVideoSignal(destVideo); + assert_not_equals(color, 0); + + // Remove RTCPeerConnection references and garbage collect. + pc1 = null; + pc2 = null; + haveTrackEvent = null; + await garbageCollect(); + + // Check that a change to video input is reflected in the output, i.e., the + // peer connections were not garbage collected. + canvas.width = canvas.height = 240; + ctx.fillStyle = "red"; + await onVideoChange(); + assert_not_equals(color, getVideoSignal(destVideo)); + }, "GC does not collect a peer connection pipe rendering to a video element"); +</script> +</body> +</html> + diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-SLD-SRD-timing.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-SLD-SRD-timing.https.html new file mode 100644 index 0000000000..36bde06c96 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-SLD-SRD-timing.https.html @@ -0,0 +1,24 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title></title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +'use strict'; + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const signalingStates = []; + pc.onsignalingstatechange = ev => signalingStates.push(pc.signalingState); + pc.addTransceiver('audio', {direction:'recvonly'}); + const offer = await pc.createOffer(); + const sldPromise = pc.setLocalDescription(offer); + const srdPromise = pc.setRemoteDescription(offer); + await Promise.all([sldPromise, srdPromise]); + assert_array_equals(signalingStates, + ['have-local-offer','stable','have-remote-offer']); +}, 'setLocalDescription and setRemoteDescription are not racy'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-add-track-no-deadlock.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-add-track-no-deadlock.https.html new file mode 100644 index 0000000000..81e3b73643 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-add-track-no-deadlock.https.html @@ -0,0 +1,31 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection addTrack does not deadlock</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // This test sets up two peer connections using a sequence of operations + // that triggered a deadlock in Chrome. See https://crbug.com/736725. + // If a deadlock is introduced again, this test times out. + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const stream = await getNoiseStream( + {audio: false, video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const videoTrack = stream.getVideoTracks()[0]; + pc1.addTrack(videoTrack, stream); + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + const srdPromise = pc2.setRemoteDescription(offer); + pc2.addTrack(videoTrack, stream); + // The deadlock encountered in https://crbug.com/736725 occured here. + await srdPromise; + await pc2.createAnswer(); + }, 'RTCPeerConnection addTrack does not deadlock.'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-addIceCandidate-connectionSetup.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-addIceCandidate-connectionSetup.html new file mode 100644 index 0000000000..cedc2ca8f0 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-addIceCandidate-connectionSetup.html @@ -0,0 +1,92 @@ +<!doctype html> +<meta name="timeout" content="long"> +<title>Test RTCPeerConnection.prototype.addIceCandidate</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + +// This test may be flaky, so it's in its own file. +// The test belongs in RTCPeerConnection-addIceCandidate. + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const transceiver = pc1.addTransceiver('video'); + + exchangeIceCandidates(pc1, pc2); + await exchangeOffer(pc1, pc2); + const answer = await pc2.createAnswer(); + // Note that sequence of the following two calls is critical + // for test stability. + await pc1.setRemoteDescription(answer); + await pc2.setLocalDescription(answer); + await waitForState(transceiver.sender.transport, 'connected'); +}, 'Candidates are added dynamically; connection should work'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const transceiver = pc1.addTransceiver('video'); + + let candidates1to2 = []; + let candidates2to1 = []; + pc1.onicecandidate = e => candidates1to2.push(e.candidate); + pc2.onicecandidate = e => candidates2to1.push(e.candidate); + const pc2GatheredCandidates = new Promise((resolve) => { + pc2.addEventListener('icegatheringstatechange', () => { + if (pc2.iceGatheringState == 'complete') { + resolve(); + } + }); + }); + await exchangeOffer(pc1, pc2); + let answer = await pc2.createAnswer(); + await pc1.setRemoteDescription(answer); + await pc2.setLocalDescription(answer); + await pc2GatheredCandidates; + // Add candidates to pc1, ensuring that it goes to "connecting" state before "connected". + // We do not iterate/await because repeatedly awaiting while we serially add + // the candidates opens the opportunity to miss the 'connecting' transition. + const addCandidatesDone = Promise.all(candidates2to1.map(c => pc1.addIceCandidate(c))); + await waitForState(transceiver.sender.transport, 'connecting'); + await addCandidatesDone; + await waitForState(transceiver.sender.transport, 'connected'); +}, 'Candidates are added at PC1; connection should work'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const transceiver = pc1.addTransceiver('video'); + + let candidates1to2 = []; + let candidates2to1 = []; + pc1.onicecandidate = e => candidates1to2.push(e.candidate); + pc2.onicecandidate = e => candidates2to1.push(e.candidate); + const pc1GatheredCandidates = new Promise((resolve) => { + pc1.addEventListener('icegatheringstatechange', () => { + if (pc1.iceGatheringState == 'complete') { + resolve(); + } + }); + }); + await exchangeOffer(pc1, pc2); + let answer = await pc2.createAnswer(); + await pc1.setRemoteDescription(answer); + await pc2.setLocalDescription(answer); + await pc1GatheredCandidates; + // Add candidates to pc2 + // We do not iterate/await because repeatedly awaiting while we serially add + // the candidates opens the opportunity to miss the ICE state transitions. + await Promise.all(candidates1to2.map(c => pc2.addIceCandidate(c))); + await waitForState(transceiver.sender.transport, 'connected'); +}, 'Candidates are added at PC2; connection should work'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-addIceCandidate-timing.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-addIceCandidate-timing.https.html new file mode 100644 index 0000000000..9793844f56 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-addIceCandidate-timing.https.html @@ -0,0 +1,149 @@ +<!doctype html> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> + +'use strict'; + +// In this test, the promises should resolve in the execution order +// (setLocalDescription, setLocalDescription, addIceCandidate) as is ensured by +// the Operations Chain; if an operation is pending, executing another operation +// will queue it. This test will fail if an Operations Chain is not implemented, +// but it gives the implementation some slack: it only ensures that +// addIceCandidate() is not resolved first, allowing timing issues in resolving +// promises where the test still passes even if addIceCandidate() is resolved +// *before* the second setLocalDescription(). +// +// This test covers Chrome issue (https://crbug.com/1019222), but does not +// require setLocalDescription-promises to resolve immediately which is another +// Chrome bug (https://crbug.com/1019232). The true order is covered by the next +// test. +// TODO(https://crbug.com/1019232): Delete this test when the next test passes +// in Chrome. +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + caller.addTransceiver('audio'); + + const candidatePromise = new Promise(resolve => { + caller.onicecandidate = e => resolve(e.candidate); + }); + await caller.setLocalDescription(await caller.createOffer()); + await callee.setRemoteDescription(caller.localDescription); + const candidate = await candidatePromise; + + // Chain setLocalDescription(), setLocalDescription() and addIceCandidate() + // without performing await between the calls. + const pendingPromises = []; + const resolveOrder = []; + pendingPromises.push(callee.setLocalDescription().then(() => { + resolveOrder.push('setLocalDescription 1'); + })); + pendingPromises.push(callee.setLocalDescription().then(() => { + resolveOrder.push('setLocalDescription 2'); + })); + pendingPromises.push(callee.addIceCandidate(candidate).then(() => { + resolveOrder.push('addIceCandidate'); + })); + await Promise.all(pendingPromises); + + assert_equals(resolveOrder[0], 'setLocalDescription 1'); +}, 'addIceCandidate is not resolved first if 2x setLocalDescription ' + + 'operations are pending'); + +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + caller.addTransceiver('audio'); + + const candidatePromise = new Promise(resolve => { + caller.onicecandidate = e => resolve(e.candidate); + }); + await caller.setLocalDescription(await caller.createOffer()); + await callee.setRemoteDescription(caller.localDescription); + const candidate = await candidatePromise; + + // Chain setLocalDescription(), setLocalDescription() and addIceCandidate() + // without performing await between the calls. + const pendingPromises = []; + const resolveOrder = []; + pendingPromises.push(callee.setLocalDescription().then(() => { + resolveOrder.push('setLocalDescription 1'); + })); + pendingPromises.push(callee.setLocalDescription().then(() => { + resolveOrder.push('setLocalDescription 2'); + })); + pendingPromises.push(callee.addIceCandidate(candidate).then(() => { + resolveOrder.push('addIceCandidate'); + })); + await Promise.all(pendingPromises); + + // This test verifies that both issues described in https://crbug.com/1019222 + // and https://crbug.com/1019232 are fixed. If this test passes in Chrome, the + // ICE candidate exchange issues described in + // https://github.com/web-platform-tests/wpt/issues/19866 should be resolved. + assert_array_equals( + resolveOrder, + ['setLocalDescription 1', 'setLocalDescription 2', 'addIceCandidate']); +}, 'addIceCandidate and setLocalDescription are resolved in the correct ' + + 'order, as defined by the operations chain specification'); + +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + caller.addTransceiver('audio'); + let events = []; + let pendingPromises = []; + + const onCandidatePromise = new Promise(resolve => { + caller.onicecandidate = () => { + events.push('candidate generated'); + resolve(); + } + }); + pendingPromises.push(onCandidatePromise); + pendingPromises.push(caller.setLocalDescription().then(() => { + events.push('setLocalDescription'); + })); + await Promise.all(pendingPromises); + assert_array_equals(events, ['setLocalDescription', 'candidate generated']); +}, 'onicecandidate fires after resolving setLocalDescription in offerer'); + +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + caller.addTransceiver('audio'); + let events = []; + let pendingPromises = []; + + caller.onicecandidate = (ev) => { + if (ev.candidate) { + callee.addIceCandidate(ev.candidate); + } + } + const offer = await caller.createOffer(); + const onCandidatePromise = new Promise(resolve => { + callee.onicecandidate = () => { + events.push('candidate generated'); + resolve(); + } + }); + await callee.setRemoteDescription(offer); + const answer = await callee.createAnswer(); + pendingPromises.push(onCandidatePromise); + pendingPromises.push(callee.setLocalDescription(answer).then(() => { + events.push('setLocalDescription'); + })); + await Promise.all(pendingPromises); + assert_array_equals(events, ['setLocalDescription', 'candidate generated']); +}, 'onicecandidate fires after resolving setLocalDescription in answerer'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-addIceCandidate.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-addIceCandidate.html new file mode 100644 index 0000000000..d8e24d608b --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-addIceCandidate.html @@ -0,0 +1,631 @@ +<!doctype html> +<title>Test RTCPeerConnection.prototype.addIceCandidate</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // SDP copied from JSEP Example 7.1 + // It contains two media streams with different ufrags + // to test if candidate is added to the correct stream + const sdp = `v=0 +o=- 4962303333179871722 1 IN IP4 0.0.0.0 +s=- +t=0 0 +a=ice-options:trickle +a=group:BUNDLE a1 v1 +a=group:LS a1 v1 +m=audio 10100 UDP/TLS/RTP/SAVPF 96 0 8 97 98 +c=IN IP4 203.0.113.100 +a=mid:a1 +a=sendrecv +a=rtpmap:96 opus/48000/2 +a=rtpmap:0 PCMU/8000 +a=rtpmap:8 PCMA/8000 +a=rtpmap:97 telephone-event/8000 +a=rtpmap:98 telephone-event/48000 +a=maxptime:120 +a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid +a=extmap:2 urn:ietf:params:rtp-hdrext:ssrc-audio-level +a=msid:47017fee-b6c1-4162-929c-a25110252400 f83006c5-a0ff-4e0a-9ed9-d3e6747be7d9 +a=ice-ufrag:ETEn +a=ice-pwd:OtSK0WpNtpUjkY4+86js7ZQl +a=fingerprint:sha-256 19:E2:1C:3B:4B:9F:81:E6:B8:5C:F4:A5:A8:D8:73:04:BB:05:2F:70:9F:04:A9:0E:05:E9:26:33:E8:70:88:A2 +a=setup:actpass +a=dtls-id:1 +a=rtcp:10101 IN IP4 203.0.113.100 +a=rtcp-mux +a=rtcp-rsize +m=video 10102 UDP/TLS/RTP/SAVPF 100 101 +c=IN IP4 203.0.113.100 +a=mid:v1 +a=sendrecv +a=rtpmap:100 VP8/90000 +a=rtpmap:101 rtx/90000 +a=fmtp:101 apt=100 +a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid +a=rtcp-fb:100 ccm fir +a=rtcp-fb:100 nack +a=rtcp-fb:100 nack pli +a=msid:47017fee-b6c1-4162-929c-a25110252400 f30bdb4a-5db8-49b5-bcdc-e0c9a23172e0 +a=ice-ufrag:BGKk +a=ice-pwd:mqyWsAjvtKwTGnvhPztQ9mIf +a=fingerprint:sha-256 19:E2:1C:3B:4B:9F:81:E6:B8:5C:F4:A5:A8:D8:73:04:BB:05:2F:70:9F:04:A9:0E:05:E9:26:33:E8:70:88:A2 +a=setup:actpass +a=dtls-id:1 +a=rtcp:10103 IN IP4 203.0.113.100 +a=rtcp-mux +a=rtcp-rsize +`; + + const sessionDesc = { type: 'offer', sdp }; + + // valid candidate attributes + const sdpMid1 = 'a1'; + const sdpMLineIndex1 = 0; + const usernameFragment1 = 'ETEn'; + + const sdpMid2 = 'v1'; + const sdpMLineIndex2 = 1; + const usernameFragment2 = 'BGKk'; + + const mediaLine1 = 'm=audio'; + const mediaLine2 = 'm=video'; + + const candidateStr1 = 'candidate:1 1 udp 2113929471 203.0.113.100 10100 typ host'; + const candidateStr2 = 'candidate:1 2 udp 2113929470 203.0.113.100 10101 typ host'; + const invalidCandidateStr = '(Invalid) candidate \r\n string'; + + const candidateLine1 = `a=${candidateStr1}`; + const candidateLine2 = `a=${candidateStr2}`; + const endOfCandidateLine = 'a=end-of-candidates'; + + // Copied from MDN + function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + function is_candidate_line_between(sdp, beforeMediaLine, candidateLine, afterMediaLine) { + const line1 = escapeRegExp(beforeMediaLine); + const line2 = escapeRegExp(candidateLine); + const line3 = escapeRegExp(afterMediaLine); + + const regex = new RegExp(`${line1}[^]+${line2}[^]+${line3}`); + return regex.test(sdp); + } + + // Check that a candidate line is found after the first media line + // but before the second, i.e. it belongs to the first media stream + function assert_candidate_line_between(sdp, beforeMediaLine, candidateLine, afterMediaLine) { + assert_true(is_candidate_line_between(sdp, beforeMediaLine, candidateLine, afterMediaLine), + `Expect candidate line to be found between media lines ${beforeMediaLine} and ${afterMediaLine}`); + } + + // Check that a candidate line is found after the second media line + // i.e. it belongs to the second media stream + function is_candidate_line_after(sdp, beforeMediaLine, candidateLine) { + const line1 = escapeRegExp(beforeMediaLine); + const line2 = escapeRegExp(candidateLine); + + const regex = new RegExp(`${line1}[^]+${line2}`); + + return regex.test(sdp); + } + + function assert_candidate_line_after(sdp, beforeMediaLine, candidateLine) { + assert_true(is_candidate_line_after(sdp, beforeMediaLine, candidateLine), + `Expect candidate line to be found after media line ${beforeMediaLine}`); + } + + /* + 4.4.2. addIceCandidate + 4. Return the result of enqueuing the following steps: + 1. If remoteDescription is null return a promise rejected with a + newly created InvalidStateError. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return promise_rejects_dom(t, 'InvalidStateError', + pc.addIceCandidate({ + candidate: candidateStr1, + sdpMid: sdpMid1, + sdpMLineIndex: sdpMLineIndex1, + usernameFragment: usernameFragment1 + })); + }, 'Add ICE candidate before setting remote description should reject with InvalidStateError'); + + /* + Success cases + */ + + // All of these should work, because all of these end up being equivalent to the + // same thing; an end-of-candidates signal for all levels/mids/ufrags. + [ + // This is just the default. Everything else here is equivalent to this. + { + candidate: '', + sdpMid: null, + sdpMLineIndex: null, + usernameFragment: undefined + }, + // The arg is optional, so when passing undefined we'll just get the default + undefined, + // The arg is optional, but not nullable, so we get the default again + null, + // Members in the dictionary take their default values + {} + ].forEach(init => { + promise_test(async t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + await pc.setRemoteDescription(sessionDesc); + await pc.addIceCandidate(init); + }, `addIceCandidate(${JSON.stringify(init)}) works`); + promise_test(async t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + await pc.setRemoteDescription(sessionDesc); + await pc.addIceCandidate(init); + assert_candidate_line_between(pc.remoteDescription.sdp, + mediaLine1, endOfCandidateLine, mediaLine2); + assert_candidate_line_after(pc.remoteDescription.sdp, + mediaLine2, endOfCandidateLine); + }, `addIceCandidate(${JSON.stringify(init)}) adds a=end-of-candidates to both m-sections`); + }); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + await pc.setRemoteDescription(sessionDesc); + await pc.setLocalDescription(await pc.createAnswer()); + await pc.addIceCandidate({}); + assert_candidate_line_between(pc.remoteDescription.sdp, + mediaLine1, endOfCandidateLine, mediaLine2); + assert_candidate_line_after(pc.remoteDescription.sdp, + mediaLine2, endOfCandidateLine); + }, 'addIceCandidate({}) in stable should work, and add a=end-of-candidates to both m-sections'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + await pc.setRemoteDescription(sessionDesc); + await pc.addIceCandidate({ + usernameFragment: usernameFragment1, + sdpMid: sdpMid1 + }); + assert_candidate_line_between(pc.remoteDescription.sdp, + mediaLine1, endOfCandidateLine, mediaLine2); + assert_false(is_candidate_line_after(pc.remoteDescription.sdp, + mediaLine2, endOfCandidateLine)); + }, 'addIceCandidate({usernameFragment: usernameFragment1, sdpMid: sdpMid1}) should work, and add a=end-of-candidates to the first m-section'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + await pc.setRemoteDescription(sessionDesc); + await pc.addIceCandidate({ + usernameFragment: usernameFragment2, + sdpMLineIndex: 1 + }); + assert_false(is_candidate_line_between(pc.remoteDescription.sdp, + mediaLine1, endOfCandidateLine, mediaLine2)); + assert_true(is_candidate_line_after(pc.remoteDescription.sdp, + mediaLine2, endOfCandidateLine)); + }, 'addIceCandidate({usernameFragment: usernameFragment2, sdpMLineIndex: 1}) should work, and add a=end-of-candidates to the first m-section'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + await pc.setRemoteDescription(sessionDesc); + await promise_rejects_dom(t, 'OperationError', + pc.addIceCandidate({usernameFragment: "no such ufrag"})); + }, 'addIceCandidate({usernameFragment: "no such ufrag"}) should not work'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + await pc.setRemoteDescription(sessionDesc) + await pc.addIceCandidate({ + candidate: candidateStr1, + sdpMid: sdpMid1, + sdpMLineIndex: sdpMLineIndex1, + usernameFragement: usernameFragment1 + }); + assert_candidate_line_after(pc.remoteDescription.sdp, + mediaLine1, candidateStr1); + }, 'Add ICE candidate after setting remote description should succeed'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => pc.addIceCandidate(new RTCIceCandidate({ + candidate: candidateStr1, + sdpMid: sdpMid1, + sdpMLineIndex: sdpMLineIndex1, + usernameFragement: usernameFragment1 + }))); + }, 'Add ICE candidate with RTCIceCandidate should succeed'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + return pc.setRemoteDescription(sessionDesc) + .then(() => pc.addIceCandidate({ + candidate: candidateStr1, + sdpMid: sdpMid1 })); + }, 'Add candidate with only valid sdpMid should succeed'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => pc.addIceCandidate(new RTCIceCandidate({ + candidate: candidateStr1, + sdpMid: sdpMid1 }))); + }, 'Add candidate with only valid sdpMid and RTCIceCandidate should succeed'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => pc.addIceCandidate({ + candidate: candidateStr1, + sdpMLineIndex: sdpMLineIndex1 })); + }, 'Add candidate with only valid sdpMLineIndex should succeed'); + + /* + 4.4.2. addIceCandidate + 4.6.2. If candidate is applied successfully, the user agent MUST queue + a task that runs the following steps: + 2. If connection.pendingRemoteDescription is non-null, and represents + the ICE generation for which candidate was processed, add + candidate to connection.pendingRemoteDescription. + 3. If connection.currentRemoteDescription is non-null, and represents + the ICE generation for which candidate was processed, add + candidate to connection.currentRemoteDescription. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => pc.addIceCandidate({ + candidate: candidateStr1, + sdpMid: sdpMid1, + sdpMLineIndex: sdpMLineIndex1, + usernameFragement: usernameFragment1 + })) + .then(() => { + assert_candidate_line_between(pc.remoteDescription.sdp, + mediaLine1, candidateLine1, mediaLine2); + }); + }, 'addIceCandidate with first sdpMid and sdpMLineIndex add candidate to first media stream'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => pc.addIceCandidate({ + candidate: candidateStr2, + sdpMid: sdpMid2, + sdpMLineIndex: sdpMLineIndex2, + usernameFragment: usernameFragment2 + })) + .then(() => { + assert_candidate_line_after(pc.remoteDescription.sdp, + mediaLine2, candidateLine2); + }); + }, 'addIceCandidate with second sdpMid and sdpMLineIndex should add candidate to second media stream'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => pc.addIceCandidate({ + candidate: candidateStr1, + sdpMid: sdpMid1, + sdpMLineIndex: sdpMLineIndex1, + usernameFragment: null + })) + .then(() => { + assert_candidate_line_between(pc.remoteDescription.sdp, + mediaLine1, candidateLine1, mediaLine2); + }); + }, 'Add candidate for first media stream with null usernameFragment should add candidate to first media stream'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => pc.addIceCandidate({ + candidate: candidateStr1, + sdpMid: sdpMid1, + sdpMLineIndex: sdpMLineIndex1, + usernameFragement: usernameFragment1 + })) + .then(() => pc.addIceCandidate({ + candidate: candidateStr2, + sdpMid: sdpMid2, + sdpMLineIndex: sdpMLineIndex2, + usernameFragment: usernameFragment2 + })) + .then(() => { + assert_candidate_line_between(pc.remoteDescription.sdp, + mediaLine1, candidateLine1, mediaLine2); + + assert_candidate_line_after(pc.remoteDescription.sdp, + mediaLine2, candidateLine2); + }); + }, 'Adding multiple candidates should add candidates to their corresponding media stream'); + + /* + 4.4.2. addIceCandidate + 4.6. If candidate.candidate is an empty string, process candidate as an + end-of-candidates indication for the corresponding media description + and ICE candidate generation. + 2. If candidate is applied successfully, the user agent MUST queue + a task that runs the following steps: + 2. If connection.pendingRemoteDescription is non-null, and represents + the ICE generation for which candidate was processed, add + candidate to connection.pendingRemoteDescription. + 3. If connection.currentRemoteDescription is non-null, and represents + the ICE generation for which candidate was processed, add + candidate to connection.currentRemoteDescription. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => pc.addIceCandidate({ + candidate: candidateStr1, + sdpMid: sdpMid1, + sdpMLineIndex: sdpMLineIndex1, + usernameFragement: usernameFragment1 + })) + .then(() => pc.addIceCandidate({ + candidate: '', + sdpMid: sdpMid1, + sdpMLineIndex: sdpMLineIndex1, + usernameFragement: usernameFragment1 + })) + .then(() => { + assert_candidate_line_between(pc.remoteDescription.sdp, + mediaLine1, candidateLine1, mediaLine2); + + assert_candidate_line_between(pc.remoteDescription.sdp, + mediaLine1, endOfCandidateLine, mediaLine2); + }); + }, 'Add with empty candidate string (end of candidates) should succeed'); + + /* + 4.4.2. addIceCandidate + 3. If both sdpMid and sdpMLineIndex are null, return a promise rejected + with a newly created TypeError. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => + promise_rejects_js(t, TypeError, + pc.addIceCandidate({ + candidate: candidateStr1, + sdpMid: null, + sdpMLineIndex: null + }))); + }, 'Add candidate with both sdpMid and sdpMLineIndex manually set to null should reject with TypeError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + await pc.setRemoteDescription(sessionDesc); + promise_rejects_js(t, TypeError, + pc.addIceCandidate({candidate: candidateStr1})); + }, 'addIceCandidate with a candidate and neither sdpMid nor sdpMLineIndex should reject with TypeError'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => + promise_rejects_js(t, TypeError, + pc.addIceCandidate({ + candidate: candidateStr1 + }))); + }, 'Add candidate with only valid candidate string should reject with TypeError'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => + promise_rejects_js(t, TypeError, + pc.addIceCandidate({ + candidate: invalidCandidateStr, + sdpMid: null, + sdpMLineIndex: null + }))); + }, 'Add candidate with invalid candidate string and both sdpMid and sdpMLineIndex null should reject with TypeError'); + + /* + 4.4.2. addIceCandidate + 4.3. If candidate.sdpMid is not null, run the following steps: + 1. If candidate.sdpMid is not equal to the mid of any media + description in remoteDescription , reject p with a newly + created OperationError and abort these steps. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => + promise_rejects_dom(t, 'OperationError', + pc.addIceCandidate({ + candidate: candidateStr1, + sdpMid: 'invalid', + sdpMLineIndex: sdpMLineIndex1, + usernameFragement: usernameFragment1 + }))); + }, 'Add candidate with invalid sdpMid should reject with OperationError'); + + /* + 4.4.2. addIceCandidate + 4.4. Else, if candidate.sdpMLineIndex is not null, run the following + steps: + 1. If candidate.sdpMLineIndex is equal to or larger than the + number of media descriptions in remoteDescription , reject p + with a newly created OperationError and abort these steps. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => + promise_rejects_dom(t, 'OperationError', + pc.addIceCandidate({ + candidate: candidateStr1, + sdpMLineIndex: 2, + usernameFragement: usernameFragment1 + }))); + }, 'Add candidate with invalid sdpMLineIndex should reject with OperationError'); + + // There is an "Else" for the statement: + // "Else, if candidate.sdpMLineIndex is not null, ..." + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => pc.addIceCandidate({ + candidate: candidateStr1, + sdpMid: sdpMid1, + sdpMLineIndex: 2, + usernameFragement: usernameFragment1 + })); + }, 'Invalid sdpMLineIndex should be ignored if valid sdpMid is provided'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => pc.addIceCandidate({ + candidate: candidateStr2, + sdpMid: sdpMid2, + sdpMLineIndex: sdpMLineIndex2, + usernameFragment: null + })) + .then(() => { + assert_candidate_line_after(pc.remoteDescription.sdp, + mediaLine2, candidateLine2); + }); + }, 'Add candidate for media stream 2 with null usernameFragment should succeed'); + + /* + 4.3.2. addIceCandidate + 4.5. If candidate.usernameFragment is neither undefined nor null, and is not equal + to any usernameFragment present in the corresponding media description of an + applied remote description, reject p with a newly created + OperationError and abort these steps. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => + promise_rejects_dom(t, 'OperationError', + pc.addIceCandidate({ + candidate: candidateStr1, + sdpMid: sdpMid1, + sdpMLineIndex: sdpMLineIndex1, + usernameFragment: 'invalid' + }))); + }, 'Add candidate with invalid usernameFragment should reject with OperationError'); + + /* + 4.4.2. addIceCandidate + 4.6.1. If candidate could not be successfully added the user agent MUST + queue a task that runs the following steps: + 2. Reject p with a DOMException object whose name attribute has + the value OperationError and abort these steps. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => + promise_rejects_dom(t, 'OperationError', + pc.addIceCandidate({ + candidate: invalidCandidateStr, + sdpMid: sdpMid1, + sdpMLineIndex: sdpMLineIndex1, + usernameFragement: usernameFragment1 + }))); + }, 'Add candidate with invalid candidate string should reject with OperationError'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(sessionDesc) + .then(() => + promise_rejects_dom(t, 'OperationError', + pc.addIceCandidate({ + candidate: candidateStr2, + sdpMid: sdpMid2, + sdpMLineIndex: sdpMLineIndex2, + usernameFragment: usernameFragment1 + }))); + }, 'Add candidate with sdpMid belonging to different usernameFragment should reject with OperationError'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-addTrack.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-addTrack.https.html new file mode 100644 index 0000000000..91665822c4 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-addTrack.https.html @@ -0,0 +1,394 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.addTrack</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // getNoiseStream() + + /* + 5.1. RTCPeerConnection Interface Extensions + partial interface RTCPeerConnection { + ... + sequence<RTCRtpSender> getSenders(); + sequence<RTCRtpReceiver> getReceivers(); + sequence<RTCRtpTransceiver> getTransceivers(); + RTCRtpSender addTrack(MediaStreamTrack track, + MediaStream... streams); + RTCRtpTransceiver addTransceiver((MediaStreamTrack or DOMString) trackOrKind, + optional RTCRtpTransceiverInit init); + }; + + Note + While addTrack checks if the MediaStreamTrack given as an argument is + already being sent to avoid sending the same MediaStreamTrack twice, + the other ways do not, allowing the same MediaStreamTrack to be sent + several times simultaneously. + */ + + /* + 5.1. addTrack + 4. If connection's [[isClosed]] slot is true, throw an InvalidStateError. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + pc.close(); + assert_throws_dom('InvalidStateError', () => pc.addTrack(track, stream)) + }, 'addTrack when pc is closed should throw InvalidStateError'); + + /* + 5.1. addTrack + 8. If sender is null, run the following steps: + 1. Create an RTCRtpSender with track and streams and let sender be + the result. + 2. Create an RTCRtpReceiver with track.kind as kind and let receiver + be the result. + 3. Create an RTCRtpTransceiver with sender and receiver and let + transceiver be the result. + 4. Add transceiver to connection's set of transceivers. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + const sender = pc.addTrack(track); + + assert_true(sender instanceof RTCRtpSender, + 'Expect sender to be instance of RTCRtpSender'); + + assert_equals(sender.track, track, + `Expect sender's track to be the added track`); + + const transceivers = pc.getTransceivers(); + assert_equals(transceivers.length, 1, + 'Expect only one transceiver with sender added'); + + const [transceiver] = transceivers; + assert_equals(transceiver.sender, sender); + + assert_array_equals([sender], pc.getSenders(), + 'Expect only one sender with given track added'); + + const { receiver } = transceiver; + assert_equals(receiver.track.kind, 'audio'); + assert_array_equals([transceiver.receiver], pc.getReceivers(), + 'Expect only one receiver associated with transceiver added'); + }, 'addTrack with single track argument and no stream should succeed'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + const sender = pc.addTrack(track, stream); + + assert_true(sender instanceof RTCRtpSender, + 'Expect sender to be instance of RTCRtpSender'); + + assert_equals(sender.track, track, + `Expect sender's track to be the added track`); + }, 'addTrack with single track argument and single stream should succeed'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + const stream2 = new MediaStream([track]); + const sender = pc.addTrack(track, stream, stream2); + + assert_true(sender instanceof RTCRtpSender, + 'Expect sender to be instance of RTCRtpSender'); + + assert_equals(sender.track, track, + `Expect sender's track to be the added track`); + }, 'addTrack with single track argument and multiple streams should succeed'); + + /* + 5.1. addTrack + 5. Let senders be the result of executing the CollectSenders algorithm. + If an RTCRtpSender for track already exists in senders, throw an + InvalidAccessError. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + pc.addTrack(track, stream); + assert_throws_dom('InvalidAccessError', () => pc.addTrack(track, stream)); + }, 'Adding the same track multiple times should throw InvalidAccessError'); + + /* + 5.1. addTrack + 6. The steps below describe how to determine if an existing sender can + be reused. + + If any RTCRtpSender object in senders matches all the following + criteria, let sender be that object, or null otherwise: + - The sender's track is null. + - The transceiver kind of the RTCRtpTransceiver, associated with + the sender, matches track's kind. + - The sender has never been used to send. More precisely, the + RTCRtpTransceiver associated with the sender has never had a + currentDirection of sendrecv or sendonly. + 7. If sender is not null, run the following steps to use that sender: + 1. Set sender.track to track. + 3. Enable sending direction on the RTCRtpTransceiver associated + with sender. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('audio', { direction: 'recvonly' }); + assert_equals(transceiver.sender.track, null); + assert_equals(transceiver.direction, 'recvonly'); + + await setMediaPermission("granted", ["microphone"]); + const stream = await navigator.mediaDevices.getUserMedia({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const sender = pc.addTrack(track); + + assert_equals(sender, transceiver.sender); + assert_equals(sender.track, track); + assert_equals(transceiver.direction, 'sendrecv'); + assert_array_equals([sender], pc.getSenders()); + }, 'addTrack with existing sender with null track, same kind, and recvonly direction should reuse sender'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('audio'); + assert_equals(transceiver.sender.track, null); + assert_equals(transceiver.direction, 'sendrecv'); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const sender = pc.addTrack(track); + + assert_equals(sender.track, track); + assert_equals(sender, transceiver.sender); + }, 'addTrack with existing sender that has not been used to send should reuse the sender'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const transceiver = caller.addTransceiver(track); + { + const offer = await caller.createOffer(); + await caller.setLocalDescription(offer); + await callee.setRemoteDescription(offer); + const answer = await callee.createAnswer(); + await callee.setLocalDescription(answer); + await caller.setRemoteDescription(answer); + } + assert_equals(transceiver.currentDirection, 'sendonly'); + + caller.removeTrack(transceiver.sender); + { + const offer = await caller.createOffer(); + await caller.setLocalDescription(offer); + await callee.setRemoteDescription(offer); + const answer = await callee.createAnswer(); + await callee.setLocalDescription(answer); + await caller.setRemoteDescription(answer); + } + assert_equals(transceiver.direction, 'recvonly'); + assert_equals(transceiver.currentDirection, 'inactive'); + + // |transceiver.sender| is currently not used for sending, but it should not + // be reused because it has been used for sending before. + const sender = caller.addTrack(track); + assert_true(sender != null); + assert_not_equals(sender, transceiver.sender); + }, 'addTrack with existing sender that has been used to send should create new sender'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('video', { direction: 'recvonly' }); + assert_equals(transceiver.sender.track, null); + assert_equals(transceiver.direction, 'recvonly'); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const sender = pc.addTrack(track); + + assert_equals(sender.track, track); + assert_not_equals(sender, transceiver.sender); + + const senders = pc.getSenders(); + assert_equals(senders.length, 2, + 'Expect 2 senders added to connection'); + + assert_true(senders.includes(sender), + 'Expect senders list to include sender'); + + assert_true(senders.includes(transceiver.sender), + `Expect senders list to include first transceiver's sender`); + }, 'addTrack with existing sender with null track, different kind, and recvonly direction should create new sender'); + + /* + TODO + 5.1. addTrack + 3. Let streams be a list of MediaStream objects constructed from the + method's remaining arguments, or an empty list if the method was + called with a single argument. + 6. The steps below describe how to determine if an existing sender can + be reused. Doing so will cause future calls to createOffer and + createAnswer to mark the corresponding media description as sendrecv + or sendonly and add the MSID of the track added, as defined in [JSEP] + (section 5.2.2. and section 5.3.2.). + + Non-Testable + 5.1. addTrack + 7. If sender is not null, run the following steps to use that sender: + 2. Set sender's [[associated MediaStreams]] to streams. + + Tested in RTCPeerConnection-onnegotiationneeded.html: + 5.1. addTrack + 10. Update the negotiation-needed flag for connection. + + */ + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const transceiver = caller.addTransceiver(track); + // Note that this test doesn't process canididates. + { + const offer = await caller.createOffer(); + await caller.setLocalDescription(offer); + await callee.setRemoteDescription(offer); + const answer = await callee.createAnswer(); + await callee.setLocalDescription(answer); + await caller.setRemoteDescription(answer); + } + assert_equals(transceiver.currentDirection, 'sendonly'); + await waitForIceGatheringState(caller, ['complete']); + await waitForIceGatheringState(callee, ['complete']); + + const second_stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => second_stream.getTracks().forEach(track => track.stop())); + // There may be callee candidates in flight. It seems that waiting + // for a createOffer() is enough time to let them complete processing. + // TODO(https://crbug.com/webrtc/13095): Fix bug and remove. + await caller.createOffer(); + + const [second_track] = second_stream.getTracks(); + caller.onicecandidate = t.unreached_func( + 'No caller candidates should be generated.'); + callee.onicecandidate = t.unreached_func( + 'No callee candidates should be generated.'); + caller.addTrack(second_track); + { + const offer = await caller.createOffer(); + await caller.setLocalDescription(offer); + await callee.setRemoteDescription(offer); + const answer = await callee.createAnswer(); + await callee.setLocalDescription(answer); + await caller.setRemoteDescription(answer); + } + // Check that we're bundled. + const [first_transceiver, second_transceiver] = caller.getTransceivers(); + assert_equals(first_transceiver.transport, second_transceiver.transport); + + }, 'Adding more tracks does not generate more candidates if bundled'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + pc1.addTrack(track); + const offer = await pc1.createOffer(); + // We do not await here; we want to ensure that the transceiver this creates + // is untouched by addTrack, and that addTrack creates _another_ transceiver + const srdPromise = pc2.setRemoteDescription(offer); + + const sender = pc2.addTrack(track); + + await srdPromise; + + assert_equals(pc2.getTransceivers().length, 1, "Should have 1 transceiver"); + assert_equals(pc2.getTransceivers()[0].sender, sender, "The transceiver should be the one added by addTrack"); + }, 'Calling addTrack while sRD(offer) is pending should allow the new remote transceiver to be the same one that addTrack creates'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver('video'); + + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + const offer = await pc1.createOffer(); + const srdPromise = pc2.setRemoteDescription(offer); + assert_equals(pc2.getTransceivers().length, 0); + pc2.addTrack(track); + assert_equals(pc2.getTransceivers().length, 1); + const transceiver0 = pc2.getTransceivers()[0]; + assert_equals(transceiver0.mid, null); + await srdPromise; + assert_equals(pc2.getTransceivers().length, 2); + const transceiver1 = pc2.getTransceivers()[1]; + assert_equals(transceiver0.mid, null); + assert_not_equals(transceiver1.mid, null); + }, 'When addTrack is called while sRD is in progress, and both addTrack and sRD add a transceiver of different media types, the addTrack transceiver should come first, and then the sRD transceiver.'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-addTransceiver.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-addTransceiver.https.html new file mode 100644 index 0000000000..3fd83a76fe --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-addTransceiver.https.html @@ -0,0 +1,441 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.addTransceiver</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://rawgit.com/w3c/webrtc-pc/cc8d80f455b86c8041d63bceb8b457f45c72aa89/webrtc.html + + /* + 5.1. RTCPeerConnection Interface Extensions + + partial interface RTCPeerConnection { + sequence<RTCRtpSender> getSenders(); + sequence<RTCRtpReceiver> getReceivers(); + sequence<RTCRtpTransceiver> getTransceivers(); + RTCRtpTransceiver addTransceiver((MediaStreamTrack or DOMString) trackOrKind, + optional RTCRtpTransceiverInit init); + ... + }; + + dictionary RTCRtpTransceiverInit { + RTCRtpTransceiverDirection direction = "sendrecv"; + sequence<MediaStream> streams; + sequence<RTCRtpEncodingParameters> sendEncodings; + }; + + enum RTCRtpTransceiverDirection { + "sendrecv", + "sendonly", + "recvonly", + "inactive" + }; + + 5.2. RTCRtpSender Interface + + interface RTCRtpSender { + readonly attribute MediaStreamTrack? track; + ... + }; + + 5.3. RTCRtpReceiver Interface + + interface RTCRtpReceiver { + readonly attribute MediaStreamTrack track; + ... + }; + + 5.4. RTCRtpTransceiver Interface + + interface RTCRtpTransceiver { + readonly attribute DOMString? mid; + [SameObject] + readonly attribute RTCRtpSender sender; + [SameObject] + readonly attribute RTCRtpReceiver receiver; + readonly attribute boolean stopped; + readonly attribute RTCRtpTransceiverDirection direction; + readonly attribute RTCRtpTransceiverDirection? currentDirection; + ... + }; + + Note + While addTrack checks if the MediaStreamTrack given as an argument is + already being sent to avoid sending the same MediaStreamTrack twice, + the other ways do not, allowing the same MediaStreamTrack to be sent + several times simultaneously. + */ + + /* + 5.1. addTransceiver + 3. If the first argument is a string, let it be kind and run the following steps: + 1. If kind is not a legal MediaStreamTrack kind, throw a TypeError. + */ + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_idl_attribute(pc, 'addTransceiver'); + assert_throws_js(TypeError, () => pc.addTransceiver('invalid')); + }, 'addTransceiver() with string argument as invalid kind should throw TypeError'); + + /* + 5.1. addTransceiver + The initial value of mid is null. + + 3. If the dictionary argument is present, let direction be the value of the + direction member. Otherwise let direction be sendrecv. + 4. If the first argument is a string, let it be kind and run the following steps: + 2. Let track be null. + 8. Create an RTCRtpSender with track, streams and sendEncodings and let + sender be the result. + 9. Create an RTCRtpReceiver with kind and let receiver be the result. + 10. Create an RTCRtpTransceiver with sender, receiver and direction, and let + transceiver be the result. + 11. Add transceiver to connection's set of transceivers. + + 5.2. RTCRtpSender Interface + Create an RTCRtpSender + 2. Set sender.track to track. + + 5.3. RTCRtpReceiver Interface + Create an RTCRtpReceiver + 2. Let track be a new MediaStreamTrack object [GETUSERMEDIA]. The source of + track is a remote source provided by receiver. + 3. Initialize track.kind to kind. + 5. Initialize track.label to the result of concatenating the string "remote " + with kind. + 6. Initialize track.readyState to live. + 7. Initialize track.muted to true. + 8. Set receiver.track to track. + + 5.4. RTCRtpTransceiver Interface + Create an RTCRtpTransceiver + 2. Set transceiver.sender to sender. + 3. Set transceiver.receiver to receiver. + 4. Let transceiver have a [[Direction]] internal slot, initialized to direction. + 5. Let transceiver have a [[CurrentDirection]] internal slot, initialized + to null. + 6. Set transceiver.stopped to false. + */ + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_idl_attribute(pc, 'addTransceiver'); + + const transceiver = pc.addTransceiver('audio'); + assert_true(transceiver instanceof RTCRtpTransceiver, + 'Expect transceiver to be instance of RTCRtpTransceiver'); + + assert_equals(transceiver.mid, null); + assert_equals(transceiver.stopped, false); + assert_equals(transceiver.direction, 'sendrecv'); + assert_equals(transceiver.currentDirection, null); + + assert_array_equals([transceiver], pc.getTransceivers(), + `Expect added transceiver to be the only element in connection's list of transceivers`); + + const sender = transceiver.sender; + + assert_true(sender instanceof RTCRtpSender, + 'Expect sender to be instance of RTCRtpSender'); + + assert_equals(sender.track, null); + + assert_array_equals([sender], pc.getSenders(), + `Expect added sender to be the only element in connection's list of senders`); + + const receiver = transceiver.receiver; + assert_true(receiver instanceof RTCRtpReceiver, + 'Expect receiver to be instance of RTCRtpReceiver'); + + const track = receiver.track; + assert_true(track instanceof MediaStreamTrack, + 'Expect receiver.track to be instance of MediaStreamTrack'); + + assert_equals(track.kind, 'audio'); + assert_equals(track.readyState, 'live'); + assert_equals(track.muted, true); + + assert_array_equals([receiver], pc.getReceivers(), + `Expect added receiver to be the only element in connection's list of receivers`); + + }, `addTransceiver('audio') should return an audio transceiver`); + + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_idl_attribute(pc, 'addTransceiver'); + + const transceiver = pc.addTransceiver('video'); + assert_true(transceiver instanceof RTCRtpTransceiver, + 'Expect transceiver to be instance of RTCRtpTransceiver'); + + assert_equals(transceiver.mid, null); + assert_equals(transceiver.stopped, false); + assert_equals(transceiver.direction, 'sendrecv'); + + assert_array_equals([transceiver], pc.getTransceivers(), + `Expect added transceiver to be the only element in connection's list of transceivers`); + + const sender = transceiver.sender; + + assert_true(sender instanceof RTCRtpSender, + 'Expect sender to be instance of RTCRtpSender'); + + assert_equals(sender.track, null); + + assert_array_equals([sender], pc.getSenders(), + `Expect added sender to be the only element in connection's list of senders`); + + const receiver = transceiver.receiver; + assert_true(receiver instanceof RTCRtpReceiver, + 'Expect receiver to be instance of RTCRtpReceiver'); + + const track = receiver.track; + assert_true(track instanceof MediaStreamTrack, + 'Expect receiver.track to be instance of MediaStreamTrack'); + + assert_equals(track.kind, 'video'); + assert_equals(track.readyState, 'live'); + assert_equals(track.muted, true); + + assert_array_equals([receiver], pc.getReceivers(), + `Expect added receiver to be the only element in connection's list of receivers`); + + }, `addTransceiver('video') should return a video transceiver`); + + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('audio', { direction: 'sendonly' }); + assert_equals(transceiver.direction, 'sendonly'); + }, `addTransceiver() with direction sendonly should have result transceiver.direction be the same`); + + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('audio', { direction: 'inactive' }); + assert_equals(transceiver.direction, 'inactive'); + }, `addTransceiver() with direction inactive should have result transceiver.direction be the same`); + + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_idl_attribute(pc, 'addTransceiver'); + assert_throws_js(TypeError, () => + pc.addTransceiver('audio', { direction: 'invalid' })); + }, `addTransceiver() with invalid direction should throw TypeError`); + + /* + 5.1. addTransceiver + 5. If the first argument is a MediaStreamTrack , let it be track and let + kind be track.kind. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const transceiver = pc.addTransceiver(track); + const { sender, receiver } = transceiver; + + assert_true(sender instanceof RTCRtpSender, + 'Expect sender to be instance of RTCRtpSender'); + + assert_true(receiver instanceof RTCRtpReceiver, + 'Expect receiver to be instance of RTCRtpReceiver'); + + assert_equals(sender.track, track, + 'Expect sender.track should be the track that is added'); + + const receiverTrack = receiver.track; + assert_true(receiverTrack instanceof MediaStreamTrack, + 'Expect receiver.track to be instance of MediaStreamTrack'); + + assert_equals(receiverTrack.kind, 'audio', + `receiver.track should have the same kind as added track's kind`); + + assert_equals(receiverTrack.readyState, 'live'); + assert_equals(receiverTrack.muted, true); + + assert_array_equals([transceiver], pc.getTransceivers(), + `Expect added transceiver to be the only element in connection's list of transceivers`); + + assert_array_equals([sender], pc.getSenders(), + `Expect added sender to be the only element in connection's list of senders`); + + assert_array_equals([receiver], pc.getReceivers(), + `Expect added receiver to be the only element in connection's list of receivers`); + + }, 'addTransceiver(track) should have result with sender.track be given track'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const transceiver1 = pc.addTransceiver(track); + const transceiver2 = pc.addTransceiver(track); + + assert_not_equals(transceiver1, transceiver2); + + const sender1 = transceiver1.sender; + const sender2 = transceiver2.sender; + + assert_not_equals(sender1, sender2); + assert_equals(transceiver1.sender.track, track); + assert_equals(transceiver2.sender.track, track); + + const transceivers = pc.getTransceivers(); + assert_equals(transceivers.length, 2); + assert_true(transceivers.includes(transceiver1)); + assert_true(transceivers.includes(transceiver2)); + + const senders = pc.getSenders(); + assert_equals(senders.length, 2); + assert_true(senders.includes(sender1)); + assert_true(senders.includes(sender2)); + + }, 'addTransceiver(track) multiple times should create multiple transceivers'); + + /* + 5.1. addTransceiver + 6. Verify that each rid value in sendEncodings is composed only of + case-sensitive alphanumeric characters (a-z, A-Z, 0-9) up to a maximum + of 16 characters. If one of the RIDs does not meet these requirements, + throw a TypeError. + */ + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + assert_idl_attribute(pc, 'addTransceiver'); + + assert_throws_js(TypeError, () => + pc.addTransceiver('video', { + sendEncodings: [{ + rid: '@Invalid!' + }] + })); + }, 'addTransceiver() with rid containing invalid non-alphanumeric characters should throw TypeError'); + + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + assert_idl_attribute(pc, 'addTransceiver'); + + assert_throws_js(TypeError, () => + pc.addTransceiver('audio', { + sendEncodings: [{ + rid: 'a'.repeat(17) + }] + })); + }, 'addTransceiver() with rid longer than 16 characters should throw TypeError'); + + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + pc.addTransceiver('audio', { + sendEncodings: [{ + rid: 'foo' + }] + }); + }, `addTransceiver() with valid rid value should succeed`); + + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + pc.addTransceiver('video', { + sendEncodings: [{ + dtx: 'enabled', + active: false, + ptime: 5, + maxBitrate: 8, + maxFramerate: 25, + rid: 'foo' + }] + }); + }, `addTransceiver() with valid sendEncodings should succeed`); + + /* + TODO + 5.1. addTransceiver + - Adding a transceiver will cause future calls to createOffer to add a media + description for the corresponding transceiver, as defined in [JSEP] + (section 5.2.2.). + + - Setting a new RTCSessionDescription may change mid to a non-null value, + as defined in [JSEP] (section 5.5. and section 5.6.). + + 1. If the dictionary argument is present, and it has a streams member, let + streams be that list of MediaStream objects. + + 5.2. RTCRtpSender Interface + Create an RTCRtpSender + 3. Let sender have an [[associated MediaStreams]] internal slot, representing + a list of MediaStream objects that the MediaStreamTrack object of this + sender is associated with. + + 4. Set sender's [[associated MediaStreams]] slot to streams. + + 5. Let sender have a [[send encodings]] internal slot, representing a list + of RTCRtpEncodingParameters dictionaries. + + 6. If sendEncodings is given as input to this algorithm, and is non-empty, + set the [[send encodings]] slot to sendEncodings. Otherwise, set it to a + list containing a single RTCRtpEncodingParameters with active set to true. + + 5.3. RTCRtpReceiver Interface + Create an RTCRtpReceiver + 4. If an id string, id, was given as input to this algorithm, initialize + track.id to id. (Otherwise the value generated when track was created + will be used.) + + Tested in RTCPeerConnection-onnegotiationneeded.html + 5.1. addTransceiver + 12. Update the negotiation-needed flag for connection. + + Out of Scope + 5.1. addTransceiver + 8. If sendEncodings is set, then subsequent calls to createOffer will be + configured to send multiple RTP encodings as defined in [JSEP] + (section 5.2.2. and section 5.2.1.). + + When setRemoteDescription is called with a corresponding remote + description that is able to receive multiple RTP encodings as defined + in [JSEP] (section 3.7.), the RTCRtpSender may send multiple RTP + encodings and the parameters retrieved via the transceiver's + sender.getParameters() will reflect the encodings negotiated. + + 9. This specification does not define how to configure createOffer to + receive multiple RTP encodings. However when setRemoteDescription is + called with a corresponding remote description that is able to send + multiple RTP encodings as defined in [JSEP], the RTCRtpReceiver may + receive multiple RTP encodings and the parameters retrieved via the + transceiver's receiver.getParameters() will reflect the encodings + negotiated. + + Coverage Report + Tested Not-Tested Non-Testable Total + addTransceiver 14 1 3 18 + Create Sender 3 4 0 7 + Create Receiver 8 1 0 9 + Create Transceiver 7 0 0 7 + + Total 32 6 3 41 + */ +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-canTrickleIceCandidates.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-canTrickleIceCandidates.html new file mode 100644 index 0000000000..09ad67751a --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-canTrickleIceCandidates.html @@ -0,0 +1,62 @@ +<!doctype html> +<html> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title>RTCPeerConnection canTrickleIceCandidates tests</title> +</head> +<body> + <!-- These files are in place when executing on W3C. --> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script type="text/javascript"> + // tests support for RTCPeerConnection.canTrickleIceCandidates: + // http://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-cantrickleicecandidates + const sdp = 'v=0\r\n' + + 'o=- 166855176514521964 2 IN IP4 127.0.0.1\r\n' + + 's=-\r\n' + + 't=0 0\r\n' + + 'a=ice-options:trickle\r\n' + + 'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' + + 'c=IN IP4 0.0.0.0\r\n' + + 'a=rtcp:9 IN IP4 0.0.0.0\r\n' + + 'a=ice-ufrag:someufrag\r\n' + + 'a=ice-pwd:somelongpwdwithenoughrandomness\r\n' + + 'a=fingerprint:sha-256 8C:71:B3:8D:A5:38:FD:8F:A4:2E:A2:65:6C:86:52:BC:E0:6E:94:F2:9F:7C:4D:B5:DF:AF:AA:6F:44:90:8D:F4\r\n' + + 'a=setup:actpass\r\n' + + 'a=rtcp-mux\r\n' + + 'a=mid:mid1\r\n' + + 'a=sendonly\r\n' + + 'a=msid:stream1 track1\r\n' + + 'a=ssrc:1001 cname:some\r\n' + + 'a=rtpmap:111 opus/48000/2\r\n'; + + test(function() { + var pc = new RTCPeerConnection(); + assert_equals(pc.canTrickleIceCandidates, null, 'canTrickleIceCandidates property is null'); + }, 'canTrickleIceCandidates property is null prior to setRemoteDescription'); + + promise_test(function(t) { + var pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(new RTCSessionDescription({type: 'offer', sdp: sdp})) + .then(function() { + assert_true(pc.canTrickleIceCandidates, 'canTrickleIceCandidates property is true after setRemoteDescription'); + }) + }, 'canTrickleIceCandidates property is true after setRemoteDescription with a=ice-options:trickle'); + + promise_test(function(t) { + var pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription(new RTCSessionDescription({type: 'offer', sdp: sdp.replace('a=ice-options:trickle\r\n', '')})) + .then(function() { + assert_false(pc.canTrickleIceCandidates, 'canTrickleIceCandidates property is false after setRemoteDescription'); + }) + }, 'canTrickleIceCandidates property is false after setRemoteDescription without a=ice-options:trickle'); +</script> + +</body> +</html> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-candidate-in-sdp.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-candidate-in-sdp.https.html new file mode 100644 index 0000000000..6c97afe94a --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-candidate-in-sdp.https.html @@ -0,0 +1,26 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +'use strict'; + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + let resolveIceCandidatePromise = null; + const iceCandidatePromise = new Promise(r => resolveIceCandidatePromise = r); + pc.onicecandidate = e => { + resolveIceCandidatePromise(pc.localDescription.sdp); + pc.onicecandidate = null; + } + pc.addTransceiver("audio"); + await pc.setLocalDescription(await pc.createOffer()); + assert_false(pc.localDescription.sdp.includes("a=candidate:"), + "localDescription is missing candidate before onicecandidate"); + // The localDescription at the time of the onicecandidate event. + const localDescriptionSdp = await iceCandidatePromise; + assert_true(localDescriptionSdp.includes("a=candidate:"), + "localDescription contains candidate after onicecandidate"); +}, 'localDescription contains candidates'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-capture-video.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-capture-video.https.html new file mode 100644 index 0000000000..b6c0222dc2 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-capture-video.https.html @@ -0,0 +1,72 @@ +<!doctype html> +<html> +<head> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="RTCPeerConnection-helper.js"></script> +</head> +<body> +<script> + 'use strict'; + +// This test checks that <video> capture works via PeerConnection. + +promise_test(async t => { + const sourceVideo = document.createElement('video'); + sourceVideo.src = "/media/test-v-128k-320x240-24fps-8kfr.webm"; + sourceVideo.loop = true; + + const onCanPlay = new Promise(r => sourceVideo.oncanplay = r); + await onCanPlay; + + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + // Attach video to pc1. + const stream = sourceVideo.captureStream(); + const tracks = stream.getTracks(); + pc1.addTrack(tracks[0]); + + const destVideo = document.createElement('video'); + destVideo.autoplay = true; + + // Setup pc1->pc2. + const haveTrackEvent1 = new Promise(r => pc2.ontrack = r); + exchangeIceCandidates(pc1, pc2); + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + + // Display pc2 received track in video element. + const onLoadedMetadata = new Promise(r => destVideo.onloadedmetadata = r); + destVideo.srcObject = new MediaStream([(await haveTrackEvent1).track]); + + // Start playback and wait for video on the other side. + sourceVideo.play(); + await onLoadedMetadata; + + // Wait until the video has non-zero resolution and some non-black pixels. + await new Promise(p => { + function checkColor() { + if (destVideo.videoWidth > 0 && getVideoSignal(destVideo) > 0.0) + p(); + else + t.step_timeout(checkColor, 0); + } + checkColor(); + }); + + // Uses Helper.js GetVideoSignal to query |destVideo| pixel value at a certain position. + const pixelValue = getVideoSignal(destVideo); + + // Anything non-black means that capture works. + assert_not_equals(pixelValue, 0); + }, "Capturing a video element and sending it via PeerConnection"); +</script> +</body> +</html> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-connectionState.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-connectionState.https.html new file mode 100644 index 0000000000..d7716a1d4d --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-connectionState.https.html @@ -0,0 +1,291 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.connectionState</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.htm + + // The following helper functions are called from RTCPeerConnection-helper.js: + // exchangeIceCandidates + // exchangeOfferAnswer + + /* + 4.3.2. Interface Definition + interface RTCPeerConnection : EventTarget { + ... + readonly attribute RTCPeerConnectionState connectionState; + attribute EventHandler onconnectionstatechange; + }; + + 4.4.3. RTCPeerConnectionState Enum + enum RTCPeerConnectionState { + "new", + "connecting", + "connected", + "disconnected", + "failed", + "closed" + }; + + 5.5. RTCDtlsTransport Interface + interface RTCDtlsTransport { + readonly attribute RTCIceTransport iceTransport; + readonly attribute RTCDtlsTransportState state; + ... + }; + + enum RTCDtlsTransportState { + "new", + "connecting", + "connected", + "closed", + "failed" + }; + + 5.6. RTCIceTransport Interface + interface RTCIceTransport { + readonly attribute RTCIceTransportState state; + ... + }; + + enum RTCIceTransportState { + "new", + "checking", + "connected", + "completed", + "failed", + "disconnected", + "closed" + }; + */ + + /* + 4.4.3. RTCPeerConnectionState Enum + new + Any of the RTCIceTransports or RTCDtlsTransports are in the new + state and none of the transports are in the connecting, checking, + failed or disconnected state, or all transports are in the closed state. + */ + test(t => { + const pc = new RTCPeerConnection(); + assert_equals(pc.connectionState, 'new'); + }, 'Initial connectionState should be new'); + + test(t => { + const pc = new RTCPeerConnection(); + pc.close(); + assert_equals(pc.connectionState, 'closed'); + }, 'Closing the connection should set connectionState to closed'); + + /* + 4.4.3. RTCPeerConnectionState Enum + connected + All RTCIceTransports and RTCDtlsTransports are in the connected, + completed or closed state and at least of them is in the connected + or completed state. + + 5.5. RTCDtlsTransportState + connected + DTLS has completed negotiation of a secure connection. + + 5.6. RTCIceTransportState + connected + The RTCIceTransport has found a usable connection, but is still + checking other candidate pairs to see if there is a better connection. + It may also still be gathering and/or waiting for additional remote + candidates. If consent checks [RFC7675] fail on the connection in use, + and there are no other successful candidate pairs available, then the + state transitions to "checking" (if there are candidate pairs remaining + to be checked) or "disconnected" (if there are no candidate pairs to + check, but the peer is still gathering and/or waiting for additional + remote candidates). + + completed + The RTCIceTransport has finished gathering, received an indication that + there are no more remote candidates, finished checking all candidate + pairs and found a connection. If consent checks [RFC7675] subsequently + fail on all successful candidate pairs, the state transitions to "failed". + */ + + async_test(t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + let had_connecting = false; + + const onConnectionStateChange = t.step_func(() => { + const {connectionState} = pc1; + if (connectionState === 'connecting') { + had_connecting = true; + } else if (connectionState === 'connected') { + assert_true(had_connecting, "state should pass connecting before reaching connected"); + t.done(); + } + }); + + pc1.createDataChannel('test'); + + pc1.addEventListener('connectionstatechange', onConnectionStateChange); + + exchangeIceCandidates(pc1, pc2); + exchangeOfferAnswer(pc1, pc2); + }, 'connection with one data channel should eventually have connected connection state'); + + async_test(t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const onConnectionStateChange = t.step_func(() => { + const {connectionState} = pc1; + if (connectionState === 'connected') { + const sctpTransport = pc1.sctp; + + const dtlsTransport = sctpTransport.transport; + assert_equals(dtlsTransport.state, 'connected', + 'Expect DTLS transport to be in connected state'); + + const iceTransport = dtlsTransport.iceTransport + assert_true(iceTransport.state === 'connected' || + iceTransport.state === 'completed', + 'Expect ICE transport to be in connected or completed state'); + + t.done(); + } + }); + + pc1.createDataChannel('test'); + + pc1.addEventListener('connectionstatechange', onConnectionStateChange); + + exchangeIceCandidates(pc1, pc2); + exchangeOfferAnswer(pc1, pc2); + }, 'connection with one data channel should eventually have transports in connected state'); + + /* + TODO + 4.4.3. RTCPeerConnectionState Enum + connecting + Any of the RTCIceTransports or RTCDtlsTransports are in the + connecting or checking state and none of them is in the failed state. + + disconnected + Any of the RTCIceTransports or RTCDtlsTransports are in the disconnected + state and none of them are in the failed or connecting or checking state. + + failed + Any of the RTCIceTransports or RTCDtlsTransports are in a failed state. + + closed + The RTCPeerConnection object's [[isClosed]] slot is true. + + 5.5. RTCDtlsTransportState + new + DTLS has not started negotiating yet. + + connecting + DTLS is in the process of negotiating a secure connection. + + closed + The transport has been closed. + + failed + The transport has failed as the result of an error (such as a failure + to validate the remote fingerprint). + + 5.6. RTCIceTransportState + new + The RTCIceTransport is gathering candidates and/or waiting for + remote candidates to be supplied, and has not yet started checking. + + checking + The RTCIceTransport has received at least one remote candidate and + is checking candidate pairs and has either not yet found a connection + or consent checks [RFC7675] have failed on all previously successful + candidate pairs. In addition to checking, it may also still be gathering. + + failed + The RTCIceTransport has finished gathering, received an indication that + there are no more remote candidates, finished checking all candidate pairs, + and all pairs have either failed connectivity checks or have lost consent. + + disconnected + The ICE Agent has determined that connectivity is currently lost for this + RTCIceTransport . This is more aggressive than failed, and may trigger + intermittently (and resolve itself without action) on a flaky network. + The way this state is determined is implementation dependent. + + Examples include: + Losing the network interface for the connection in use. + Repeatedly failing to receive a response to STUN requests. + + Alternatively, the RTCIceTransport has finished checking all existing + candidates pairs and failed to find a connection (or consent checks + [RFC7675] once successful, have now failed), but it is still gathering + and/or waiting for additional remote candidates. + + closed + The RTCIceTransport has shut down and is no longer responding to STUN requests. + */ + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + caller.addTrack(track, stream); + + await exchangeOfferAnswer(caller, callee); + + assert_equals(caller.iceConnectionState, 'new'); + assert_equals(callee.iceConnectionState, 'new'); + }, 'connectionState remains new when not adding remote ice candidates'); + + promise_test(async t => { + + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + caller.addTrack(track, stream); + + const states = []; + caller.addEventListener('connectionstatechange', () => states.push(caller.connectionState)); + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + + await listenToConnected(caller); + + assert_array_equals(states, ['connecting', 'connected']); + }, 'connectionState transitions to connected via connecting'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + + stream.getTracks().forEach(track => pc1.addTrack(track, stream)); + exchangeIceCandidates(pc1, pc2); + exchangeOfferAnswer(pc1, pc2); + await listenToIceConnected(pc2); + + pc2.onconnectionstatechange = t.unreached_func(); + pc2.close(); + assert_equals(pc2.connectionState, 'closed'); + await new Promise(r => t.step_timeout(r, 100)); + }, 'Closing a PeerConnection should not fire connectionstatechange event'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-constructor.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-constructor.html new file mode 100644 index 0000000000..1708b2705f --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-constructor.html @@ -0,0 +1,76 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection constructor</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +test(function() { + assert_equals(RTCPeerConnection.length, 0); +}, 'RTCPeerConnection.length'); + +// These are used for string and number dictionary members to see if they are +// being accessed at all. +const toStringThrows = { toString: function() { throw new Error; } }; +const toNumberThrows = Symbol(); + +// Test the first argument of the constructor. The key is the argument itself, +// and the value is the first argument for assert_throws_js, or false if no +// exception should be thrown. +const testArgs = { + // No argument or equivalent. + '': false, + 'null': false, + 'undefined': false, + '{}': false, + + // certificates + '{ certificates: null }': TypeError, + '{ certificates: undefined }': false, + '{ certificates: [] }': false, + '{ certificates: [null] }': TypeError, + '{ certificates: [undefined] }': TypeError, + + // iceCandidatePoolSize + '{ iceCandidatePoolSize: toNumberThrows }': TypeError, +} + +for (const arg in testArgs) { + const expr = 'new RTCPeerConnection(' + arg + ')'; + test(function() { + const throws = testArgs[arg]; + if (throws) { + assert_throws_js(throws, function() { + eval(expr); + }); + } else { + eval(expr); + } + }, expr); +} + +// The initial values of attributes of RTCPeerConnection. +const initialState = { + 'localDescription': null, + 'currentLocalDescription': null, + 'pendingLocalDescription': null, + 'remoteDescription': null, + 'currentRemoteDescription': null, + 'pendingRemoteDescription': null, + 'signalingState': 'stable', + 'iceGatheringState': 'new', + 'iceConnectionState': 'new', + 'connectionState': 'new', + 'canTrickleIceCandidates': null, + // TODO: defaultIceServers +}; + +for (const attr in initialState) { + test(function() { + // Use one RTCPeerConnection instance for all initial value tests. + if (!window.pc) { + window.pc = new RTCPeerConnection; + } + assert_equals(window.pc[attr], initialState[attr]); + }, attr + ' initial value'); +} +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-createAnswer.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-createAnswer.html new file mode 100644 index 0000000000..1970db0737 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-createAnswer.html @@ -0,0 +1,41 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.createAnswer</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + await promise_rejects_dom(t, 'InvalidStateError', pc.createAnswer()); +}, 'createAnswer() with null remoteDescription should reject with InvalidStateError'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const offer = await generateVideoReceiveOnlyOffer(pc); + await pc.setRemoteDescription(offer); + const answer = await pc.createAnswer(); + assert_equals(typeof answer, 'object', + 'Expect answer to be plain object dictionary RTCSessionDescriptionInit'); + assert_false(answer instanceof RTCSessionDescription, + 'Expect answer to not be instance of RTCSessionDescription'); +}, 'createAnswer() after setting remote description should succeed'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + // generateDataChannelOffer() is defined in RTCPeerConnection-helper.js. + const offer = await generateDataChannelOffer(pc); + await pc.setRemoteDescription(offer); + pc.close(); + await promise_rejects_dom(t, 'InvalidStateError', pc.createAnswer()); +}, 'createAnswer() when connection is closed should reject with InvalidStateError'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-createDataChannel.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-createDataChannel.html new file mode 100644 index 0000000000..7ad8bf7d46 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-createDataChannel.html @@ -0,0 +1,758 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection.prototype.createDataChannel</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +const stopTracks = (...streams) => { + streams.forEach(stream => stream.getTracks().forEach(track => track.stop())); +}; + +// Test is based on the following revision: +// https://rawgit.com/w3c/webrtc-pc/1cc5bfc3ff18741033d804c4a71f7891242fb5b3/webrtc.html + +/* + 6.1. RTCPeerConnection Interface Extensions + + partial interface RTCPeerConnection { + [...] + RTCDataChannel createDataChannel(USVString label, + optional RTCDataChannelInit dataChannelDict); + [...] + }; + + 6.2. RTCDataChannel + + interface RTCDataChannel : EventTarget { + readonly attribute USVString label; + readonly attribute boolean ordered; + readonly attribute unsigned short? maxPacketLifeTime; + readonly attribute unsigned short? maxRetransmits; + readonly attribute USVString protocol; + readonly attribute boolean negotiated; + readonly attribute unsigned short? id; + readonly attribute RTCDataChannelState readyState; + readonly attribute unsigned long bufferedAmount; + attribute unsigned long bufferedAmountLowThreshold; + [...] + attribute DOMString binaryType; + [...] + }; + + dictionary RTCDataChannelInit { + boolean ordered = true; + unsigned short maxPacketLifeTime; + unsigned short maxRetransmits; + USVString protocol = ""; + boolean negotiated = false; + [EnforceRange] + unsigned short id; + }; + */ + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_equals(pc.createDataChannel.length, 1); + assert_throws_js(TypeError, () => pc.createDataChannel()); +}, 'createDataChannel with no argument should throw TypeError'); + +/* + 6.2. createDataChannel + 2. If connection's [[isClosed]] slot is true, throw an InvalidStateError. + */ +test(t => { + const pc = new RTCPeerConnection(); + pc.close(); + assert_equals(pc.signalingState, 'closed', 'signaling state'); + assert_throws_dom('InvalidStateError', () => pc.createDataChannel('')); +}, 'createDataChannel with closed connection should throw InvalidStateError'); + +/* + 6.1. createDataChannel + 4. Let channel have a [[DataChannelLabel]] internal slot initialized to the value of the + first argument. + 6. Let options be the second argument. + 7. Let channel have an [[MaxPacketLifeTime]] internal slot initialized to + option's maxPacketLifeTime member, if present, otherwise null. + 8. Let channel have a [[ReadyState]] internal slot initialized to "connecting". + 9. Let channel have a [[BufferedAmount]] internal slot initialized to 0. + 10. Let channel have an [[MaxRetransmits]] internal slot initialized to + option's maxRetransmits member, if present, otherwise null. + 11. Let channel have an [[Ordered]] internal slot initialized to option's + ordered member. + 12. Let channel have a [[DataChannelProtocol]] internal slot initialized to option's + protocol member. + 14. Let channel have a [[Negotiated]] internal slot initialized to option's negotiated + member. + 15. Let channel have an [[DataChannelId]] internal slot initialized to option's id + member, if it is present and [[Negotiated]] is true, otherwise null. + 21. If the [[DataChannelId]] slot is null (due to no ID being passed into + createDataChannel, or [[Negotiated]] being false), and the DTLS role of the SCTP + transport has already been negotiated, then initialize [[DataChannelId]] to a value + generated by the user agent, according to [RTCWEB-DATA-PROTOCOL], and skip + to the next step. If no available ID could be generated, or if the value of the + [[DataChannelId]] slot is being used by an existing RTCDataChannel, throw an + OperationError exception. + + Note + If the [[DataChannelId]] slot is null after this step, it will be populated once + the DTLS role is determined during the process of setting an RTCSessionDescription. + 22. If channel is the first RTCDataChannel created on connection, update the + negotiation-needed flag for connection. + + + 6.2. RTCDataChannel + + A RTCDataChannel, created with createDataChannel or dispatched via a + RTCDataChannelEvent, MUST initially be in the connecting state + + bufferedAmountLowThreshold + [...] The bufferedAmountLowThreshold is initially zero on each new RTCDataChannel, + but the application may change its value at any time. + + binaryType + [...] When a RTCDataChannel object is created, the binaryType attribute MUST + be initialized to the string "blob". + */ +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const dc = pc.createDataChannel(''); + + assert_true(dc instanceof RTCDataChannel, 'is RTCDataChannel'); + assert_equals(dc.label, ''); + assert_equals(dc.ordered, true); + assert_equals(dc.maxPacketLifeTime, null); + assert_equals(dc.maxRetransmits, null); + assert_equals(dc.protocol, ''); + assert_equals(dc.negotiated, false); + // Since no offer/answer exchange has occurred yet, the DTLS role is unknown + // and so the ID should be null. + assert_equals(dc.id, null); + assert_equals(dc.readyState, 'connecting'); + assert_equals(dc.bufferedAmount, 0); + assert_equals(dc.bufferedAmountLowThreshold, 0); + assert_equals(dc.binaryType, 'blob'); +}, 'createDataChannel attribute default values'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const dc = pc.createDataChannel('test', { + ordered: false, + maxRetransmits: 1, + // Note: maxPacketLifeTime is not set in this test. + protocol: 'custom', + negotiated: true, + id: 3 + }); + + assert_true(dc instanceof RTCDataChannel, 'is RTCDataChannel'); + assert_equals(dc.label, 'test'); + assert_equals(dc.ordered, false); + assert_equals(dc.maxPacketLifeTime, null); + assert_equals(dc.maxRetransmits, 1); + assert_equals(dc.protocol, 'custom'); + assert_equals(dc.negotiated, true); + assert_equals(dc.id, 3); + assert_equals(dc.readyState, 'connecting'); + assert_equals(dc.bufferedAmount, 0); + assert_equals(dc.bufferedAmountLowThreshold, 0); + assert_equals(dc.binaryType, 'blob'); + + const dc2 = pc.createDataChannel('test2', { + ordered: false, + maxPacketLifeTime: 42 + }); + assert_equals(dc2.label, 'test2'); + assert_equals(dc2.maxPacketLifeTime, 42); + assert_equals(dc2.maxRetransmits, null); +}, 'createDataChannel with provided parameters should initialize attributes to provided values'); + +/* + 6.2. createDataChannel + 4. Let channel have a [[DataChannelLabel]] internal slot initialized to the value of the + first argument. + + [ECMA262] 7.1.12. ToString(argument) + undefined -> "undefined" + null -> "null" + + [WebIDL] 3.10.15. Convert a DOMString to a sequence of Unicode scalar values + */ +const labels = [ + ['"foo"', 'foo', 'foo'], + ['null', null, 'null'], + ['undefined', undefined, 'undefined'], + ['lone surrogate', '\uD800', '\uFFFD'], +]; +for (const [description, label, expected] of labels) { + test(t => { + const pc = new RTCPeerConnection; + t.add_cleanup(() => pc.close()); + + const dc = pc.createDataChannel(label); + assert_equals(dc.label, expected); + }, `createDataChannel with label ${description} should succeed`); +} + +/* + 6.2. RTCDataChannel + createDataChannel + 11. Let channel have an [[Ordered]] internal slot initialized to option's + ordered member. + */ +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const dc = pc.createDataChannel('', { ordered: false }); + assert_equals(dc.ordered, false); +}, 'createDataChannel with ordered false should succeed'); + +// true as the default value of a boolean is confusing because null is converted +// to false while undefined is converted to true. +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const dc1 = pc.createDataChannel('', { ordered: null }); + assert_equals(dc1.ordered, false); + const dc2 = pc.createDataChannel('', { ordered: undefined }); + assert_equals(dc2.ordered, true); +}, 'createDataChannel with ordered null/undefined should succeed'); + +/* + 6.2. RTCDataChannel + createDataChannel + 7. Let channel have an [[MaxPacketLifeTime]] internal slot initialized to + option's maxPacketLifeTime member, if present, otherwise null. + */ +test(t => { + const pc = new RTCPeerConnection; + t.add_cleanup(() => pc.close()); + + const dc = pc.createDataChannel('', { maxPacketLifeTime: 0 }); + assert_equals(dc.maxPacketLifeTime, 0); +}, 'createDataChannel with maxPacketLifeTime 0 should succeed'); + +/* + 6.2. RTCDataChannel + createDataChannel + 10. Let channel have an [[MaxRetransmits]] internal slot initialized to + option's maxRetransmits member, if present, otherwise null. + */ +test(t => { + const pc = new RTCPeerConnection; + t.add_cleanup(() => pc.close()); + + const dc = pc.createDataChannel('', { maxRetransmits: 0 }); + assert_equals(dc.maxRetransmits, 0); +}, 'createDataChannel with maxRetransmits 0 should succeed'); + +/* + 6.2. createDataChannel + 18. If both [[MaxPacketLifeTime]] and [[MaxRetransmits]] attributes are set (not null), + throw a TypeError. + */ +test(t => { + const pc = new RTCPeerConnection; + t.add_cleanup(() => pc.close()); + + pc.createDataChannel('', { + maxPacketLifeTime: undefined, + maxRetransmits: undefined + }); +}, 'createDataChannel with both maxPacketLifeTime and maxRetransmits undefined should succeed'); + +test(t => { + const pc = new RTCPeerConnection; + t.add_cleanup(() => pc.close()); + + assert_throws_js(TypeError, () => pc.createDataChannel('', { + maxPacketLifeTime: 0, + maxRetransmits: 0 + })); + assert_throws_js(TypeError, () => pc.createDataChannel('', { + maxPacketLifeTime: 42, + maxRetransmits: 42 + })); +}, 'createDataChannel with both maxPacketLifeTime and maxRetransmits should throw TypeError'); + +/* + 6.2. RTCDataChannel + createDataChannel + 12. Let channel have a [[DataChannelProtocol]] internal slot initialized to option's + protocol member. + */ +const protocols = [ + ['"foo"', 'foo', 'foo'], + ['null', null, 'null'], + ['undefined', undefined, ''], + ['lone surrogate', '\uD800', '\uFFFD'], +]; +for (const [description, protocol, expected] of protocols) { + test(t => { + const pc = new RTCPeerConnection; + t.add_cleanup(() => pc.close()); + + const dc = pc.createDataChannel('', { protocol }); + assert_equals(dc.protocol, expected); + }, `createDataChannel with protocol ${description} should succeed`); +} + +/* + 6.2. RTCDataChannel + createDataChannel + 20. If [[DataChannelId]] is equal to 65535, which is greater than the maximum allowed + ID of 65534 but still qualifies as an unsigned short, throw a TypeError. + */ +for (const id of [0, 1, 65534, 65535]) { + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const dc = pc.createDataChannel('', { id }); + assert_equals(dc.id, null); + }, `createDataChannel with id ${id} and negotiated not set should succeed, but not set the channel's id`); +} + +for (const id of [0, 1, 65534]) { + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const dc = pc.createDataChannel('', { 'negotiated': true, 'id': id }); + assert_equals(dc.id, id); + }, `createDataChannel with id ${id} and negotiated true should succeed, and set the channel's id`); +} + +for (const id of [-1, 65536]) { + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + assert_throws_js(TypeError, () => pc.createDataChannel('', { id })); + }, `createDataChannel with id ${id} and negotiated not set should throw TypeError`); +} + +for (const id of [-1, 65535, 65536]) { + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_throws_js(TypeError, () => pc.createDataChannel('', + { 'negotiated': true, 'id': id })); + }, `createDataChannel with id ${id} should throw TypeError`); +} + +/* + 6.2. createDataChannel + 5. If [[DataChannelLabel]] is longer than 65535 bytes, throw a TypeError. + */ +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_throws_js(TypeError, () => + pc.createDataChannel('l'.repeat(65536))); + + assert_throws_js(TypeError, () => + pc.createDataChannel('l'.repeat(65536), { + negotiated: true, + id: 42 + })); +}, 'createDataChannel with too long label should throw TypeError'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_throws_js(TypeError, () => + pc.createDataChannel('\u00b5'.repeat(32768))); + + assert_throws_js(TypeError, () => + pc.createDataChannel('\u00b5'.repeat(32768), { + negotiated: true, + id: 42 + })); +}, 'createDataChannel with too long label (2 byte unicode) should throw TypeError'); + +/* + 6.2. label + [...] Scripts are allowed to create multiple RTCDataChannel objects with the same label. + [...] + */ +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const label = 'test'; + + pc.createDataChannel(label); + pc.createDataChannel(label); +}, 'createDataChannel with same label used twice should not throw'); + +/* + 6.2. createDataChannel + 13. If [[DataChannelProtocol]] is longer than 65535 bytes long, throw a TypeError. + */ + +test(t => { + const pc = new RTCPeerConnection; + t.add_cleanup(() => pc.close()); + const channel = pc.createDataChannel('', { negotiated: true, id: 42 }); + assert_equals(channel.negotiated, true); +}, 'createDataChannel with negotiated true and id should succeed'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_throws_js(TypeError, () => + pc.createDataChannel('', { + protocol: 'p'.repeat(65536) + })); + + assert_throws_js(TypeError, () => + pc.createDataChannel('', { + protocol: 'p'.repeat(65536), + negotiated: true, + id: 42 + })); +}, 'createDataChannel with too long protocol should throw TypeError'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_throws_js(TypeError, () => + pc.createDataChannel('', { + protocol: '\u00b6'.repeat(32768) + })); + + assert_throws_js(TypeError, () => + pc.createDataChannel('', { + protocol: '\u00b6'.repeat(32768), + negotiated: true, + id: 42 + })); +}, 'createDataChannel with too long protocol (2 byte unicode) should throw TypeError'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const label = 'l'.repeat(65535); + const protocol = 'p'.repeat(65535); + + const dc = pc.createDataChannel(label, { + protocol: protocol + }); + + assert_equals(dc.label, label); + assert_equals(dc.protocol, protocol); +}, 'createDataChannel with maximum length label and protocol should succeed'); + +/* + 6.2 createDataChannel + 15. Let channel have an [[DataChannelId]] internal slot initialized to option's id member, + if it is present and [[Negotiated]] is true, otherwise null. + + NOTE + This means the id member will be ignored if the data channel is negotiated in-band; this + is intentional. Data channels negotiated in-band should have IDs selected based on the + DTLS role, as specified in [RTCWEB-DATA-PROTOCOL]. + */ +test(t => { + const pc = new RTCPeerConnection; + t.add_cleanup(() => pc.close()); + + const dc = pc.createDataChannel('', { + negotiated: false, + }); + assert_equals(dc.negotiated, false, 'Expect dc.negotiated to be false'); +}, 'createDataChannel with negotiated false should succeed'); + +test(t => { + const pc = new RTCPeerConnection; + t.add_cleanup(() => pc.close()); + + const dc = pc.createDataChannel('', { + negotiated: false, + id: 42 + }); + assert_equals(dc.negotiated, false, 'Expect dc.negotiated to be false'); + assert_equals(dc.id, null, 'Expect dc.id to be ignored (null)'); +}, 'createDataChannel with negotiated false and id 42 should ignore the id'); + +/* + 6.2. createDataChannel + 16. If [[Negotiated]] is true and [[DataChannelId]] is null, throw a TypeError. + */ +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_throws_js(TypeError, () => + pc.createDataChannel('test', { + negotiated: true + })); +}, 'createDataChannel with negotiated true and id not defined should throw TypeError'); + +/* + 4.4.1.6. Set the RTCSessionSessionDescription + 2.2.6. If description is of type "answer" or "pranswer", then run the + following steps: + 3. If description negotiates the DTLS role of the SCTP transport, and there is an + RTCDataChannel with a null id, then generate an ID according to + [RTCWEB-DATA-PROTOCOL]. [...] + + 6.1. createDataChannel + 21. If the [[DataChannelId]] slot is null (due to no ID being passed into + createDataChannel, or [[Negotiated]] being false), and the DTLS role of the SCTP + transport has already been negotiated, then initialize [[DataChannelId]] to a value + generated by the user agent, according to [RTCWEB-DATA-PROTOCOL], and skip + to the next step. If no available ID could be generated, or if the value of the + [[DataChannelId]] slot is being used by an existing RTCDataChannel, throw an + OperationError exception. + + Note + If the [[DataChannelId]] slot is null after this step, it will be populated once + the DTLS role is determined during the process of setting an RTCSessionDescription. + */ +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const negotiatedDc = pc1.createDataChannel('negotiated-channel', { + negotiated: true, + id: 42, + }); + assert_equals(negotiatedDc.id, 42, 'Expect negotiatedDc.id to be 42'); + + const dc1 = pc1.createDataChannel('channel'); + assert_equals(dc1.id, null, 'Expect initial id to be null'); + + const offer = await pc1.createOffer(); + await Promise.all([pc1.setLocalDescription(offer), pc2.setRemoteDescription(offer)]); + const answer = await pc2.createAnswer(); + await pc1.setRemoteDescription(answer); + + assert_not_equals(dc1.id, null, + 'Expect dc1.id to be assigned after remote description has been set'); + + assert_greater_than_equal(dc1.id, 0, + 'Expect dc1.id to be set to valid unsigned short'); + + assert_less_than(dc1.id, 65535, + 'Expect dc1.id to be set to valid unsigned short'); + + const dc2 = pc1.createDataChannel('channel'); + + assert_not_equals(dc2.id, null, + 'Expect dc2.id to be assigned after remote description has been set'); + + assert_greater_than_equal(dc2.id, 0, + 'Expect dc2.id to be set to valid unsigned short'); + + assert_less_than(dc2.id, 65535, + 'Expect dc2.id to be set to valid unsigned short'); + + assert_not_equals(dc2, dc1, + 'Expect channels created from same label to be different'); + + assert_equals(dc2.label, dc1.label, + 'Expect different channels can have the same label but different id'); + + assert_not_equals(dc2.id, dc1.id, + 'Expect different channels can have the same label but different id'); + + assert_equals(negotiatedDc.id, 42, + 'Expect negotiatedDc.id to be 42 after remote description has been set'); +}, 'Channels created (after setRemoteDescription) should have id assigned'); + +test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const dc1 = pc.createDataChannel('channel-1', { + negotiated: true, + id: 42, + }); + assert_equals(dc1.id, 42, + 'Expect dc1.id to be 42'); + + const dc2 = pc.createDataChannel('channel-2', { + negotiated: true, + id: 43, + }); + assert_equals(dc2.id, 43, + 'Expect dc2.id to be 43'); + + assert_throws_dom('OperationError', () => + pc.createDataChannel('channel-3', { + negotiated: true, + id: 42, + })); + +}, 'Reusing a data channel id that is in use should throw OperationError'); + +// We've seen implementations behaving differently before and after the connection has been +// established. +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const dc1 = pc1.createDataChannel('channel-1', { + negotiated: true, + id: 42, + }); + assert_equals(dc1.id, 42, 'Expect dc1.id to be 42'); + + const dc2 = pc1.createDataChannel('channel-2', { + negotiated: true, + id: 43, + }); + assert_equals(dc2.id, 43, 'Expect dc2.id to be 43'); + + const offer = await pc1.createOffer(); + await Promise.all([pc1.setLocalDescription(offer), pc2.setRemoteDescription(offer)]); + const answer = await pc2.createAnswer(); + await pc1.setRemoteDescription(answer); + + assert_equals(dc1.id, 42, 'Expect dc1.id to be 42'); + + assert_equals(dc2.id, 43, 'Expect dc2.id to be 43'); + + assert_throws_dom('OperationError', () => + pc1.createDataChannel('channel-3', { + negotiated: true, + id: 42, + })); +}, 'Reusing a data channel id that is in use (after setRemoteDescription) should throw ' + + 'OperationError'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const dc1 = pc1.createDataChannel('channel-1'); + + const offer = await pc1.createOffer(); + await Promise.all([pc1.setLocalDescription(offer), pc2.setRemoteDescription(offer)]); + const answer = await pc2.createAnswer(); + await pc1.setRemoteDescription(answer); + + assert_not_equals(dc1.id, null, + 'Expect dc1.id to be assigned after remote description has been set'); + + assert_throws_dom('OperationError', () => + pc1.createDataChannel('channel-2', { + negotiated: true, + id: dc1.id, + })); +}, 'Reusing a data channel id that is in use (after setRemoteDescription, negotiated via DCEP) ' + + 'should throw OperationError'); + + +for (const options of [{}, {negotiated: true, id: 0}]) { + const mode = `${options.negotiated? "negotiated " : ""}datachannel`; + + // Based on https://bugzilla.mozilla.org/show_bug.cgi?id=1441723 + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + + await createDataChannelPair(t, options, pc1); + + const dc = pc1.createDataChannel(''); + assert_equals(dc.readyState, 'connecting', 'Channel should be in the connecting state'); + }, `New ${mode} should be in the connecting state after creation ` + + `(after connection establishment)`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const stream = await getNoiseStream({audio: true, video: true}); + t.add_cleanup(() => stopTracks(stream)); + const audio = stream.getAudioTracks()[0]; + const video = stream.getVideoTracks()[0]; + pc1.addTrack(audio, stream); + pc1.addTrack(video, stream); + await createDataChannelPair(t, options, pc1); + }, `addTrack, then creating ${mode}, should negotiate properly`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection({bundlePolicy: "max-bundle"}); + t.add_cleanup(() => pc1.close()); + const stream = await getNoiseStream({audio: true, video: true}); + t.add_cleanup(() => stopTracks(stream)); + const audio = stream.getAudioTracks()[0]; + const video = stream.getVideoTracks()[0]; + pc1.addTrack(audio, stream); + pc1.addTrack(video, stream); + await createDataChannelPair(t, options, pc1); + }, `addTrack, then creating ${mode}, should negotiate properly when max-bundle is used`); + +/* +This test is disabled until https://github.com/w3c/webrtc-pc/issues/2562 +has been resolved; it presupposes that stopping the first transceiver +breaks the transport. + + promise_test(async t => { + const pc1 = new RTCPeerConnection({bundlePolicy: "max-bundle"}); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const stream = await getNoiseStream({audio: true, video: true}); + t.add_cleanup(() => stopTracks(stream)); + const audio = stream.getAudioTracks()[0]; + const video = stream.getVideoTracks()[0]; + pc1.addTrack(audio, stream); + pc1.addTrack(video, stream); + const [dc1, dc2] = await createDataChannelPair(t, options, pc1, pc2); + + pc2.getTransceivers()[0].stop(); + const dc1Closed = new Promise(r => dc1.onclose = r); + await exchangeOfferAnswer(pc1, pc2); + await dc1Closed; + }, `Stopping the bundle-tag when there is a ${mode} in the bundle ` + + `should kill the DataChannel`); +*/ +} + +/* + Untestable + 6.1. createDataChannel + 19. If a setting, either [[MaxPacketLifeTime]] or [[MaxRetransmits]], has been set to + indicate unreliable mode, and that value exceeds the maximum value supported + by the user agent, the value MUST be set to the user agents maximum value. + + 23. Return channel and continue the following steps in parallel. + 24. Create channel's associated underlying data transport and configure + it according to the relevant properties of channel. + + Tested in RTCPeerConnection-onnegotiationneeded.html + 22. If channel is the first RTCDataChannel created on connection, update the + negotiation-needed flag for connection. + + Tested in RTCDataChannel-id.html + - Odd/even rules for '.id' + + Tested in RTCDataChannel-dcep.html + - Transmission of '.label' and further options +*/ +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-createOffer.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-createOffer.html new file mode 100644 index 0000000000..704fa3c646 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-createOffer.html @@ -0,0 +1,134 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.createOffer</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170515/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // countAudioLine() + // countVideoLine() + // assert_session_desc_similar() + + /* + * 4.3.2. createOffer() + */ + + /* + * Final steps to create an offer + * 4. Let offer be a newly created RTCSessionDescriptionInit dictionary + * with its type member initialized to the string "offer" and its sdp member + * initialized to sdpString. + */ + promise_test(t => { + const pc = new RTCPeerConnection() + + t.add_cleanup(() => pc.close()); + + return pc.createOffer() + .then(offer => { + assert_equals(typeof offer, 'object', + 'Expect offer to be plain object dictionary RTCSessionDescriptionInit'); + + assert_false(offer instanceof RTCSessionDescription, + 'Expect offer to not be instance of RTCSessionDescription') + }); + }, 'createOffer() with no argument from newly created RTCPeerConnection should succeed'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return generateVideoReceiveOnlyOffer(pc) + .then(offer => + pc.setLocalDescription(offer) + .then(() => { + assert_equals(pc.signalingState, 'have-local-offer'); + assert_session_desc_similar(pc.localDescription, offer); + assert_session_desc_similar(pc.pendingLocalDescription, offer); + assert_equals(pc.currentLocalDescription, null); + + assert_array_equals(states, ['have-local-offer']); + })); + }, 'createOffer() and then setLocalDescription() should succeed'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + pc.close(); + + return promise_rejects_dom(t, 'InvalidStateError', + pc.createOffer()); + }, 'createOffer() after connection is closed should reject with InvalidStateError'); + + /* + * Final steps to create an offer + * 2. If connection was modified in such a way that additional inspection of the + * system state is necessary, then in parallel begin the steps to create an + * offer again, given p, and abort these steps. + * + * This test might hit step 2 of final steps to create an offer. But the media stream + * is likely added already by the time steps to create an offer is executed, because + * that is enqueued as an operation. + * Either way it verifies that the media stream is included in the offer even though + * the stream is added after synchronous call to createOffer. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const promise = pc.createOffer(); + + pc.addTransceiver('audio'); + return promise.then(offer => { + assert_equals(countAudioLine(offer.sdp), 1, + 'Expect m=audio line to be found in offer SDP') + }); + }, 'When media stream is added when createOffer() is running in parallel, the result offer should contain the new media stream'); + + /* + If connection's signaling state is neither "stable" nor "have-local-offer", return a promise rejected with a newly created InvalidStateError. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return generateVideoReceiveOnlyOffer(pc) + .then(offer => + pc.setRemoteDescription(offer) + .then(() => { + assert_equals(pc.signalingState, 'have-remote-offer'); + return promise_rejects_dom(t, 'InvalidStateError', + pc.createOffer()); + }) + ) + }, 'createOffer() should fail when signaling state is not stable or have-local-offer'); + /* + * TODO + * 4.3.2 createOffer + * 3. If connection is configured with an identity provider, and an identity + * assertion has not yet been generated using said identity provider, then + * begin the identity assertion request process if it has not already begun. + * Steps to create an offer + * 1. If the need for an identity assertion was identified when createOffer was + * invoked, wait for the identity assertion request process to complete. + * + * Non-Testable + * 4.3.2 createOffer + * Steps to create an offer + * 4. Inspect the system state to determine the currently available resources as + * necessary for generating the offer, as described in [JSEP] (section 4.1.6.). + * 5. If this inspection failed for any reason, reject p with a newly created + * OperationError and abort these steps. + */ +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-description-attributes-timing.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-description-attributes-timing.https.html new file mode 100644 index 0000000000..2d2565c3e1 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-description-attributes-timing.https.html @@ -0,0 +1,81 @@ +<!doctype html> +<meta charset=utf-8> +<title></title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +'use strict'; + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const offer = await pc.createOffer(); + + assert_equals(pc.pendingLocalDescription, null, + 'pendingLocalDescription is null before setLocalDescription'); + const promise = pc.setLocalDescription(offer); + assert_equals(pc.pendingLocalDescription, null, + 'pendingLocalDescription is still null while promise pending'); + await promise; + assert_not_equals(pc.pendingLocalDescription, null, + 'pendingLocalDescription is not null after await'); +}, "pendingLocalDescription is surfaced at the right time"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const offer = await pc.createOffer(); + + assert_equals(pc.pendingRemoteDescription, null, + 'pendingRemoteDescription is null before setRemoteDescription'); + const promise = pc.setRemoteDescription(offer); + assert_equals(pc.pendingRemoteDescription, null, + 'pendingRemoteDescription is still null while promise pending'); + await promise; + assert_not_equals(pc.pendingRemoteDescription, null, + 'pendingRemoteDescription is not null after await'); +}, "pendingRemoteDescription is surfaced at the right time"); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + await pc2.setRemoteDescription(offer); + const answer = await pc2.createAnswer(); + + assert_equals(pc2.currentLocalDescription, null, + 'currentLocalDescription is null before setLocalDescription'); + const promise = pc2.setLocalDescription(answer); + assert_equals(pc2.currentLocalDescription, null, + 'currentLocalDescription is still null while promise pending'); + await promise; + assert_not_equals(pc2.currentLocalDescription, null, + 'currentLocalDescription is not null after await'); +}, "currentLocalDescription is surfaced at the right time"); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + await pc2.setRemoteDescription(offer); + const answer = await pc2.createAnswer(); + + assert_equals(pc1.currentRemoteDescription, null, + 'currentRemoteDescription is null before setRemoteDescription'); + const promise = pc1.setRemoteDescription(answer); + assert_equals(pc1.currentRemoteDescription, null, + 'currentRemoteDescription is still null while promise pending'); + await promise; + assert_not_equals(pc1.currentRemoteDescription, null, + 'currentRemoteDescription is not null after await'); +}, "currentRemoteDescription is surfaced at the right time"); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-explicit-rollback-iceGatheringState.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-explicit-rollback-iceGatheringState.html new file mode 100644 index 0000000000..e39b985bef --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-explicit-rollback-iceGatheringState.html @@ -0,0 +1,53 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.iceGatheringState</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver('audio', { direction: 'recvonly' }); + await initialOfferAnswerWithIceGatheringStateTransitions( + pc1, pc2); + await pc1.setLocalDescription(await pc1.createOffer({iceRestart: true})); + await iceGatheringStateTransitions(pc1, 'gathering', 'complete'); + expectNoMoreGatheringStateChanges(t, pc1); + await pc1.setLocalDescription({type: 'rollback'}); + await new Promise(r => t.step_timeout(r, 1000)); +}, 'rolling back an ICE restart when gathering is complete should not result in iceGatheringState changes'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + pc.addTransceiver('audio', { direction: 'recvonly' }); + await pc.setLocalDescription( + await pc.createOffer()); + await iceGatheringStateTransitions(pc, 'gathering', 'complete'); + await pc.setLocalDescription({type: 'rollback'}); + await iceGatheringStateTransitions(pc, 'new'); +}, 'setLocalDescription(rollback) of original offer should cause iceGatheringState to reach "new" when starting in "complete"'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + pc.addTransceiver('audio', { direction: 'recvonly' }); + await pc.setLocalDescription( + await pc.createOffer()); + await iceGatheringStateTransitions(pc, 'gathering'); + await pc.setLocalDescription({type: 'rollback'}); + // We might go directly to 'new', or we might go to 'complete' first, + // depending on timing. Allow either. + const results = await Promise.allSettled([ + iceGatheringStateTransitions(pc, 'new'), + iceGatheringStateTransitions(pc, 'complete', 'new')]); + assert_true(results.some(result => result.status == 'fulfilled'), + 'ICE gathering state should go back to "new", possibly through "complete"'); +}, 'setLocalDescription(rollback) of original offer should cause iceGatheringState to reach "new" when starting in "gathering"'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-generateCertificate.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-generateCertificate.html new file mode 100644 index 0000000000..4cda97e9b7 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-generateCertificate.html @@ -0,0 +1,138 @@ +<!doctype html> +<meta charset="utf-8"> +<title>Test RTCPeerConnection.generateCertificate</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170515/webrtc.html + + /* + * 4.10. Certificate Management + * partial interface RTCPeerConnection { + * static Promise<RTCCertificate> generateCertificate( + * AlgorithmIdentifier keygenAlgorithm); + * }; + * + * 4.10.2. RTCCertificate Interface + * interface RTCCertificate { + * readonly attribute DOMTimeStamp expires; + * ... + * }; + * + * [WebCrypto] + * 11. Algorithm Dictionary + * typedef (object or DOMString) AlgorithmIdentifier; + */ + + /* + * 4.10. The following values must be supported by a user agent: + * { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, + * publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + * and { name: "ECDSA", namedCurve: "P-256" }. + */ + promise_test(t => + RTCPeerConnection.generateCertificate({ + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256' + }).then(cert => { + assert_true(cert instanceof RTCCertificate, + 'Expect cert to be instance of RTCCertificate'); + + assert_greater_than(cert.expires, Date.now(), + 'Expect generated certificate to expire reasonably long after current time'); + }), + 'generateCertificate() with compulsary RSASSA-PKCS1-v1_5 parameters should succeed'); + + promise_test(t => + RTCPeerConnection.generateCertificate({ + name: 'ECDSA', + namedCurve: 'P-256' + }).then(cert => { + assert_true(cert instanceof RTCCertificate, + 'Expect cert to be instance of RTCCertificate'); + + assert_greater_than(cert.expires, Date.now(), + 'Expect generated certificate to expire reasonably long after current time'); + }), + 'generateCertificate() with compulsary ECDSA parameters should succeed'); + + /* + * 4.10. A user agent must reject a call to generateCertificate() with a + * DOMException of type NotSupportedError if the keygenAlgorithm + * parameter identifies an algorithm that the user agent cannot or + * will not use to generate a certificate for RTCPeerConnection. + */ + promise_test(t => + promise_rejects_dom(t, 'NotSupportedError', + RTCPeerConnection.generateCertificate('invalid-algo')), + 'generateCertificate() with invalid string algorithm should reject with NotSupportedError'); + + promise_test(t => + promise_rejects_dom(t, 'NotSupportedError', + RTCPeerConnection.generateCertificate({ + name: 'invalid-algo' + })), + 'generateCertificate() with invalid algorithm dict should reject with NotSupportedError'); + + /* + * 4.10.1. Dictionary RTCCertificateExpiration + * dictionary RTCCertificateExpiration { + * [EnforceRange] + * DOMTimeStamp expires; + * }; + * + * If this parameter is present it indicates the maximum time that + * the RTCCertificate is valid for relative to the current time. + * + * When generateCertificate is called with an object argument, + * the user agent attempts to convert the object into a + * RTCCertificateExpiration. If this is unsuccessful, immediately + * return a promise that is rejected with a newly created TypeError + * and abort processing. + */ + + promise_test(t => { + const start = Date.now(); + return RTCPeerConnection.generateCertificate({ + name: 'ECDSA', + namedCurve: 'P-256', + expires: 2000 + }).then(cert => { + assert_approx_equals(cert.expires, start+2000, 1000); + }) + }, 'generateCertificate() with valid expires parameter should succeed'); + + promise_test(t => { + return RTCPeerConnection.generateCertificate({ + name: 'ECDSA', + namedCurve: 'P-256', + expires: 0 + }).then(cert => { + assert_less_than_equal(cert.expires, Date.now()); + }) + }, 'generateCertificate() with 0 expires parameter should generate expired cert'); + + promise_test(t => { + return promise_rejects_js(t, TypeError, + RTCPeerConnection.generateCertificate({ + name: 'ECDSA', + namedCurve: 'P-256', + expires: -1 + })) + }, 'generateCertificate() with invalid range for expires should reject with TypeError'); + + promise_test(t => { + return promise_rejects_js(t, TypeError, + RTCPeerConnection.generateCertificate({ + name: 'ECDSA', + namedCurve: 'P-256', + expires: 'invalid' + })) + }, 'generateCertificate() with invalid type for expires should reject with TypeError'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-getStats.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-getStats.https.html new file mode 100644 index 0000000000..4889bcf4dd --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-getStats.https.html @@ -0,0 +1,422 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection.prototype.getStats</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script src="dictionary-helper.js"></script> +<script src="RTCStats-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // webrtc-pc 20171130 + // webrtc-stats 20171122 + + // The following helper function is called from RTCPeerConnection-helper.js + // getTrackFromUserMedia + + // The following helper function is called from RTCStats-helper.js + // validateStatsReport + // assert_stats_report_has_stats + + // The following helper function is called from RTCPeerConnection-helper.js + // exchangeIceCandidates + // exchangeOfferAnswer + + /* + 8.2. getStats + 1. Let selectorArg be the method's first argument. + 2. Let connection be the RTCPeerConnection object on which the method was invoked. + 3. If selectorArg is null, let selector be null. + 4. If selectorArg is a MediaStreamTrack let selector be an RTCRtpSender + or RTCRtpReceiver on connection which track member matches selectorArg. + If no such sender or receiver exists, or if more than one sender or + receiver fit this criteria, return a promise rejected with a newly + created InvalidAccessError. + 5. Let p be a new promise. + 6. Run the following steps in parallel: + 1. Gather the stats indicated by selector according to the stats selection algorithm. + 2. Resolve p with the resulting RTCStatsReport object, containing the gathered stats. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + return pc.getStats(); + }, 'getStats() with no argument should succeed'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + return pc.getStats(null); + }, 'getStats(null) should succeed'); + + /* + 8.2. getStats + 4. If selectorArg is a MediaStreamTrack let selector be an RTCRtpSender + or RTCRtpReceiver on connection which track member matches selectorArg. + If no such sender or receiver exists, or if more than one sender or + receiver fit this criteria, return a promise rejected with a newly + created InvalidAccessError. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + return getTrackFromUserMedia('audio') + .then(([track, mediaStream]) => { + return promise_rejects_dom(t, 'InvalidAccessError', pc.getStats(track)); + }); + }, 'getStats() with track not added to connection should reject with InvalidAccessError'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + return getTrackFromUserMedia('audio') + .then(([track, mediaStream]) => { + pc.addTrack(track, mediaStream); + return pc.getStats(track); + }); + }, 'getStats() with track added via addTrack should succeed'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + pc.addTransceiver(track); + + return pc.getStats(track); + }, 'getStats() with track added via addTransceiver should succeed'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver1 = pc.addTransceiver('audio'); + + // Create another transceiver that resends what + // is being received, kind of like echo + const transceiver2 = pc.addTransceiver(transceiver1.receiver.track); + assert_equals(transceiver1.receiver.track, transceiver2.sender.track); + + return promise_rejects_dom(t, 'InvalidAccessError', pc.getStats(transceiver1.receiver.track)); + }, 'getStats() with track associated with both sender and receiver should reject with InvalidAccessError'); + + /* + 8.5. The stats selection algorithm + 2. If selector is null, gather stats for the whole connection, add them to result, + return result, and abort these steps. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + return pc.getStats() + .then(statsReport => { + validateStatsReport(statsReport); + assert_stats_report_has_stats(statsReport, ['peer-connection']); + }); + }, 'getStats() with no argument should return stats report containing peer-connection stats on an empty PC'); + + promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + const [sendtrack, mediaStream] = await getTrackFromUserMedia('audio'); + pc.addTrack(sendtrack, mediaStream); + exchangeIceCandidates(pc, pc2); + await Promise.all([ + exchangeOfferAnswer(pc, pc2), + new Promise(r => pc2.ontrack = e => e.track.onunmute = r) + ]); + const statsReport = await pc.getStats(); + getRequiredStats(statsReport, 'peer-connection'); + getRequiredStats(statsReport, 'outbound-rtp'); + }, 'getStats() track with stream returns peer-connection and outbound-rtp stats'); + + promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + const [sendtrack, mediaStream] = await getTrackFromUserMedia('audio'); + pc.addTrack(sendtrack); + exchangeIceCandidates(pc, pc2); + await Promise.all([ + exchangeOfferAnswer(pc, pc2), + new Promise(r => pc2.ontrack = e => e.track.onunmute = r) + ]); + const statsReport = await pc.getStats(); + getRequiredStats(statsReport, 'peer-connection'); + getRequiredStats(statsReport, 'outbound-rtp'); + }, 'getStats() track without stream returns peer-connection and outbound-rtp stats'); + + promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + const [sendtrack, mediaStream] = await getTrackFromUserMedia('audio'); + pc.addTrack(sendtrack, mediaStream); + exchangeIceCandidates(pc, pc2); + await Promise.all([ + exchangeOfferAnswer(pc, pc2), + new Promise(r => pc2.ontrack = e => e.track.onunmute = r) + ]); + const statsReport = await pc.getStats(); + assert_stats_report_has_stats(statsReport, ['outbound-rtp']); + }, 'getStats() audio outbound-rtp contains all mandatory stats'); + + promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + const [sendtrack, mediaStream] = await getTrackFromUserMedia('video'); + pc.addTrack(sendtrack, mediaStream); + exchangeIceCandidates(pc, pc2); + await Promise.all([ + exchangeOfferAnswer(pc, pc2), + new Promise(r => pc2.ontrack = e => e.track.onunmute = r) + ]); + const statsReport = await pc.getStats(); + assert_stats_report_has_stats(statsReport, ['outbound-rtp']); + }, 'getStats() video outbound-rtp contains all mandatory stats'); + + promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + const [audioTrack, audioStream] = await getTrackFromUserMedia('audio'); + pc.addTrack(audioTrack, audioStream); + const [videoTrack, videoStream] = await getTrackFromUserMedia('video'); + pc.addTrack(videoTrack, videoStream); + exchangeIceCandidates(pc, pc2); + await Promise.all([ + exchangeOfferAnswer(pc, pc2), + new Promise(r => pc2.ontrack = e => e.track.onunmute = r) + ]); + const statsReport = await pc.getStats(); + validateStatsReport(statsReport); + }, 'getStats() audio and video validate all mandatory stats'); + + /* + 8.5. The stats selection algorithm + 3. If selector is an RTCRtpSender, gather stats for and add the following objects + to result: + - All RTCOutboundRtpStreamStats objects corresponding to selector. + - All stats objects referenced directly or indirectly by the RTCOutboundRtpStreamStats + objects added. + */ + promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + + let [sendtrack, mediaStream] = await getTrackFromUserMedia('audio'); + pc.addTrack(sendtrack, mediaStream); + exchangeIceCandidates(pc, pc2); + await Promise.all([ + exchangeOfferAnswer(pc, pc2), + new Promise(r => pc2.ontrack = e => e.track.onunmute = r) + ]); + const stats = await pc.getStats(sendtrack); + getRequiredStats(stats, 'outbound-rtp'); + }, `getStats() on track associated with RTCRtpSender should return stats report containing outbound-rtp stats`); + + /* + 8.5. The stats selection algorithm + 4. If selector is an RTCRtpReceiver, gather stats for and add the following objects + to result: + - All RTCInboundRtpStreamStats objects corresponding to selector. + - All stats objects referenced directly or indirectly by the RTCInboundRtpStreamStats + added. + */ + promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + + let [track, mediaStream] = await getTrackFromUserMedia('audio'); + pc.addTrack(track, mediaStream); + exchangeIceCandidates(pc, pc2); + await exchangeOfferAnswer(pc, pc2); + // Wait for unmute if the track is not already unmuted. + // According to spec, it should be muted when being created, but this + // is not what this test is testing, so allow it to be unmuted. + if (pc2.getReceivers()[0].track.muted) { + await new Promise(resolve => { + pc2.getReceivers()[0].track.addEventListener('unmute', resolve); + }); + } + const stats = await pc2.getStats(pc2.getReceivers()[0].track); + getRequiredStats(stats, 'inbound-rtp'); + }, `getStats() on track associated with RTCRtpReceiver should return stats report containing inbound-rtp stats`); + + promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + + let [track, mediaStream] = await getTrackFromUserMedia('audio'); + pc.addTrack(track, mediaStream); + exchangeIceCandidates(pc, pc2); + await exchangeOfferAnswer(pc, pc2); + // Wait for unmute if the track is not already unmuted. + // According to spec, it should be muted when being created, but this + // is not what this test is testing, so allow it to be unmuted. + if (pc2.getReceivers()[0].track.muted) { + await new Promise(resolve => { + pc2.getReceivers()[0].track.addEventListener('unmute', resolve); + }); + } + const stats = await pc2.getStats(pc2.getReceivers()[0].track); + getRequiredStats(stats, 'inbound-rtp'); + }, `getStats() inbound-rtp contains all mandatory stats`); + + /* + 8.6 Mandatory To Implement Stats + An implementation MUST support generating statistics of the following types + when the corresponding objects exist on a PeerConnection, with the attributes + that are listed when they are valid for that object. + */ + + const mandatoryStats = [ + "codec", + "inbound-rtp", + "outbound-rtp", + "remote-inbound-rtp", + "remote-outbound-rtp", + "media-source", + "peer-connection", + "data-channel", + "sender", + "receiver", + "transport", + "candidate-pair", + "local-candidate", + "remote-candidate", + "certificate" + ]; + + async_test(t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const dataChannel = pc1.createDataChannel('test-channel'); + + getNoiseStream({ + audio: true, + video: true + }) + .then(t.step_func(mediaStream => { + const tracks = mediaStream.getTracks(); + const [audioTrack] = mediaStream.getAudioTracks(); + const [videoTrack] = mediaStream.getVideoTracks(); + + for (const track of mediaStream.getTracks()) { + t.add_cleanup(() => track.stop()); + pc1.addTrack(track, mediaStream); + } + + const testStatsReport = (pc, statsReport) => { + validateStatsReport(statsReport); + assert_stats_report_has_stats(statsReport, mandatoryStats); + + const dataChannelStats = findStatsFromReport(statsReport, + stats => { + return stats.type === 'data-channel' && + stats.dataChannelIdentifier === dataChannel.id; + }, + 'Expect data channel stats to be found'); + + assert_equals(dataChannelStats.label, 'test-channel'); + + /* TODO track stats are obsolete - replace with sender/receiver? */ + const audioTrackStats = findStatsFromReport(statsReport, + stats => { + return stats.type === 'track' && + stats.trackIdentifier === audioTrack.id; + }, + 'Expect audio track stats to be found'); + + assert_equals(audioTrackStats.kind, 'audio'); + + const videoTrackStats = findStatsFromReport(statsReport, + stats => { + return stats.type === 'track' && + stats.trackIdentifier === videoTrack.id; + }, + 'Expect video track stats to be found'); + + assert_equals(videoTrackStats.kind, 'video'); + } + + const onConnected = t.step_func(() => { + // Wait a while for the peer connections to collect stats + t.step_timeout(() => { + Promise.all([ + /* TODO: for both pc1 and pc2 to expose all mandatory stats, they need to both send/receive tracks and data channels */ + pc1.getStats() + .then(statsReport => testStatsReport(pc1, statsReport)), + + pc2.getStats() + .then(statsReport => testStatsReport(pc2, statsReport)) + ]) + .then(t.step_func_done()) + .catch(t.step_func(err => { + assert_unreached(`test failed with error: ${err}`); + })); + }, 200) + }) + + let onTrackCount = 0 + let onDataChannelCalled = false + + pc2.addEventListener('track', t.step_func(() => { + onTrackCount++; + if (onTrackCount === 2 && onDataChannelCalled) { + onConnected(); + } + })); + + pc2.addEventListener('datachannel', t.step_func(() => { + onDataChannelCalled = true; + if (onTrackCount === 2) { + onConnected(); + } + })); + + + exchangeIceCandidates(pc1, pc2); + exchangeOfferAnswer(pc1, pc2); + })) + .catch(t.step_func(err => { + assert_unreached(`test failed with error: ${err}`); + })); + + }, `getStats() with connected peer connections having tracks and data channel should return all mandatory to implement stats`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const [track, mediaStream] = await getTrackFromUserMedia('audio'); + pc.addTransceiver(track); + pc.addTransceiver(track); + await promise_rejects_dom(t, 'InvalidAccessError', pc.getStats(track)); + }, `getStats(track) should not work if multiple senders have the same track`); + + promise_test(async t => { + const kMinimumTimeElapsedBetweenGetStatsCallsMs = 500; + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const t0 = Math.floor(performance.now()); + const t0Stats = getRequiredStats(await pc.getStats(), 'peer-connection'); + await new Promise( + r => t.step_timeout(r, kMinimumTimeElapsedBetweenGetStatsCallsMs)); + const t1Stats = getRequiredStats(await pc.getStats(), 'peer-connection'); + const t1 = Math.ceil(performance.now()); + const maximumTimeElapsedBetweenGetStatsCallsMs = t1 - t0; + const deltaTimestampMs = t1Stats.timestamp - t0Stats.timestamp; + // The delta must be at least the time we waited between calls. + assert_greater_than_equal(deltaTimestampMs, + kMinimumTimeElapsedBetweenGetStatsCallsMs); + // The delta must be at most the time elapsed before the first getStats() + // call and after the second getStats() call. + assert_less_than_equal(deltaTimestampMs, + maximumTimeElapsedBetweenGetStatsCallsMs); + }, `RTCStats.timestamp increases with time passing`); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-getTransceivers.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-getTransceivers.html new file mode 100644 index 0000000000..381b42b0cf --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-getTransceivers.html @@ -0,0 +1,39 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.getTransceivers</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + /* + * 5.1. RTCPeerConnection Interface Extensions + * partial interface RTCPeerConnection { + * sequence<RTCRtpSender> getSenders(); + * sequence<RTCRtpReceiver> getReceivers(); + * sequence<RTCRtpTransceiver> getTransceivers(); + * ... + * }; + */ + + test(t => { + const pc = new RTCPeerConnection(); + + assert_idl_attribute(pc, 'getSenders'); + const senders = pc.getSenders(); + assert_array_equals([], senders, 'Expect senders to be empty array'); + + assert_idl_attribute(pc, 'getReceivers'); + const receivers = pc.getReceivers(); + assert_array_equals([], receivers, 'Expect receivers to be empty array'); + + assert_idl_attribute(pc, 'getTransceivers'); + const transceivers = pc.getTransceivers(); + assert_array_equals([], transceivers, 'Expect transceivers to be empty array'); + + }, 'Initial peer connection should have list of zero senders, receivers and transceivers'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-helper-test.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-helper-test.html new file mode 100644 index 0000000000..42f6652ac4 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-helper-test.html @@ -0,0 +1,21 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection-helper tests</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const transceiver = pc1.addTransceiver('video'); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + await waitForState(transceiver.sender.transport, 'connected'); +}, 'Setting up a connection using helpers and defaults should work'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-helper.js b/testing/web-platform/tests/webrtc/RTCPeerConnection-helper.js new file mode 100644 index 0000000000..eefe10579b --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-helper.js @@ -0,0 +1,722 @@ +'use strict' + +/* + * Helper Methods for testing the following methods in RTCPeerConnection: + * createOffer + * createAnswer + * setLocalDescription + * setRemoteDescription + * + * This file offers the following features: + * SDP similarity comparison + * Generating offer/answer using anonymous peer connection + * Test signalingstatechange event + * Test promise that never resolve + */ + +const audioLineRegex = /\r\nm=audio.+\r\n/g; +const videoLineRegex = /\r\nm=video.+\r\n/g; +const applicationLineRegex = /\r\nm=application.+\r\n/g; + +function countLine(sdp, regex) { + const matches = sdp.match(regex); + if(matches === null) { + return 0; + } else { + return matches.length; + } +} + +function countAudioLine(sdp) { + return countLine(sdp, audioLineRegex); +} + +function countVideoLine(sdp) { + return countLine(sdp, videoLineRegex); +} + +function countApplicationLine(sdp) { + return countLine(sdp, applicationLineRegex); +} + +function similarMediaDescriptions(sdp1, sdp2) { + if(sdp1 === sdp2) { + return true; + } else if( + countAudioLine(sdp1) !== countAudioLine(sdp2) || + countVideoLine(sdp1) !== countVideoLine(sdp2) || + countApplicationLine(sdp1) !== countApplicationLine(sdp2)) + { + return false; + } else { + return true; + } +} + +// Assert that given object is either an +// RTCSessionDescription or RTCSessionDescriptionInit +function assert_is_session_description(sessionDesc) { + if(sessionDesc instanceof RTCSessionDescription) { + return; + } + + assert_not_equals(sessionDesc, undefined, + 'Expect session description to be defined'); + + assert_true(typeof(sessionDesc) === 'object', + 'Expect sessionDescription to be either a RTCSessionDescription or an object'); + + assert_true(typeof(sessionDesc.type) === 'string', + 'Expect sessionDescription.type to be a string'); + + assert_true(typeof(sessionDesc.sdp) === 'string', + 'Expect sessionDescription.sdp to be a string'); +} + + +// We can't do string comparison to the SDP content, +// because RTCPeerConnection may return SDP that is +// slightly modified or reordered from what is given +// to it due to ICE candidate events or serialization. +// Instead, we create SDP with different number of media +// lines, and if the SDP strings are not the same, we +// simply count the media description lines and if they +// are the same, we assume it is the same. +function isSimilarSessionDescription(sessionDesc1, sessionDesc2) { + assert_is_session_description(sessionDesc1); + assert_is_session_description(sessionDesc2); + + if(sessionDesc1.type !== sessionDesc2.type) { + return false; + } else { + return similarMediaDescriptions(sessionDesc1.sdp, sessionDesc2.sdp); + } +} + +function assert_session_desc_similar(sessionDesc1, sessionDesc2) { + assert_true(isSimilarSessionDescription(sessionDesc1, sessionDesc2), + 'Expect both session descriptions to have the same count of media lines'); +} + +function assert_session_desc_not_similar(sessionDesc1, sessionDesc2) { + assert_false(isSimilarSessionDescription(sessionDesc1, sessionDesc2), + 'Expect both session descriptions to have different count of media lines'); +} + +async function generateDataChannelOffer(pc) { + pc.createDataChannel('test'); + const offer = await pc.createOffer(); + assert_equals(countApplicationLine(offer.sdp), 1, 'Expect m=application line to be present in generated SDP'); + return offer; +} + +async function generateAudioReceiveOnlyOffer(pc) +{ + try { + pc.addTransceiver('audio', { direction: 'recvonly' }); + return pc.createOffer(); + } catch(e) { + return pc.createOffer({ offerToReceiveAudio: true }); + } +} + +async function generateVideoReceiveOnlyOffer(pc) +{ + try { + pc.addTransceiver('video', { direction: 'recvonly' }); + return pc.createOffer(); + } catch(e) { + return pc.createOffer({ offerToReceiveVideo: true }); + } +} + +// Helper function to generate answer based on given offer using a freshly +// created RTCPeerConnection object +async function generateAnswer(offer) { + const pc = new RTCPeerConnection(); + await pc.setRemoteDescription(offer); + const answer = await pc.createAnswer(); + pc.close(); + return answer; +} + +// Helper function to generate offer using a freshly +// created RTCPeerConnection object +async function generateOffer() { + const pc = new RTCPeerConnection(); + const offer = await pc.createOffer(); + pc.close(); + return offer; +} + +// Run a test function that return a promise that should +// never be resolved. For lack of better options, +// we wait for a time out and pass the test if the +// promise doesn't resolve within that time. +function test_never_resolve(testFunc, testName) { + async_test(t => { + testFunc(t) + .then( + t.step_func(result => { + assert_unreached(`Pending promise should never be resolved. Instead it is fulfilled with: ${result}`); + }), + t.step_func(err => { + assert_unreached(`Pending promise should never be resolved. Instead it is rejected with: ${err}`); + })); + + t.step_timeout(t.step_func_done(), 100) + }, testName); +} + +// Helper function to exchange ice candidates between +// two local peer connections +function exchangeIceCandidates(pc1, pc2) { + // private function + function doExchange(localPc, remotePc) { + localPc.addEventListener('icecandidate', event => { + const { candidate } = event; + + // Guard against already closed peerconnection to + // avoid unrelated exceptions. + if (remotePc.signalingState !== 'closed') { + remotePc.addIceCandidate(candidate); + } + }); + } + + doExchange(pc1, pc2); + doExchange(pc2, pc1); +} + +// Returns a promise that resolves when a |name| event is fired. +function waitUntilEvent(obj, name) { + return new Promise(r => obj.addEventListener(name, r, {once: true})); +} + +// Returns a promise that resolves when the |transport.state| is |state| +// This should work for RTCSctpTransport, RTCDtlsTransport and RTCIceTransport. +async function waitForState(transport, state) { + while (transport.state != state) { + await waitUntilEvent(transport, 'statechange'); + } +} + +// Returns a promise that resolves when |pc.iceConnectionState| is 'connected' +// or 'completed'. +async function listenToIceConnected(pc) { + await waitForIceStateChange(pc, ['connected', 'completed']); +} + +// Returns a promise that resolves when |pc.iceConnectionState| is in one of the +// wanted states. +async function waitForIceStateChange(pc, wantedStates) { + while (!wantedStates.includes(pc.iceConnectionState)) { + await waitUntilEvent(pc, 'iceconnectionstatechange'); + } +} + +// Returns a promise that resolves when |pc.connectionState| is 'connected'. +async function listenToConnected(pc) { + while (pc.connectionState != 'connected') { + await waitUntilEvent(pc, 'connectionstatechange'); + } +} + +// Returns a promise that resolves when |pc.connectionState| is in one of the +// wanted states. +async function waitForConnectionStateChange(pc, wantedStates) { + while (!wantedStates.includes(pc.connectionState)) { + await waitUntilEvent(pc, 'connectionstatechange'); + } +} + +async function waitForIceGatheringState(pc, wantedStates) { + while (!wantedStates.includes(pc.iceGatheringState)) { + await waitUntilEvent(pc, 'icegatheringstatechange'); + } +} + +// Resolves when RTP packets have been received. +async function listenForSSRCs(t, receiver) { + while (true) { + const ssrcs = receiver.getSynchronizationSources(); + if (Array.isArray(ssrcs) && ssrcs.length > 0) { + return ssrcs; + } + await new Promise(r => t.step_timeout(r, 0)); + } +} + +// Helper function to create a pair of connected data channels. +// On success the promise resolves to an array with two data channels. +// It does the heavy lifting of performing signaling handshake, +// ICE candidate exchange, and waiting for data channel at two +// end points to open. Can do both negotiated and non-negotiated setup. +async function createDataChannelPair(t, options, + pc1 = createPeerConnectionWithCleanup(t), + pc2 = createPeerConnectionWithCleanup(t)) { + let pair = [], bothOpen; + try { + if (options.negotiated) { + pair = [pc1, pc2].map(pc => pc.createDataChannel('', options)); + bothOpen = Promise.all(pair.map(dc => new Promise((r, e) => { + dc.onopen = r; + dc.onerror = ({error}) => e(error); + }))); + } else { + pair = [pc1.createDataChannel('', options)]; + bothOpen = Promise.all([ + new Promise((r, e) => { + pair[0].onopen = r; + pair[0].onerror = ({error}) => e(error); + }), + new Promise((r, e) => pc2.ondatachannel = ({channel}) => { + pair[1] = channel; + channel.onopen = r; + channel.onerror = ({error}) => e(error); + }) + ]); + } + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + await bothOpen; + return pair; + } finally { + for (const dc of pair) { + dc.onopen = dc.onerror = null; + } + } +} + +// Wait for RTP and RTCP stats to arrive +async function waitForRtpAndRtcpStats(pc) { + // If remote stats are never reported, return after 5 seconds. + const startTime = performance.now(); + while (true) { + const report = await pc.getStats(); + const stats = [...report.values()].filter(({type}) => type.endsWith("bound-rtp")); + // Each RTP and RTCP stat has a reference + // to the matching stat in the other direction + if (stats.length && stats.every(({localId, remoteId}) => localId || remoteId)) { + break; + } + if (performance.now() > startTime + 5000) { + break; + } + } +} + +// Wait for a single message event and return +// a promise that resolve when the event fires +function awaitMessage(channel) { + const once = true; + return new Promise((resolve, reject) => { + channel.addEventListener('message', ({data}) => resolve(data), {once}); + channel.addEventListener('error', reject, {once}); + }); +} + +// Helper to convert a blob to array buffer so that +// we can read the content +async function blobToArrayBuffer(blob) { + const reader = new FileReader(); + reader.readAsArrayBuffer(blob); + return new Promise((resolve, reject) => { + reader.addEventListener('load', () => resolve(reader.result), {once: true}); + reader.addEventListener('error', () => reject(reader.error), {once: true}); + }); +} + +// Assert that two TypedArray or ArrayBuffer objects have the same byte values +function assert_equals_typed_array(array1, array2) { + const [view1, view2] = [array1, array2].map((array) => { + if (array instanceof ArrayBuffer) { + return new DataView(array); + } else { + assert_true(array.buffer instanceof ArrayBuffer, + 'Expect buffer to be instance of ArrayBuffer'); + return new DataView(array.buffer, array.byteOffset, array.byteLength); + } + }); + + assert_equals(view1.byteLength, view2.byteLength, + 'Expect both arrays to be of the same byte length'); + + const byteLength = view1.byteLength; + + for (let i = 0; i < byteLength; ++i) { + assert_equals(view1.getUint8(i), view2.getUint8(i), + `Expect byte at buffer position ${i} to be equal`); + } +} + +// These media tracks will be continually updated with deterministic "noise" in +// order to ensure UAs do not cease transmission in response to apparent +// silence. +// +// > Many codecs and systems are capable of detecting "silence" and changing +// > their behavior in this case by doing things such as not transmitting any +// > media. +// +// Source: https://w3c.github.io/webrtc-pc/#offer-answer-options +const trackFactories = { + // Share a single context between tests to avoid exceeding resource limits + // without requiring explicit destruction. + audioContext: null, + + /** + * Given a set of requested media types, determine if the user agent is + * capable of procedurally generating a suitable media stream. + * + * @param {object} requested + * @param {boolean} [requested.audio] - flag indicating whether the desired + * stream should include an audio track + * @param {boolean} [requested.video] - flag indicating whether the desired + * stream should include a video track + * + * @returns {boolean} + */ + canCreate(requested) { + const supported = { + audio: !!window.AudioContext && !!window.MediaStreamAudioDestinationNode, + video: !!HTMLCanvasElement.prototype.captureStream + }; + + return (!requested.audio || supported.audio) && + (!requested.video || supported.video); + }, + + audio() { + const ctx = trackFactories.audioContext = trackFactories.audioContext || + new AudioContext(); + const oscillator = ctx.createOscillator(); + const dst = oscillator.connect(ctx.createMediaStreamDestination()); + oscillator.start(); + return dst.stream.getAudioTracks()[0]; + }, + + video({width = 640, height = 480, signal} = {}) { + const canvas = Object.assign( + document.createElement("canvas"), {width, height} + ); + const ctx = canvas.getContext('2d'); + const stream = canvas.captureStream(); + + let count = 0; + const interval = setInterval(() => { + ctx.fillStyle = `rgb(${count%255}, ${count*count%255}, ${count%255})`; + count += 1; + ctx.fillRect(0, 0, width, height); + // Add some bouncing boxes in contrast color to add a little more noise. + const contrast = count + 128; + ctx.fillStyle = `rgb(${contrast%255}, ${contrast*contrast%255}, ${contrast%255})`; + const xpos = count % (width - 20); + const ypos = count % (height - 20); + ctx.fillRect(xpos, ypos, xpos + 20, ypos + 20); + const xpos2 = (count + width / 2) % (width - 20); + const ypos2 = (count + height / 2) % (height - 20); + ctx.fillRect(xpos2, ypos2, xpos2 + 20, ypos2 + 20); + // If signal is set (0-255), add a constant-color box of that luminance to + // the video frame at coordinates 20 to 60 in both X and Y direction. + // (big enough to avoid color bleed from surrounding video in some codecs, + // for more stable tests). + if (signal != undefined) { + ctx.fillStyle = `rgb(${signal}, ${signal}, ${signal})`; + ctx.fillRect(20, 20, 40, 40); + } + }, 100); + + if (document.body) { + document.body.appendChild(canvas); + } else { + document.addEventListener('DOMContentLoaded', () => { + document.body.appendChild(canvas); + }, {once: true}); + } + + // Implement track.stop() for performance in some tests on some platforms + const track = stream.getVideoTracks()[0]; + const nativeStop = track.stop; + track.stop = function stop() { + clearInterval(interval); + nativeStop.apply(this); + if (document.body && canvas.parentElement == document.body) { + document.body.removeChild(canvas); + } + }; + return track; + } +}; + +// Get the signal from a video element inserted by createNoiseStream +function getVideoSignal(v) { + if (v.videoWidth < 60 || v.videoHeight < 60) { + throw new Error('getVideoSignal: video too small for test'); + } + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 60; + const context = canvas.getContext('2d'); + context.drawImage(v, 0, 0); + // Extract pixel value at position 40, 40 + const pixel = context.getImageData(40, 40, 1, 1); + // Use luma reconstruction to get back original value according to + // ITU-R rec BT.709 + return (pixel.data[0] * 0.21 + pixel.data[1] * 0.72 + pixel.data[2] * 0.07); +} + +async function detectSignal(t, v, value) { + while (true) { + const signal = getVideoSignal(v).toFixed(); + // allow off-by-two pixel error (observed in some implementations) + if (value - 2 <= signal && signal <= value + 2) { + return; + } + // We would like to wait for each new frame instead here, + // but there seems to be no such callback. + await new Promise(r => t.step_timeout(r, 100)); + } +} + +// Generate a MediaStream bearing the specified tracks. +// +// @param {object} [caps] +// @param {boolean} [caps.audio] - flag indicating whether the generated stream +// should include an audio track +// @param {boolean} [caps.video] - flag indicating whether the generated stream +// should include a video track, or parameters for video +async function getNoiseStream(caps = {}) { + if (!trackFactories.canCreate(caps)) { + return navigator.mediaDevices.getUserMedia(caps); + } + const tracks = []; + + if (caps.audio) { + tracks.push(trackFactories.audio()); + } + + if (caps.video) { + tracks.push(trackFactories.video(caps.video)); + } + + return new MediaStream(tracks); +} + +// Obtain a MediaStreamTrack of kind using procedurally-generated streams (and +// falling back to `getUserMedia` when the user agent cannot generate the +// requested streams). +// Return Promise of pair of track and associated mediaStream. +// Assumes that there is at least one available device +// to generate the track. +function getTrackFromUserMedia(kind) { + return getNoiseStream({ [kind]: true }) + .then(mediaStream => { + const [track] = mediaStream.getTracks(); + return [track, mediaStream]; + }); +} + +// Obtain |count| MediaStreamTracks of type |kind| and MediaStreams. The tracks +// do not belong to any stream and the streams are empty. Returns a Promise +// resolved with a pair of arrays [tracks, streams]. +// Assumes there is at least one available device to generate the tracks and +// streams and that the getUserMedia() calls resolve. +function getUserMediaTracksAndStreams(count, type = 'audio') { + let otherTracksPromise; + if (count > 1) + otherTracksPromise = getUserMediaTracksAndStreams(count - 1, type); + else + otherTracksPromise = Promise.resolve([[], []]); + return otherTracksPromise.then(([tracks, streams]) => { + return getTrackFromUserMedia(type) + .then(([track, stream]) => { + // Remove the default stream-track relationship. + stream.removeTrack(track); + tracks.push(track); + streams.push(stream); + return [tracks, streams]; + }); + }); +} + +// Performs an offer exchange caller -> callee. +async function exchangeOffer(caller, callee) { + await caller.setLocalDescription(await caller.createOffer()); + await callee.setRemoteDescription(caller.localDescription); +} +// Performs an answer exchange caller -> callee. +async function exchangeAnswer(caller, callee) { + // Note that caller's remote description must be set first; if not, + // there's a chance that candidates from callee arrive at caller before + // it has a remote description to apply them to. + const answer = await callee.createAnswer(); + await caller.setRemoteDescription(answer); + await callee.setLocalDescription(answer); +} +async function exchangeOfferAnswer(caller, callee) { + await exchangeOffer(caller, callee); + await exchangeAnswer(caller, callee); +} + +// The returned promise is resolved with caller's ontrack event. +async function exchangeAnswerAndListenToOntrack(t, caller, callee) { + const ontrackPromise = addEventListenerPromise(t, caller, 'track'); + await exchangeAnswer(caller, callee); + return ontrackPromise; +} +// The returned promise is resolved with callee's ontrack event. +async function exchangeOfferAndListenToOntrack(t, caller, callee) { + const ontrackPromise = addEventListenerPromise(t, callee, 'track'); + await exchangeOffer(caller, callee); + return ontrackPromise; +} + +// The resolver extends a |promise| that can be resolved or rejected using |resolve| +// or |reject|. +class Resolver extends Promise { + constructor(executor) { + let resolve, reject; + super((resolve_, reject_) => { + resolve = resolve_; + reject = reject_; + if (executor) { + return executor(resolve_, reject_); + } + }); + + this._done = false; + this._resolve = resolve; + this._reject = reject; + } + + /** + * Return whether the promise is done (resolved or rejected). + */ + get done() { + return this._done; + } + + /** + * Resolve the promise. + */ + resolve(...args) { + this._done = true; + return this._resolve(...args); + } + + /** + * Reject the promise. + */ + reject(...args) { + this._done = true; + return this._reject(...args); + } +} + +function addEventListenerPromise(t, obj, type, listener) { + if (!listener) { + return waitUntilEvent(obj, type); + } + return new Promise(r => obj.addEventListener(type, + t.step_func(e => r(listener(e))), + {once: true})); +} + +function createPeerConnectionWithCleanup(t) { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + return pc; +} + +async function createTrackAndStreamWithCleanup(t, kind = 'audio') { + let constraints = {}; + constraints[kind] = true; + const stream = await getNoiseStream(constraints); + const [track] = stream.getTracks(); + t.add_cleanup(() => track.stop()); + return [track, stream]; +} + +function findTransceiverForSender(pc, sender) { + const transceivers = pc.getTransceivers(); + for (let i = 0; i < transceivers.length; ++i) { + if (transceivers[i].sender == sender) + return transceivers[i]; + } + return null; +} + +function preferCodec(transceiver, mimeType, sdpFmtpLine) { + const {codecs} = RTCRtpSender.getCapabilities(transceiver.receiver.track.kind); + // sdpFmtpLine is optional, pick the first partial match if not given. + const selectedCodecIndex = codecs.findIndex(c => { + return c.mimeType === mimeType && (c.sdpFmtpLine === sdpFmtpLine || !sdpFmtpLine); + }); + const selectedCodec = codecs[selectedCodecIndex]; + codecs.slice(selectedCodecIndex, 1); + codecs.unshift(selectedCodec); + return transceiver.setCodecPreferences(codecs); +} + +// Contains a set of values and will yell at you if you try to add a value twice. +class UniqueSet extends Set { + constructor(items) { + super(); + if (items !== undefined) { + for (const item of items) { + this.add(item); + } + } + } + + add(value, message) { + if (message === undefined) { + message = `Value '${value}' needs to be unique but it is already in the set`; + } + assert_true(!this.has(value), message); + super.add(value); + } +} + +const iceGatheringStateTransitions = async (pc, ...states) => { + for (const state of states) { + await new Promise((resolve, reject) => { + pc.addEventListener('icegatheringstatechange', () => { + if (pc.iceGatheringState == state) { + resolve(); + } else { + reject(`Unexpected gathering state: ${pc.iceGatheringState}, was expecting ${state}`); + } + }, {once: true}); + }); + } +}; + +const initialOfferAnswerWithIceGatheringStateTransitions = + async (pc1, pc2, offerOptions) => { + await pc1.setLocalDescription( + await pc1.createOffer(offerOptions)); + const pc1Transitions = + iceGatheringStateTransitions(pc1, 'gathering', 'complete'); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(await pc2.createAnswer()); + const pc2Transitions = + iceGatheringStateTransitions(pc2, 'gathering', 'complete'); + await pc1.setRemoteDescription(pc2.localDescription); + await pc1Transitions; + await pc2Transitions; + }; + +const expectNoMoreGatheringStateChanges = async (t, pc) => { + pc.onicegatheringstatechange = + t.step_func(() => { + assert_unreached( + 'Should not get an icegatheringstatechange right now!'); + }); +}; + +async function queueAWebrtcTask() { + const pc = new RTCPeerConnection(); + pc.addTransceiver('audio'); + await new Promise(r => pc.onnegotiationneeded = r); +} + diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-iceConnectionState-disconnected.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-iceConnectionState-disconnected.https.html new file mode 100644 index 0000000000..af55a0c003 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-iceConnectionState-disconnected.https.html @@ -0,0 +1,30 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection.prototype.iceConnectionState - disconnection</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream = await getNoiseStream({audio:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + caller.addTrack(track, stream); + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + + await listenToIceConnected(caller); + + callee.close(); + await waitForIceStateChange(caller, ['disconnected', 'failed']); + // TODO: this should eventually transition to failed but that takes + // somewhat long (15-30s) so is not testable. + }, 'ICE goes to disconnected if the other side goes away'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-iceConnectionState.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-iceConnectionState.https.html new file mode 100644 index 0000000000..5083be6cdf --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-iceConnectionState.https.html @@ -0,0 +1,396 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection.prototype.iceConnectionState</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + /* + 4.3.2. Interface Definition + interface RTCPeerConnection : EventTarget { + ... + readonly attribute RTCIceConnectionState iceConnectionState; + attribute EventHandler oniceconnectionstatechange; + }; + + 4.4.4 RTCIceConnectionState Enum + enum RTCIceConnectionState { + "new", + "checking", + "connected", + "completed", + "failed", + "disconnected", + "closed" + }; + + 5.6. RTCIceTransport Interface + interface RTCIceTransport { + readonly attribute RTCIceTransportState state; + attribute EventHandler onstatechange; + + ... + }; + + enum RTCIceTransportState { + "new", + "checking", + "connected", + "completed", + "failed", + "disconnected", + "closed" + }; + */ + + /* + 4.4.4 RTCIceConnectionState Enum + new + Any of the RTCIceTransports are in the new state and none of them + are in the checking, failed or disconnected state, or all + RTCIceTransport s are in the closed state. + */ + test(t => { + const pc = new RTCPeerConnection(); + assert_equals(pc.iceConnectionState, 'new'); + }, 'Initial iceConnectionState should be new'); + + test(t => { + const pc = new RTCPeerConnection(); + pc.close(); + assert_equals(pc.iceConnectionState, 'closed'); + }, 'Closing the connection should set iceConnectionState to closed'); + + /* + 4.4.4 RTCIceConnectionState Enum + checking + Any of the RTCIceTransport s are in the checking state and none of + them are in the failed or disconnected state. + + connected + All RTCIceTransport s are in the connected, completed or closed state + and at least one of them is in the connected state. + + completed + All RTCIceTransport s are in the completed or closed state and at least + one of them is in the completed state. + + checking + The RTCIceTransport has received at least one remote candidate and + is checking candidate pairs and has either not yet found a connection + or consent checks [RFC7675] have failed on all previously successful + candidate pairs. In addition to checking, it may also still be gathering. + + 5.6. enum RTCIceTransportState + connected + The RTCIceTransport has found a usable connection, but is still + checking other candidate pairs to see if there is a better connection. + It may also still be gathering and/or waiting for additional remote + candidates. If consent checks [RFC7675] fail on the connection in use, + and there are no other successful candidate pairs available, then the + state transitions to "checking" (if there are candidate pairs remaining + to be checked) or "disconnected" (if there are no candidate pairs to + check, but the peer is still gathering and/or waiting for additional + remote candidates). + + completed + The RTCIceTransport has finished gathering, received an indication that + there are no more remote candidates, finished checking all candidate + pairs and found a connection. If consent checks [RFC7675] subsequently + fail on all successful candidate pairs, the state transitions to "failed". + */ + async_test(t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + let had_checking = false; + + const onIceConnectionStateChange = t.step_func(() => { + const {iceConnectionState} = pc1; + if (iceConnectionState === 'checking') { + had_checking = true; + } else if (iceConnectionState === 'connected' || + iceConnectionState === 'completed') { + assert_true(had_checking, 'state should pass checking before' + + ' reaching connected or completed'); + t.done(); + } else if (iceConnectionState === 'failed') { + assert_unreached("ICE should not fail"); + } + }); + + pc1.createDataChannel('test'); + + pc1.addEventListener('iceconnectionstatechange', onIceConnectionStateChange); + + exchangeIceCandidates(pc1, pc2); + exchangeOfferAnswer(pc1, pc2); + }, 'connection with one data channel should eventually have connected or ' + + 'completed connection state'); + +async_test(t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + + t.add_cleanup(() => pc2.close()); + + const onIceConnectionStateChange = t.step_func(() => { + const { iceConnectionState } = pc1; + + if(iceConnectionState === 'checking') { + const iceTransport = pc1.sctp.transport.iceTransport; + + assert_equals(iceTransport.state, 'checking', + 'Expect ICE transport to be in checking state when' + + ' iceConnectionState is checking'); + + } else if(iceConnectionState === 'connected') { + const iceTransport = pc1.sctp.transport.iceTransport; + + assert_equals(iceTransport.state, 'connected', + 'Expect ICE transport to be in connected state when' + + ' iceConnectionState is connected'); + t.done(); + } else if(iceConnectionState === 'completed') { + const iceTransport = pc1.sctp.transport.iceTransport; + + assert_equals(iceTransport.state, 'completed', + 'Expect ICE transport to be in connected state when' + + ' iceConnectionState is completed'); + t.done(); + } else if (iceConnectionState === 'failed') { + assert_unreached("ICE should not fail"); + } + }); + + pc1.createDataChannel('test'); + + assert_equals(pc1.oniceconnectionstatechange, null, + 'Expect connection to have iceconnectionstatechange event'); + + pc1.addEventListener('iceconnectionstatechange', onIceConnectionStateChange); + + exchangeIceCandidates(pc1, pc2); + exchangeOfferAnswer(pc1, pc2); + }, 'connection with one data channel should eventually ' + + 'have connected connection state'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + stream.getTracks().forEach(track => pc1.addTrack(track, stream)); + + exchangeIceCandidates(pc1, pc2); + exchangeOfferAnswer(pc1, pc2); + await listenToIceConnected(pc1); + }, 'connection with audio track should eventually ' + + 'have connected connection state'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true, video:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + stream.getTracks().forEach(track => pc1.addTrack(track, stream)); + + exchangeIceCandidates(pc1, pc2); + exchangeOfferAnswer(pc1, pc2); + await listenToIceConnected(pc1); + }, 'connection with audio and video tracks should eventually ' + + 'have connected connection state'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + caller.addTransceiver('audio', {direction:'recvonly'}); + const stream = await getNoiseStream({audio:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + callee.addTrack(track, stream); + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + + assert_equals(caller.getTransceivers().length, 1); + const [transceiver] = caller.getTransceivers(); + assert_equals(transceiver.currentDirection, 'recvonly'); + + await listenToIceConnected(caller); + }, 'ICE can connect in a recvonly usecase'); + + /* + TODO + 4.4.4 RTCIceConnectionState Enum + failed + Any of the RTCIceTransport s are in the failed state. + + disconnected + Any of the RTCIceTransport s are in the disconnected state and none of + them are in the failed state. + + closed + The RTCPeerConnection object's [[ isClosed]] slot is true. + + 5.6. enum RTCIceTransportState + new + The RTCIceTransport is gathering candidates and/or waiting for + remote candidates to be supplied, and has not yet started checking. + + failed + The RTCIceTransport has finished gathering, received an indication that + there are no more remote candidates, finished checking all candidate pairs, + and all pairs have either failed connectivity checks or have lost consent. + + disconnected + The ICE Agent has determined that connectivity is currently lost for this + RTCIceTransport . This is more aggressive than failed, and may trigger + intermittently (and resolve itself without action) on a flaky network. + The way this state is determined is implementation dependent. + + Examples include: + Losing the network interface for the connection in use. + Repeatedly failing to receive a response to STUN requests. + + Alternatively, the RTCIceTransport has finished checking all existing + candidates pairs and failed to find a connection (or consent checks + [RFC7675] once successful, have now failed), but it is still gathering + and/or waiting for additional remote candidates. + + closed + The RTCIceTransport has shut down and is no longer responding to STUN requests. + */ + +for (let bundle_policy of ['balanced', 'max-bundle', 'max-compat']) { + + + promise_test(async t => { + const caller = new RTCPeerConnection({bundlePolicy: bundle_policy}); + t.add_cleanup(() => caller.close()); + const stream = await getNoiseStream( + {audio: true, video:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track1, track2] = stream.getTracks(); + const sender1 = caller.addTrack(track1); + const sender2 = caller.addTrack(track2); + caller.createDataChannel('datachannel'); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + exchangeIceCandidates(caller, callee); + const offer = await caller.createOffer(); + await caller.setLocalDescription(offer); + const [caller_transceiver1, caller_transceiver2] = caller.getTransceivers(); + assert_equals(sender1.transport, caller_transceiver1.sender.transport); + await callee.setRemoteDescription(offer); + const [callee_transceiver1, callee_transceiver2] = callee.getTransceivers(); + const answer = await callee.createAnswer(); + await callee.setLocalDescription(answer); + await caller.setRemoteDescription(answer); + // At this point, we should have a single ICE transport, and it + // should eventually get to the "connected" state. + await waitForState(caller_transceiver1.receiver.transport.iceTransport, + 'connected'); + // The PeerConnection's iceConnectionState should therefore be 'connected' + assert_equals(caller.iceConnectionState, 'connected', + 'PC.iceConnectionState:'); + }, 'iceConnectionState changes at the right time, with bundle policy ' + + bundle_policy); +} + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc1.onicecandidate = e => pc2.addIceCandidate(e.candidate); + pc1.candidateBuffer = []; + pc2.onicecandidate = e => { + // Don't add candidate if candidate buffer is already used + if (pc1.candidateBuffer) { + pc1.candidateBuffer.push(e.candidate) + } + }; + pc1.iceStates = [pc1.iceConnectionState]; + pc2.iceStates = [pc2.iceConnectionState]; + pc1.oniceconnectionstatechange = () => { + pc1.iceStates.push(pc1.iceConnectionState); + }; + pc2.oniceconnectionstatechange = () => { + pc2.iceStates.push(pc2.iceConnectionState); + }; + + const localStream = await getNoiseStream({audio: true, video: true}); + const localStream2 = await getNoiseStream({audio: true, video: true}); + const remoteStream = await getNoiseStream({audio: true, video: true}); + for (const stream of [localStream, localStream2, remoteStream]) { + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + } + localStream.getTracks().forEach(t => pc1.addTrack(t, localStream)); + localStream2.getTracks().forEach(t => pc1.addTrack(t, localStream2)); + remoteStream.getTracks().forEach(t => pc2.addTrack(t, remoteStream)); + const offer = await pc1.createOffer(); + await pc2.setRemoteDescription(offer); + await pc1.setLocalDescription(offer); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + pc1.candidateBuffer.forEach(c => pc1.addIceCandidate(c)); + delete pc1.candidateBuffer; + await listenToIceConnected(pc1); + await listenToIceConnected(pc2); + // While we're waiting for pc2, pc1 may or may not have transitioned + // to "completed" state, so allow for both cases. + if (pc1.iceStates.length == 3) { + assert_array_equals(pc1.iceStates, ['new', 'checking', 'connected']); + } else { + assert_array_equals(pc1.iceStates, ['new', 'checking', 'connected', + 'completed']); + } + assert_array_equals(pc2.iceStates, ['new', 'checking', 'connected']); +}, 'Responder ICE connection state behaves as expected'); + +/* + Test case for step 11 of PeerConnection.close(). + ... + 11. Set connection's ICE connection state to "closed". This does not invoke + the "update the ICE connection state" procedure, and does not fire any + event. + ... +*/ +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + + stream.getTracks().forEach(track => pc1.addTrack(track, stream)); + exchangeIceCandidates(pc1, pc2); + exchangeOfferAnswer(pc1, pc2); + await listenToIceConnected(pc2); + + pc2.oniceconnectionstatechange = t.unreached_func(); + pc2.close(); + assert_equals(pc2.iceConnectionState, 'closed'); + await new Promise(r => t.step_timeout(r, 100)); +}, 'Closing a PeerConnection should not fire iceconnectionstatechange event'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-iceGatheringState.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-iceGatheringState.html new file mode 100644 index 0000000000..6afaf0fbfb --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-iceGatheringState.html @@ -0,0 +1,244 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.iceGatheringState</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // exchangeAnswer + // exchangeIceCandidates + // generateAudioReceiveOnlyOffer + + /* + 4.3.2. Interface Definition + interface RTCPeerConnection : EventTarget { + ... + readonly attribute RTCIceGatheringState iceGatheringState; + attribute EventHandler onicegatheringstatechange; + }; + + 4.4.2. RTCIceGatheringState Enum + enum RTCIceGatheringState { + "new", + "gathering", + "complete" + }; + + 5.6. RTCIceTransport Interface + interface RTCIceTransport { + readonly attribute RTCIceGathererState gatheringState; + ... + }; + + enum RTCIceGathererState { + "new", + "gathering", + "complete" + }; + */ + + /* + 4.4.2. RTCIceGatheringState Enum + new + Any of the RTCIceTransport s are in the new gathering state and + none of the transports are in the gathering state, or there are + no transports. + */ + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_equals(pc.iceGatheringState, 'new'); + }, 'Initial iceGatheringState should be new'); + + async_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + let reachedGathering = false; + const onIceGatheringStateChange = t.step_func(() => { + const { iceGatheringState } = pc; + + if(iceGatheringState === 'gathering') { + reachedGathering = true; + } else if(iceGatheringState === 'complete') { + assert_true(reachedGathering, 'iceGatheringState should reach gathering before complete'); + t.done(); + } + }); + + assert_equals(pc.onicegatheringstatechange, null, + 'Expect connection to have icegatheringstatechange event'); + assert_equals(pc.iceGatheringState, 'new'); + + pc.addEventListener('icegatheringstatechange', onIceGatheringStateChange); + + generateAudioReceiveOnlyOffer(pc) + .then(offer => pc.setLocalDescription(offer)) + .then(err => t.step_func(err => + assert_unreached(`Unhandled rejection ${err.name}: ${err.message}`))); + }, 'iceGatheringState should eventually become complete after setLocalDescription'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver('audio', { direction: 'recvonly' }); + await initialOfferAnswerWithIceGatheringStateTransitions( + pc1, pc2); + + expectNoMoreGatheringStateChanges(t, pc1); + expectNoMoreGatheringStateChanges(t, pc2); + + await pc1.setLocalDescription(await pc1.createOffer()); + await pc2.setLocalDescription(await pc2.createOffer()); + + await new Promise(r => t.step_timeout(r, 500)); + }, 'setLocalDescription(reoffer) with no new transports should not cause iceGatheringState to change'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + + expectNoMoreGatheringStateChanges(t, pc1); + + await pc1.setLocalDescription(await pc1.createOffer()); + + await new Promise(r => t.step_timeout(r, 500)); + }, 'setLocalDescription() with no transports should not cause iceGatheringState to change'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver('audio', { direction: 'recvonly' }); + await initialOfferAnswerWithIceGatheringStateTransitions( + pc1, pc2); + await pc1.setLocalDescription(await pc1.createOffer({iceRestart: true})); + await iceGatheringStateTransitions(pc1, 'gathering', 'complete'); + }, 'setLocalDescription(reoffer) with a new transport should cause iceGatheringState to go to "checking" and then "complete"'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + expectNoMoreGatheringStateChanges(t, pc2); + pc1.addTransceiver('audio', { direction: 'recvonly' }); + const offer = await pc1.createOffer(); + await pc2.setRemoteDescription(offer); + await pc2.setRemoteDescription(offer); + await pc2.setRemoteDescription({type: 'rollback'}); + await pc2.setRemoteDescription(offer); + }, 'sRD does not cause ICE gathering state changes'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver('audio', { direction: 'recvonly' }); + await initialOfferAnswerWithIceGatheringStateTransitions( + pc1, pc2); + + const pc1waiter = iceGatheringStateTransitions(pc1, 'new'); + const pc2waiter = iceGatheringStateTransitions(pc2, 'new'); + pc1.getTransceivers()[0].stop(); + await pc1.setLocalDescription(await pc1.createOffer()); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(await pc2.createAnswer()); + assert_equals(pc2.getTransceivers().length, 0, + 'PC2 transceivers should be invisible after negotiation'); + assert_equals(pc2.iceGatheringState, 'new'); + await pc2waiter; + await pc1.setRemoteDescription(pc2.localDescription); + assert_equals(pc1.getTransceivers().length, 0, + 'PC1 transceivers should be invisible after negotiation'); + assert_equals(pc1.iceGatheringState, 'new'); + await pc1waiter; + }, 'renegotiation that closes all transports should result in ICE gathering state "new"'); + + /* + 4.3.2. RTCIceGatheringState Enum + new + Any of the RTCIceTransports are in the "new" gathering state and none + of the transports are in the "gathering" state, or there are no + transports. + + gathering + Any of the RTCIceTransport s are in the gathering state. + + complete + At least one RTCIceTransport exists, and all RTCIceTransports are + in the completed gathering state. + + 5.6. RTCIceGathererState + gathering + The RTCIceTransport is in the process of gathering candidates. + + complete + The RTCIceTransport has completed gathering and the end-of-candidates + indication for this transport has been sent. It will not gather candidates + again until an ICE restart causes it to restart. + */ + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + + t.add_cleanup(() => pc2.close()); + + const onIceGatheringStateChange = t.step_func(() => { + const { iceGatheringState } = pc2; + + if(iceGatheringState === 'gathering') { + const iceTransport = pc2.sctp.transport.iceTransport; + + assert_equals(iceTransport.gatheringState, 'gathering', + 'Expect ICE transport to be in gathering gatheringState when iceGatheringState is gathering'); + + } else if(iceGatheringState === 'complete') { + const iceTransport = pc2.sctp.transport.iceTransport; + + assert_equals(iceTransport.gatheringState, 'complete', + 'Expect ICE transport to be in complete gatheringState when iceGatheringState is complete'); + + t.done(); + } + }); + + pc1.createDataChannel('test'); + + // Spec bug w3c/webrtc-pc#1382 + // Because sctp is only defined when answer is set, we listen + // to pc2 so that we can be confident that sctp is defined + // when icegatheringstatechange event is fired. + pc2.addEventListener('icegatheringstatechange', onIceGatheringStateChange); + + + exchangeIceCandidates(pc1, pc2); + + await pc1.setLocalDescription(); + assert_equals(pc1.sctp.transport.iceTransport.gatheringState, 'new'); + await pc2.setRemoteDescription(pc1.localDescription); + + await exchangeAnswer(pc1, pc2); + }, 'connection with one data channel should eventually have connected connection state'); + + /* + TODO + 5.6. RTCIceTransport Interface + new + The RTCIceTransport was just created, and has not started gathering + candidates yet. + */ +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-mandatory-getStats.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-mandatory-getStats.https.html new file mode 100644 index 0000000000..099fba8eaf --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-mandatory-getStats.https.html @@ -0,0 +1,277 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>Mandatory-to-implement stats compliance (a subset of webrtc-stats)</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="RTCPeerConnection-helper.js"></script> +<script src="dictionary-helper.js"></script> +<script src="RTCStats-helper.js"></script> +<script> +'use strict'; + +// From https://w3c.github.io/webrtc-pc/#mandatory-to-implement-stats + +const mandatory = { + RTCRtpStreamStats: [ + "ssrc", + "kind", + "transportId", + "codecId", + ], + RTCReceivedRtpStreamStats: [ + "packetsReceived", + "packetsLost", + "jitter", + ], + RTCInboundRtpStreamStats: [ + "trackIdentifier", + "remoteId", + "framesDecoded", + "framesDropped", + "nackCount", + "framesReceived", + "bytesReceived", + "totalAudioEnergy", + "totalSamplesDuration", + "packetsDiscarded", + ], + RTCRemoteInboundRtpStreamStats: [ + "localId", + "roundTripTime", + ], + RTCSentRtpStreamStats: [ + "packetsSent", + "bytesSent" + ], + RTCOutboundRtpStreamStats: [ + "remoteId", + "framesEncoded", + "nackCount", + "framesSent" + ], + RTCRemoteOutboundRtpStreamStats: [ + "localId", + "remoteTimestamp", + ], + RTCPeerConnectionStats: [ + "dataChannelsOpened", + "dataChannelsClosed", + ], + RTCDataChannelStats: [ + "label", + "protocol", + "dataChannelIdentifier", + "state", + "messagesSent", + "bytesSent", + "messagesReceived", + "bytesReceived", + ], + RTCMediaSourceStats: [ + "trackIdentifier", + "kind" + ], + RTCAudioSourceStats: [ + "totalAudioEnergy", + "totalSamplesDuration" + ], + RTCVideoSourceStats: [ + "width", + "height", + "framesPerSecond" + ], + RTCCodecStats: [ + "payloadType", + /* codecType is part of MTI but is not systematically set + per https://www.w3.org/TR/webrtc-stats/#dom-rtccodecstats-codectype + If the dictionary member is not present, it means that + this media format can be both encoded and decoded. */ + // "codecType", + "mimeType", + "clockRate", + "channels", + "sdpFmtpLine", + ], + RTCTransportStats: [ + "bytesSent", + "bytesReceived", + "selectedCandidatePairId", + "localCertificateId", + "remoteCertificateId", + ], + RTCIceCandidatePairStats: [ + "transportId", + "localCandidateId", + "remoteCandidateId", + "state", + "nominated", + "bytesSent", + "bytesReceived", + "totalRoundTripTime", + "currentRoundTripTime" + ], + RTCIceCandidateStats: [ + "address", + "port", + "protocol", + "candidateType", + "url", + ], + RTCCertificateStats: [ + "fingerprint", + "fingerprintAlgorithm", + "base64Certificate", + /* issuerCertificateId is part of MTI but is not systematically set + per https://www.w3.org/TR/webrtc-stats/#dom-rtccertificatestats-issuercertificateid + If the current certificate is at the end of the chain + (i.e. a self-signed certificate), this will not be set. */ + // "issuerCertificateId", + ], +}; + +// From https://w3c.github.io/webrtc-stats/webrtc-stats.html#rtcstatstype-str* + +const dictionaryNames = { + "codec": "RTCCodecStats", + "inbound-rtp": "RTCInboundRtpStreamStats", + "outbound-rtp": "RTCOutboundRtpStreamStats", + "remote-inbound-rtp": "RTCRemoteInboundRtpStreamStats", + "remote-outbound-rtp": "RTCRemoteOutboundRtpStreamStats", + "csrc": "RTCRtpContributingSourceStats", + "peer-connection": "RTCPeerConnectionStats", + "data-channel": "RTCDataChannelStats", + "media-source": { + audio: "RTCAudioSourceStats", + video: "RTCVideoSourceStats" + }, + "track": { + video: "RTCSenderVideoTrackAttachmentStats", + audio: "RTCSenderAudioTrackAttachmentStats" + }, + "sender": { + audio: "RTCAudioSenderStats", + video: "RTCVideoSenderStats" + }, + "receiver": { + audio: "RTCAudioReceiverStats", + video: "RTCVideoReceiverStats", + }, + "transport": "RTCTransportStats", + "candidate-pair": "RTCIceCandidatePairStats", + "local-candidate": "RTCIceCandidateStats", + "remote-candidate": "RTCIceCandidateStats", + "certificate": "RTCCertificateStats", +}; + +// From https://w3c.github.io/webrtc-stats/webrtc-stats.html (webidl) + +const parents = { + RTCVideoSourceStats: "RTCMediaSourceStats", + RTCAudioSourceStats: "RTCMediaSourceStats", + RTCReceivedRtpStreamStats: "RTCRtpStreamStats", + RTCInboundRtpStreamStats: "RTCReceivedRtpStreamStats", + RTCRemoteInboundRtpStreamStats: "RTCReceivedRtpStreamStats", + RTCSentRtpStreamStats: "RTCRtpStreamStats", + RTCOutboundRtpStreamStats: "RTCSentRtpStreamStats", + RTCRemoteOutboundRtpStreamStats : "RTCSentRtpStreamStats", +}; + +const remaining = JSON.parse(JSON.stringify(mandatory)); +for (const dictName in remaining) { + remaining[dictName] = new Set(remaining[dictName]); +} + +async function getAllStats(t, pc) { + // Try to obtain as many stats as possible, waiting up to 20 seconds for + // roundTripTime of RTCRemoteInboundRtpStreamStats and + // remoteTimestamp of RTCRemoteOutboundRtpStreamStats which can take + // several RTCP messages to calculate. + let stats; + let remoteInboundFound = false; + let remoteOutboundFound = false; + for (let i = 0; i < 20; i++) { + stats = await pc.getStats(); + const values = [...stats.values()]; + const [remoteInboundAudio, remoteInboundVideo] = ["audio", "video"].map( + kind => values.find(s => + s.type == "remote-inbound-rtp" && s.kind == kind)); + if (remoteInboundAudio && "roundTripTime" in remoteInboundAudio && + remoteInboundVideo && "roundTripTime" in remoteInboundVideo) { + remoteInboundFound = true; + } + const [remoteOutboundAudio, remoteOutboundVideo] = ["audio", "video"].map( + kind => values.find(s => + s.type == "remote-outbound-rtp" && s.kind == kind)); + if (remoteOutboundAudio && "remoteTimestamp" in remoteOutboundAudio && + remoteOutboundVideo && "remoteTimestamp" in remoteOutboundVideo) { + remoteOutboundFound = true; + } + if (remoteInboundFound && remoteOutboundFound) { + return stats; + } + await new Promise(r => t.step_timeout(r, 1000)); + } + return stats; +} + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const dc1 = pc1.createDataChannel("dummy", {negotiated: true, id: 0}); + const dc2 = pc2.createDataChannel("dummy", {negotiated: true, id: 0}); + + const stream = await getNoiseStream({video: true, audio:true}); + for (const track of stream.getTracks()) { + pc1.addTrack(track, stream); + pc2.addTrack(track, stream); + t.add_cleanup(() => track.stop()); + } + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + const stats = await getAllStats(t, pc1); + + // The focus of this test is not API correctness, but rather to provide an + // accessible metric of implementation progress by dictionary member. We count + // whether we've seen each dictionary's mandatory members in getStats(). + + test(t => { + for (const stat of stats.values()) { + let dictName = dictionaryNames[stat.type]; + if (!dictName) continue; + if (typeof dictName == "object") { + dictName = dictName[stat.kind]; + } + + assert_equals(typeof dictName, "string", "Test error. String."); + if (dictName && mandatory[dictName]) { + do { + const memberNames = mandatory[dictName]; + const remainingNames = remaining[dictName]; + assert_true(memberNames.length > 0, "Test error. Parent not found."); + for (const memberName of memberNames) { + if (memberName in stat) { + assert_not_equals(stat[memberName], undefined, "Not undefined"); + remainingNames.delete(memberName); + } + } + dictName = parents[dictName]; + } while (dictName); + } + } + }, "Validating stats"); + + for (const dictName in mandatory) { + for (const memberName of mandatory[dictName]) { + test(t => { + assert_true(!remaining[dictName].has(memberName), + `Is ${memberName} present`); + }, `${dictName}'s ${memberName}`); + } + } +}, 'getStats succeeds'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-ondatachannel.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-ondatachannel.html new file mode 100644 index 0000000000..08f206fb02 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-ondatachannel.html @@ -0,0 +1,374 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection.prototype.ondatachannel</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +// Test is based on the following revision: +// https://rawgit.com/w3c/webrtc-pc/1cc5bfc3ff18741033d804c4a71f7891242fb5b3/webrtc.html + +// The following helper functions are called from RTCPeerConnection-helper.js: +// exchangeIceCandidates +// exchangeOfferAnswer +// createDataChannelPair + +/* + 6.2. RTCDataChannel + When an underlying data transport is to be announced (the other peer created a channel with + negotiated unset or set to false), the user agent of the peer that did not initiate the + creation process MUST queue a task to run the following steps: + 2. Let channel be a newly created RTCDataChannel object. + 7. Set channel's [[ReadyState]] to open (but do not fire the open event, yet). + 8. Fire a datachannel event named datachannel with channel at the RTCPeerConnection object. + + 6.3. RTCDataChannelEvent + Firing a datachannel event named e with an RTCDataChannel channel means that an event with the + name e, which does not bubble (except where otherwise stated) and is not cancelable (except + where otherwise stated), and which uses the RTCDataChannelEvent interface with the channel + attribute set to channel, MUST be created and dispatched at the given target. + + interface RTCDataChannelEvent : Event { + readonly attribute RTCDataChannel channel; + }; + */ +promise_test(async (t) => { + const resolver = new Resolver(); + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + let eventCount = 0; + + pc2.ondatachannel = t.step_func((event) => { + eventCount++; + assert_equals(eventCount, 1, + 'Expect data channel event to fire exactly once'); + + assert_true(event instanceof RTCDataChannelEvent, + 'Expect event to be instance of RTCDataChannelEvent'); + + assert_equals(event.bubbles, false); + assert_equals(event.cancelable, false); + + const dc = event.channel; + assert_true(dc instanceof RTCDataChannel, + 'Expect channel to be instance of RTCDataChannel'); + + // The channel should be in the 'open' state already. + // See: https://github.com/w3c/webrtc-pc/pull/1851 + assert_equals(dc.readyState, 'open', + 'Expect channel ready state to be open'); + + resolver.resolve(); + }); + + pc1.createDataChannel('fire-me!'); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + await resolver; +}, 'Data channel event should fire when new data channel is announced to the remote peer'); + +/* + Since the channel should be in the 'open' state when dispatching via the 'datachannel' event, + we should be able to send data in the event handler. + */ +promise_test(async (t) => { + const resolver = new Resolver(); + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const message = 'meow meow!'; + + pc2.ondatachannel = t.step_func((event) => { + const dc2 = event.channel; + dc2.send(message); + }); + + const dc1 = pc1.createDataChannel('fire-me!'); + dc1.onmessage = t.step_func((event) => { + assert_equals(event.data, message, + 'Received data should be equal to sent data'); + + resolver.resolve(); + }); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + await resolver; +}, 'Should be able to send data in a datachannel event handler'); + +/* + 6.2. RTCDataChannel + When an underlying data transport is to be announced (the other peer created a channel with + negotiated unset or set to false), the user agent of the peer that did not initiate the + creation process MUST queue a task to run the following steps: + 8. Fire a datachannel event named datachannel with channel at the RTCPeerConnection object. + 9. If the channel's [[ReadyState]] is still open, announce the data channel as open. + */ +promise_test(async (t) => { + const resolver = new Resolver(); + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc2.ondatachannel = t.step_func((event) => { + const dc = event.channel; + dc.onopen = t.step_func(() => { + assert_unreached('Open event should not fire'); + }); + + // This should prevent triggering the 'open' event + dc.close(); + + // Wait a bit to ensure the 'open' event does NOT fire + t.step_timeout(() => resolver.resolve(), 500); + }); + + pc1.createDataChannel('fire-me!'); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + await resolver; +}, 'Open event should not be raised when closing the channel in the datachannel event'); + +// Added this test as a result of the discussion in +// https://github.com/w3c/webrtc-pc/pull/1851#discussion_r185976747 +promise_test(async (t) => { + const resolver = new Resolver(); + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc2.ondatachannel = t.step_func((event) => { + const dc = event.channel; + dc.onopen = t.step_func((event) => { + resolver.resolve(); + }); + + // This should NOT prevent triggering the 'open' event since it enqueues at least two tasks + t.step_timeout(() => { + t.step_timeout(() => { + dc.close() + }, 1); + }, 1); + }); + + pc1.createDataChannel('fire-me!'); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + await resolver; +}, 'Open event should be raised when closing the channel in the datachannel event after ' + + 'enqueuing a task'); + + +/* + Combination of the two tests above (send and close). + */ +promise_test(async (t) => { + const resolver = new Resolver(); + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const message = 'meow meow!'; + + pc2.ondatachannel = t.step_func((event) => { + const dc2 = event.channel; + dc2.onopen = t.step_func(() => { + assert_unreached('Open event should not fire'); + }); + + // This should send but still prevent triggering the 'open' event + dc2.send(message); + dc2.close(); + }); + + const dc1 = pc1.createDataChannel('fire-me!'); + dc1.onmessage = t.step_func((event) => { + assert_equals(event.data, message, + 'Received data should be equal to sent data'); + + resolver.resolve(); + }); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + await resolver; +}, 'Open event should not be raised when sending and immediately closing the channel in the ' + + 'datachannel event'); + +/* + 6.2. RTCDataChannel + + interface RTCDataChannel : EventTarget { + readonly attribute USVString label; + readonly attribute boolean ordered; + readonly attribute unsigned short? maxPacketLifeTime; + readonly attribute unsigned short? maxRetransmits; + readonly attribute USVString protocol; + readonly attribute boolean negotiated; + readonly attribute unsigned short? id; + readonly attribute RTCDataChannelState readyState; + ... + }; + + When an underlying data transport is to be announced (the other peer created a channel with + negotiated unset or set to false), the user agent of the peer that did not initiate the + creation process MUST queue a task to run the following steps: + 2. Let channel be a newly created RTCDataChannel object. + 3. Let configuration be an information bundle received from the other peer as a part of the + process to establish the underlying data transport described by the WebRTC DataChannel + Protocol specification [RTCWEB-DATA-PROTOCOL]. + 4. Initialize channel's [[DataChannelLabel]], [[Ordered]], [[MaxPacketLifeTime]], + [[MaxRetransmits]], [[DataChannelProtocol]], and [[DataChannelId]] internal slots to the + corresponding values in configuration. + 5. Initialize channel's [[Negotiated]] internal slot to false. + 7. Set channel's [[ReadyState]] slot to connecting. + 8. Fire a datachannel event named datachannel with channel at the RTCPeerConnection object. + + Note: More exhaustive tests are defined in RTCDataChannel-dcep + */ + +promise_test(async (t) => { + const resolver = new Resolver(); + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const dc1 = pc1.createDataChannel('test', { + ordered: false, + maxRetransmits: 1, + protocol: 'custom' + }); + + assert_equals(dc1.label, 'test'); + assert_equals(dc1.ordered, false); + assert_equals(dc1.maxPacketLifeTime, null); + assert_equals(dc1.maxRetransmits, 1); + assert_equals(dc1.protocol, 'custom'); + assert_equals(dc1.negotiated, false); + + pc2.ondatachannel = t.step_func((event) => { + const dc2 = event.channel; + assert_true(dc2 instanceof RTCDataChannel, + 'Expect channel to be instance of RTCDataChannel'); + + assert_equals(dc2.label, 'test'); + assert_equals(dc2.ordered, false); + assert_equals(dc2.maxPacketLifeTime, null); + assert_equals(dc2.maxRetransmits, 1); + assert_equals(dc2.protocol, 'custom'); + assert_equals(dc2.negotiated, false); + assert_equals(dc2.id, dc1.id); + + resolver.resolve(); + }); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + await resolver; +}, 'In-band negotiated channel created on remote peer should match the same configuration as local ' + + 'peer'); + +promise_test(async (t) => { + const resolver = new Resolver(); + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const dc1 = pc1.createDataChannel(''); + + assert_equals(dc1.label, ''); + assert_equals(dc1.ordered, true); + assert_equals(dc1.maxPacketLifeTime, null); + assert_equals(dc1.maxRetransmits, null); + assert_equals(dc1.protocol, ''); + assert_equals(dc1.negotiated, false); + + pc2.ondatachannel = t.step_func((event) => { + const dc2 = event.channel; + assert_true(dc2 instanceof RTCDataChannel, + 'Expect channel to be instance of RTCDataChannel'); + + assert_equals(dc2.label, ''); + assert_equals(dc2.ordered, true); + assert_equals(dc2.maxPacketLifeTime, null); + assert_equals(dc2.maxRetransmits, null); + assert_equals(dc2.protocol, ''); + assert_equals(dc2.negotiated, false); + assert_equals(dc2.id, dc1.id); + + resolver.resolve(); + }); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + await resolver; +}, 'In-band negotiated channel created on remote peer should match the same (default) ' + + 'configuration as local peer'); + +/* + 6.2. RTCDataChannel + Dictionary RTCDataChannelInit Members + negotiated + The default value of false tells the user agent to announce the + channel in-band and instruct the other peer to dispatch a corresponding + RTCDataChannel object. If set to true, it is up to the application + to negotiate the channel and create a RTCDataChannel object with the + same id at the other peer. + */ +promise_test(async (t) => { + const resolver = new Resolver(); + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc2.ondatachannel = t.unreached_func('datachannel event should not be fired'); + + pc1.createDataChannel('test', { + negotiated: true, + id: 42 + }); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + // Wait a bit to ensure the 'datachannel' event does NOT fire + t.step_timeout(() => resolver.resolve(), 500); + await resolver; +}, 'Negotiated channel should not fire datachannel event on remote peer'); + +/* + Non-testable + 6.2. RTCDataChannel + When an underlying data transport is to be announced + 1. If the associated RTCPeerConnection object's [[isClosed]] slot + is true, abort these steps. + + The above step is not testable because to reach it we would have to + close the peer connection just between receiving the in-band negotiated data + channel via DCEP and firing the datachannel event. + */ +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-onicecandidateerror.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-onicecandidateerror.https.html new file mode 100644 index 0000000000..096cc9dd1a --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-onicecandidateerror.https.html @@ -0,0 +1,38 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.onicecandidateerror</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + +promise_test(async t => { + const config = { + iceServers: [{urls: "turn:123", username: "123", credential: "123"}] + }; + const pc = new RTCPeerConnection(config); + t.add_cleanup(() => pc.close()); + const onErrorPromise = addEventListenerPromise(t, pc, 'icecandidateerror', event => { + assert_true(event instanceof RTCPeerConnectionIceErrorEvent, + 'Expect event to be instance of RTCPeerConnectionIceErrorEvent'); + // Do not hardcode any specific errors here. Instead only verify + // that all the fields contain something expected. + // Testing of event.errorText can be added later once it's content is + // specified in spec with more detail. + assert_true(event.errorCode >= 300 && event.errorCode <= 799, "errorCode"); + if (event.port == 0) { + assert_equals(event.address, null); + } else { + assert_true(event.address.includes(".") || event.address.includes(":")); + } + assert_true(event.url.includes("123"), "url"); + }); + const stream = await getNoiseStream({audio:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + pc.addTrack(stream.getAudioTracks()[0], stream); + + await pc.setLocalDescription(await pc.createOffer()); + await onErrorPromise; +}, 'Surfacing onicecandidateerror'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-onnegotiationneeded.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-onnegotiationneeded.html new file mode 100644 index 0000000000..6ede5ccebf --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-onnegotiationneeded.html @@ -0,0 +1,627 @@ +<!doctype html> +<meta charset="utf-8"> +<title>Test RTCPeerConnection.prototype.onnegotiationneeded</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // generateOffer + // generateAnswer + // generateAudioReceiveOnlyOffer + // test_never_resolve + + // Listen to the negotiationneeded event on a peer connection + // Returns a promise that resolves when the first event is fired. + // The resolve result is a dictionary with event and nextPromise, + // which resolves when the next negotiationneeded event is fired. + // This allow us to promisify the event listening and assert whether + // an event is fired or not by testing whether a promise is resolved. + function awaitNegotiation(pc) { + if(pc.onnegotiationneeded) { + throw new Error('connection is already attached with onnegotiationneeded event handler'); + } + + function waitNextNegotiation() { + return new Promise(resolve => { + pc.onnegotiationneeded = event => { + const nextPromise = waitNextNegotiation(); + resolve({ nextPromise, event }); + } + }); + } + + return waitNextNegotiation(); + } + + // Return a promise that rejects if the first promise is resolved before second promise. + // Also rejects when either promise rejects. + function assert_first_promise_fulfill_after_second(promise1, promise2, message) { + if(!message) { + message = 'first promise is resolved before second promise'; + } + + return new Promise((resolve, reject) => { + let secondResolved = false; + + promise1.then(() => { + if(secondResolved) { + resolve(); + } else { + assert_unreached(message); + } + }) + .catch(reject); + + promise2.then(() => { + secondResolved = true; + }, reject); + }); + } + + /* + 4.7.3. Updating the Negotiation-Needed flag + + To update the negotiation-needed flag + 5. Set connection's [[needNegotiation]] slot to true. + 6. Queue a task that runs the following steps: + 3. Fire a simple event named negotiationneeded at connection. + + To check if negotiation is needed + 2. If connection has created any RTCDataChannels, and no m= section has + been negotiated yet for data, return "true". + + 6.1. RTCPeerConnection Interface Extensions + + createDataChannel + 14. If channel was the first RTCDataChannel created on connection, + update the negotiation-needed flag for connection. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const negotiated = awaitNegotiation(pc); + + pc.createDataChannel('test'); + return negotiated; + }, 'Creating first data channel should fire negotiationneeded event'); + + test_never_resolve(t => { + const pc = new RTCPeerConnection(); + const negotiated = awaitNegotiation(pc); + + pc.createDataChannel('foo'); + return negotiated + .then(({nextPromise}) => { + pc.createDataChannel('bar'); + return nextPromise; + }); + }, 'calling createDataChannel twice should fire negotiationneeded event once'); + + /* + 4.7.3. Updating the Negotiation-Needed flag + To check if negotiation is needed + 3. For each transceiver t in connection's set of transceivers, perform + the following checks: + 1. If t isn't stopped and isn't yet associated with an m= section + according to [JSEP] (section 3.4.1.), return "true". + + 5.1. RTCPeerConnection Interface Extensions + addTransceiver + 9. Update the negotiation-needed flag for connection. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const negotiated = awaitNegotiation(pc); + + pc.addTransceiver('audio'); + return negotiated; + }, 'addTransceiver() should fire negotiationneeded event'); + + /* + 4.7.3. Updating the Negotiation-Needed flag + To update the negotiation-needed flag + 4. If connection's [[needNegotiation]] slot is already true, abort these steps. + */ + test_never_resolve(t => { + const pc = new RTCPeerConnection(); + const negotiated = awaitNegotiation(pc); + + pc.addTransceiver('audio'); + return negotiated + .then(({nextPromise}) => { + pc.addTransceiver('video'); + return nextPromise; + }); + }, 'Calling addTransceiver() twice should fire negotiationneeded event once'); + + /* + 4.7.3. Updating the Negotiation-Needed flag + To update the negotiation-needed flag + 4. If connection's [[needNegotiation]] slot is already true, abort these steps. + */ + test_never_resolve(t => { + const pc = new RTCPeerConnection(); + const negotiated = awaitNegotiation(pc); + + pc.createDataChannel('test'); + return negotiated + .then(({nextPromise}) => { + pc.addTransceiver('video'); + return nextPromise; + }); + }, 'Calling both addTransceiver() and createDataChannel() should fire negotiationneeded event once'); + + /* + 4.7.3. Updating the Negotiation-Needed flag + To update the negotiation-needed flag + 2. If connection's signaling state is not "stable", abort these steps. + */ + test_never_resolve(t => { + const pc = new RTCPeerConnection(); + let negotiated; + + return generateAudioReceiveOnlyOffer(pc) + .then(offer => { + pc.setLocalDescription(offer); + negotiated = awaitNegotiation(pc); + }) + .then(() => negotiated) + .then(({nextPromise}) => { + assert_equals(pc.signalingState, 'have-local-offer'); + pc.createDataChannel('test'); + return nextPromise; + }); + }, 'negotiationneeded event should not fire if signaling state is not stable'); + + /* + 4.4.1.6. Set the RTCSessionSessionDescription + 2.2.10. If connection's signaling state is now stable, update the negotiation-needed + flag. If connection's [[NegotiationNeeded]] slot was true both before and after + this update, queue a task that runs the following steps: + 2. If connection's [[NegotiationNeeded]] slot is false, abort these steps. + 3. Fire a simple event named negotiationneeded at connection. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + pc.addTransceiver('audio'); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + let fired = false; + pc.onnegotiationneeded = e => fired = true; + pc.createDataChannel('test'); + await pc.setRemoteDescription(await generateAnswer(offer)); + await undefined; + assert_false(fired, "negotiationneeded should not fire until the next iteration of the event loop after SRD success"); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + }, 'negotiationneeded event should fire only after signaling state goes back to stable after setRemoteDescription'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + pc.addTransceiver('audio'); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + + let fired = false; + pc.onnegotiationneeded = e => fired = true; + await pc.setRemoteDescription(await generateOffer()); + pc.createDataChannel('test'); + await pc.setLocalDescription(await pc.createAnswer()); + await undefined; + assert_false(fired, "negotiationneeded should not fire until the next iteration of the event loop after SLD success"); + + await new Promise(resolve => pc.onnegotiationneeded = resolve); + }, 'negotiationneeded event should fire only after signaling state goes back to stable after setLocalDescription'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + pc.addTransceiver('audio'); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + let fired = false; + pc.onnegotiationneeded = e => fired = true; + pc.createDataChannel('test'); + const p = pc.setRemoteDescription(await generateAnswer(offer)); + await new Promise(resolve => pc.onsignalingstatechange = resolve); + assert_false(fired, "negotiationneeded should not fire before signalingstatechange fires"); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + await p; + }, 'negotiationneeded event should fire only after signalingstatechange event fires from setRemoteDescription'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + pc.addTransceiver('audio'); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + + let fired = false; + pc.onnegotiationneeded = e => fired = true; + await pc.setRemoteDescription(await generateOffer()); + pc.createDataChannel('test'); + + const p = pc.setLocalDescription(await pc.createAnswer()); + await new Promise(resolve => pc.onsignalingstatechange = resolve); + assert_false(fired, "negotiationneeded should not fire until the next iteration of the event loop after returning to stable"); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + await p; + }, 'negotiationneeded event should fire only after signalingstatechange event fires from setLocalDescription'); + + /* + 5.1. RTCPeerConnection Interface Extensions + + addTrack + 10. Update the negotiation-needed flag for connection. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + pc.addTrack(track, stream); + + await new Promise(resolve => pc.onnegotiationneeded = resolve); + }, 'addTrack should cause negotiationneeded to fire'); + + /* + 5.1. RTCPeerConnection Interface Extensions + + removeTrack + 12. Update the negotiation-needed flag for connection. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const sender = pc.addTrack(track, stream); + + await new Promise(resolve => pc.onnegotiationneeded = resolve); + pc.onnegotiationneeded = t.step_func(() => { + assert_unreached('onnegotiationneeded misfired'); + }); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + const answer = await generateAnswer(offer); + await pc.setRemoteDescription(answer); + + pc.removeTrack(sender); + await new Promise(resolve => pc.onnegotiationneeded = resolve) + }, 'removeTrack should cause negotiationneeded to fire on the caller'); + + /* + 5.1. RTCPeerConnection Interface Extensions + + removeTrack + 12. Update the negotiation-needed flag for connection. + */ + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + caller.addTransceiver('audio', {direction:'recvonly'}); + const offer = await caller.createOffer(); + + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const sender = callee.addTrack(track, stream); + + await new Promise(resolve => callee.onnegotiationneeded = resolve); + callee.onnegotiationneeded = t.step_func(() => { + assert_unreached('onnegotiationneeded misfired'); + }); + + await callee.setRemoteDescription(offer); + const answer = await callee.createAnswer(); + callee.setLocalDescription(answer); + + callee.removeTrack(sender); + await new Promise(resolve => callee.onnegotiationneeded = resolve) + }, 'removeTrack should cause negotiationneeded to fire on the callee'); + + /* + 5.4. RTCRtpTransceiver Interface + + setDirection + 7. Update the negotiation-needed flag for connection. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('audio', {direction:'sendrecv'}); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await generateAnswer(offer); + await pc.setRemoteDescription(answer); + transceiver.direction = 'recvonly'; + await new Promise(resolve => pc.onnegotiationneeded = resolve); + }, 'Updating the direction of the transceiver should cause negotiationneeded to fire'); + + /* + 5.2. RTCRtpSender Interface + + setStreams + 7. Update the negotiation-needed flag for connection. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('audio', {direction:'sendrecv'}); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await generateAnswer(offer); + await pc.setRemoteDescription(answer); + + const stream = new MediaStream(); + transceiver.sender.setStreams(stream); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + }, 'Calling setStreams should cause negotiationneeded to fire'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('audio', {direction:'sendrecv'}); + const stream = new MediaStream(); + transceiver.sender.setStreams(stream); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await generateAnswer(offer); + await pc.setRemoteDescription(answer); + + const stream2 = new MediaStream(); + transceiver.sender.setStreams(stream2); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + }, 'Calling setStreams with a different stream as before should cause negotiationneeded to fire'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('audio', {direction:'sendrecv'}); + const stream = new MediaStream(); + transceiver.sender.setStreams(stream); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await generateAnswer(offer); + await pc.setRemoteDescription(answer); + + const stream2 = new MediaStream(); + transceiver.sender.setStreams(stream, stream2); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + }, 'Calling setStreams with an additional stream should cause negotiationneeded to fire'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('audio', {direction:'sendrecv'}); + const stream1 = new MediaStream(); + const stream2 = new MediaStream(); + transceiver.sender.setStreams(stream1, stream2); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await generateAnswer(offer); + await pc.setRemoteDescription(answer); + + transceiver.sender.setStreams(stream2); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + }, 'Calling setStreams with a stream removed should cause negotiationneeded to fire'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('audio', {direction:'sendrecv'}); + const stream1 = new MediaStream(); + const stream2 = new MediaStream(); + transceiver.sender.setStreams(stream1, stream2); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await generateAnswer(offer); + await pc.setRemoteDescription(answer); + + transceiver.sender.setStreams(); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + }, 'Calling setStreams with all streams removed should cause negotiationneeded to fire'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('audio', {direction:'sendrecv'}); + const stream = new MediaStream(); + transceiver.sender.setStreams(stream); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await generateAnswer(offer); + await pc.setRemoteDescription(answer); + + transceiver.sender.setStreams(stream); + const event = await Promise.race([ + new Promise(r => pc.onnegotiationneeded = r), + new Promise(r => t.step_timeout(r, 10)) + ]); + assert_equals(event, undefined, "No negotiationneeded event"); + }, 'Calling setStreams with the same stream as before should not cause negotiationneeded to fire'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('audio', {direction:'sendrecv'}); + const stream = new MediaStream(); + transceiver.sender.setStreams(stream); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await generateAnswer(offer); + await pc.setRemoteDescription(answer); + + transceiver.sender.setStreams(stream, stream); + const event = await Promise.race([ + new Promise(r => pc.onnegotiationneeded = r), + new Promise(r => t.step_timeout(r, 10)) + ]); + assert_equals(event, undefined, "No negotiationneeded event"); + }, 'Calling setStreams with duplicates of the same stream as before should not cause negotiationneeded to fire'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('audio', {direction:'sendrecv'}); + const stream1 = new MediaStream(); + const stream2 = new MediaStream(); + transceiver.sender.setStreams(stream1, stream2); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await generateAnswer(offer); + await pc.setRemoteDescription(answer); + + transceiver.sender.setStreams(stream2, stream1); + const event = await Promise.race([ + new Promise(r => pc.onnegotiationneeded = r), + new Promise(r => t.step_timeout(r, 10)) + ]); + assert_equals(event, undefined, "No negotiationneeded event"); + }, 'Calling setStreams with the same streams as before in a different order should not cause negotiationneeded to fire'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver('audio', {direction:'sendrecv'}); + const stream1 = new MediaStream(); + const stream2 = new MediaStream(); + transceiver.sender.setStreams(stream1, stream2); + await new Promise(resolve => pc.onnegotiationneeded = resolve); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await generateAnswer(offer); + await pc.setRemoteDescription(answer); + + transceiver.sender.setStreams(stream1, stream2, stream1); + const event = await Promise.race([ + new Promise(r => pc.onnegotiationneeded = r), + new Promise(r => t.step_timeout(r, 10)) + ]); + assert_equals(event, undefined, "No negotiationneeded event"); + }, 'Calling setStreams with duplicates of the same streams as before should not cause negotiationneeded to fire'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + let negotiationCount = 0; + pc1.onnegotiationneeded = async () => { + negotiationCount++; + await pc1.setLocalDescription(await pc1.createOffer()); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(await pc2.createAnswer()); + await pc1.setRemoteDescription(pc2.localDescription); + } + + pc1.addTransceiver("video"); + await new Promise(r => pc1.onsignalingstatechange = () => pc1.signalingState == "stable" && r()); + pc1.addTransceiver("audio"); + await new Promise(r => pc1.onsignalingstatechange = () => pc1.signalingState == "stable" && r()); + assert_equals(negotiationCount, 2); + }, 'Adding two transceivers, one at a time, results in the expected number of negotiationneeded events'); + + /* + TODO + 4.7.3. Updating the Negotiation-Needed flag + + To update the negotiation-needed flag + 3. If the result of checking if negotiation is needed is "false", + clear the negotiation-needed flag by setting connection's + [[needNegotiation]] slot to false, and abort these steps. + 6. Queue a task that runs the following steps: + 2. If connection's [[needNegotiation]] slot is false, abort these steps. + + To check if negotiation is needed + 3. For each transceiver t in connection's set of transceivers, perform + the following checks: + 2. If t isn't stopped and is associated with an m= section according + to [JSEP] (section 3.4.1.), then perform the following checks: + 1. If t's direction is "sendrecv" or "sendonly", and the + associated m= section in connection's currentLocalDescription + doesn't contain an "a=msid" line, return "true". + 2. If connection's currentLocalDescription if of type "offer", + and the direction of the associated m= section in neither the + offer nor answer matches t's direction, return "true". + 3. If connection's currentLocalDescription if of type "answer", + and the direction of the associated m= section in the answer + does not match t's direction intersected with the offered + direction (as described in [JSEP] (section 5.3.1.)), + return "true". + 3. If t is stopped and is associated with an m= section according + to [JSEP] (section 3.4.1.), but the associated m= section is + not yet rejected in connection's currentLocalDescription or + currentRemoteDescription , return "true". + 4. If all the preceding checks were performed and "true" was not returned, + nothing remains to be negotiated; return "false". + + 4.3.1. RTCPeerConnection Operation + + When the RTCPeerConnection() constructor is invoked + 7. Let connection have a [[needNegotiation]] internal slot, initialized to false. + + 5.4. RTCRtpTransceiver Interface + + stop + 11. Update the negotiation-needed flag for connection. + + Untestable + 4.7.3. Updating the Negotiation-Needed flag + 1. If connection's [[isClosed]] slot is true, abort these steps. + 6. Queue a task that runs the following steps: + 1. If connection's [[isClosed]] slot is true, abort these steps. + */ + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-onsignalingstatechanged.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-onsignalingstatechanged.https.html new file mode 100644 index 0000000000..ad92bf5fc6 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-onsignalingstatechanged.https.html @@ -0,0 +1,71 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection onsignalingstatechanged</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + +promise_test(async t => { + const [track] = (await getNoiseStream({video: true})).getTracks(); + t.add_cleanup(() => track.stop()); + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc1.addTrack(track, new MediaStream()); + await pc1.setLocalDescription(await pc1.createOffer()); + const events = []; + pc2.onsignalingstatechange = t.step_func(e => { + const [transceiver] = pc2.getTransceivers(); + assert_equals(transceiver.currentDirection, null); + events.push(pc2.signalingState); + }); + await pc2.setRemoteDescription(pc1.localDescription); + assert_equals(events.length, 1, "event fired"); + assert_equals(events[0], "have-remote-offer"); + + pc2.onsignalingstatechange = t.step_func(e => { + const [transceiver] = pc2.getTransceivers(); + assert_equals(transceiver.currentDirection, "recvonly"); + events.push(pc2.signalingState); + }); + await pc2.setLocalDescription(await pc2.createAnswer()); + assert_equals(events.length, 2, "event fired"); + assert_equals(events[1], "stable"); +}, 'Negotiation methods fire signalingstatechange events'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + + stream.getTracks().forEach(track => pc1.addTrack(track, stream)); + exchangeIceCandidates(pc1, pc2); + exchangeOfferAnswer(pc1, pc2); + await listenToIceConnected(pc2); + + pc2.onsignalingstatechange = t.unreached_func(); + pc2.close(); + assert_equals(pc2.signalingState, 'closed'); + await new Promise(r => t.step_timeout(r, 100)); +}, 'Closing a PeerConnection should not fire signalingstatechange event'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc2.addTransceiver('video'); + + pc1.ontrack = t.unreached_func(); + pc1.onsignalingstatechange = t.step_func(e => { + pc1.ontrack = null; + }); + await pc1.setRemoteDescription(await pc2.createOffer()); +}, 'signalingstatechange is the first event to fire'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-ontrack.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-ontrack.https.html new file mode 100644 index 0000000000..ccdd29f6a5 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-ontrack.https.html @@ -0,0 +1,258 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.ontrack</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // getTrackFromUserMedia + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 2.2.8. If description is set as a remote description, then run the following + steps for each media description in description: + 3. Set transceiver's mid value to the mid of the corresponding media + description. If the media description has no MID, and transceiver's + mid is unset, generate a random value as described in [JSEP] (section 5.9.). + 4. If the direction of the media description is sendrecv or sendonly, and + transceiver.receiver.track has not yet been fired in a track event, + process the remote track for the media description, given transceiver. + + 5.1.1. Processing Remote MediaStreamTracks + To process the remote track for an incoming media description [JSEP] + (section 5.9.) given RTCRtpTransceiver transceiver, the user agent MUST + run the following steps: + + 1. Let connection be the RTCPeerConnection object associated with transceiver. + 2. Let streams be a list of MediaStream objects that the media description + indicates the MediaStreamTrack belongs to. + 3. Add track to all MediaStream objects in streams. + 4. Queue a task to fire an event named track with transceiver, track, and + streams at the connection object. + + 5.7. RTCTrackEvent + [Constructor(DOMString type, RTCTrackEventInit eventInitDict)] + interface RTCTrackEvent : Event { + readonly attribute RTCRtpReceiver receiver; + readonly attribute MediaStreamTrack track; + [SameObject] + readonly attribute FrozenArray<MediaStream> streams; + readonly attribute RTCRtpTransceiver transceiver; + }; + + [mediacapture-main] + 4.2. MediaStream + interface MediaStream : EventTarget { + readonly attribute DOMString id; + sequence<MediaStreamTrack> getTracks(); + ... + }; + + [mediacapture-main] + 4.3. MediaStreamTrack + interface MediaStreamTrack : EventTarget { + readonly attribute DOMString kind; + readonly attribute DOMString id; + ... + }; + */ + + function validateTrackEvent(trackEvent) { + const { receiver, track, streams, transceiver } = trackEvent; + + assert_true(track instanceof MediaStreamTrack, + 'Expect track to be instance of MediaStreamTrack'); + + assert_true(Array.isArray(streams), + 'Expect streams to be an array'); + + for(const mediaStream of streams) { + assert_true(mediaStream instanceof MediaStream, + 'Expect elements in streams to be instance of MediaStream'); + + assert_true(mediaStream.getTracks().includes(track), + 'Expect each mediaStream to have track as one of their tracks'); + } + + assert_true(receiver instanceof RTCRtpReceiver, + 'Expect trackEvent.receiver to be defined and is instance of RTCRtpReceiver'); + + assert_equals(receiver.track, track, + 'Expect trackEvent.receiver.track to be the same as trackEvent.track'); + + assert_true(transceiver instanceof RTCRtpTransceiver, + 'Expect trackEvent.transceiver to be defined and is instance of RTCRtpTransceiver'); + + assert_equals(transceiver.receiver, receiver, + 'Expect trackEvent.transceiver.receiver to be the same as trackEvent.receiver'); + } + + // tests that ontrack is called and parses the msid information from the SDP and creates + // the streams with matching identifiers. + promise_test(async t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + // Fail the test if the ontrack event handler is not implemented + assert_idl_attribute(pc, 'ontrack', 'Expect pc to have ontrack event handler attribute'); + + const sdp = `v=0 +o=- 166855176514521964 2 IN IP4 127.0.0.1 +s=- +t=0 0 +a=msid-semantic:WMS * +m=audio 9 UDP/TLS/RTP/SAVPF 111 +c=IN IP4 0.0.0.0 +a=rtcp:9 IN IP4 0.0.0.0 +a=ice-ufrag:someufrag +a=ice-pwd:somelongpwdwithenoughrandomness +a=fingerprint:sha-256 8C:71:B3:8D:A5:38:FD:8F:A4:2E:A2:65:6C:86:52:BC:E0:6E:94:F2:9F:7C:4D:B5:DF:AF:AA:6F:44:90:8D:F4 +a=setup:actpass +a=rtcp-mux +a=mid:mid1 +a=sendonly +a=rtpmap:111 opus/48000/2 +a=msid:stream1 track1 +a=ssrc:1001 cname:some +`; + + const trackEventPromise = addEventListenerPromise(t, pc, 'track'); + await pc.setRemoteDescription({ type: 'offer', sdp }); + const trackEvent = await trackEventPromise; + const { streams, track, transceiver } = trackEvent; + + assert_equals(streams.length, 1, + 'the track belongs to one MediaStream'); + + const [stream] = streams; + assert_equals(stream.id, 'stream1', + 'Expect stream.id to be the same as specified in the a=msid line'); + + assert_equals(track.kind, 'audio', + 'Expect track.kind to be audio'); + + validateTrackEvent(trackEvent); + + assert_equals(transceiver.direction, 'recvonly', + 'Expect transceiver.direction to be reverse of sendonly (recvonly)'); + }, 'setRemoteDescription should trigger ontrack event when the MSID of the stream is is parsed.'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + assert_idl_attribute(pc, 'ontrack', 'Expect pc to have ontrack event handler attribute'); + + const sdp = `v=0 +o=- 166855176514521964 2 IN IP4 127.0.0.1 +s=- +t=0 0 +a=msid-semantic:WMS * +m=audio 9 UDP/TLS/RTP/SAVPF 111 +c=IN IP4 0.0.0.0 +a=rtcp:9 IN IP4 0.0.0.0 +a=ice-ufrag:someufrag +a=ice-pwd:somelongpwdwithenoughrandomness +a=fingerprint:sha-256 8C:71:B3:8D:A5:38:FD:8F:A4:2E:A2:65:6C:86:52:BC:E0:6E:94:F2:9F:7C:4D:B5:DF:AF:AA:6F:44:90:8D:F4 +a=setup:actpass +a=rtcp-mux +a=mid:mid1 +a=recvonly +a=rtpmap:111 opus/48000/2 +a=msid:stream1 track1 +a=ssrc:1001 cname:some +`; + + pc.ontrack = t.unreached_func('ontrack event should not fire for track with recvonly direction'); + + await pc.setRemoteDescription({ type: 'offer', sdp }); + await new Promise(resolve => t.step_timeout(resolve, 100)); + }, 'setRemoteDescription() with m= line of recvonly direction should not trigger track event'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + + t.add_cleanup(() => pc2.close()); + + const [track, mediaStream] = await getTrackFromUserMedia('audio'); + pc1.addTrack(track, mediaStream); + const trackEventPromise = addEventListenerPromise(t, pc2, 'track'); + await pc2.setRemoteDescription(await pc1.createOffer()); + const trackEvent = await trackEventPromise; + + assert_equals(trackEvent.track.kind, 'audio', + 'Expect track.kind to be audio'); + + validateTrackEvent(trackEvent); + }, 'addTrack() should cause remote connection to fire ontrack when setRemoteDescription()'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver('video'); + + const trackEventPromise = addEventListenerPromise(t, pc2, 'track'); + await pc2.setRemoteDescription(await pc1.createOffer()); + const trackEvent = await trackEventPromise; + const { track } = trackEvent; + + assert_equals(track.kind, 'video', + 'Expect track.kind to be video'); + + validateTrackEvent(trackEvent); + }, `addTransceiver('video') should cause remote connection to fire ontrack when setRemoteDescription()`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver('audio', { direction: 'inactive' }); + pc2.ontrack = t.unreached_func('ontrack event should not fire for track with inactive direction'); + + await pc2.setRemoteDescription(await pc1.createOffer()); + await new Promise(resolve => t.step_timeout(resolve, 100)); + }, `addTransceiver() with inactive direction should not cause remote connection to fire ontrack when setRemoteDescription()`); + + ["audio", "video"].forEach(type => promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const checkNoUnexpectedTrack = ({track}) => { + assert_equals(track.kind, type, `ontrack event should not fire for ${track.kind}`); + }; + + pc2.ontrack = t.step_func(checkNoUnexpectedTrack); + pc1.ontrack = t.step_func(checkNoUnexpectedTrack); + + await pc1.setLocalDescription(await pc1.createOffer( + { offerToReceiveVideo: true, offerToReceiveAudio: true })); + + pc2.addTrack(...await getTrackFromUserMedia(type)); + + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(await pc2.createAnswer()); + await pc1.setRemoteDescription(pc2.localDescription); + + await new Promise(resolve => t.step_timeout(resolve, 100)); + }, `Using offerToReceiveAudio and offerToReceiveVideo should only cause a ${type} track event to fire, if ${type} was the only type negotiated`)); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-operations.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-operations.https.html new file mode 100644 index 0000000000..28ae3afcd7 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-operations.https.html @@ -0,0 +1,425 @@ +<!doctype html> +<meta charset=utf-8> +<title></title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +'use strict'; + +// Helpers to test APIs "return a promise rejected with a newly created" error. +// Strictly speaking this means already-rejected upon return. +function promiseState(p) { + const t = {}; + return Promise.race([p, t]) + .then(v => (v === t)? "pending" : "fulfilled", () => "rejected"); +} + +// However, to allow promises to be used in implementations, this helper adds +// some slack: returning a pending promise will pass, provided it is rejected +// before the end of the current run of the event loop (i.e. on microtask queue +// before next task). +async function promiseStateFinal(p) { + for (let i = 0; i < 20; i++) { + await promiseState(p); + } + return promiseState(p); +} + +[promiseState, promiseStateFinal].forEach(f => promise_test(async t => { + assert_equals(await f(Promise.resolve()), "fulfilled"); + assert_equals(await f(Promise.reject()), "rejected"); + assert_equals(await f(new Promise(() => {})), "pending"); +}, `${f.name} helper works`)); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + await pc.setRemoteDescription(await pc.createOffer()); + const p = pc.createOffer(); + const haveState = promiseStateFinal(p); + try { + await p; + assert_unreached("Control. Must not succeed"); + } catch (e) { + assert_equals(e.name, "InvalidStateError"); + } + assert_equals(await haveState, "rejected", "promise rejected on same task"); +}, "createOffer must detect InvalidStateError synchronously when chain is empty (prerequisite)"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const p = pc.createAnswer(); + const haveState = promiseStateFinal(p); + try { + await p; + assert_unreached("Control. Must not succeed"); + } catch (e) { + assert_equals(e.name, "InvalidStateError"); + } + assert_equals(await haveState, "rejected", "promise rejected on same task"); +}, "createAnswer must detect InvalidStateError synchronously when chain is empty (prerequisite)"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const p = pc.setLocalDescription({type: "rollback"}); + const haveState = promiseStateFinal(p); + try { + await p; + assert_unreached("Control. Must not succeed"); + } catch (e) { + assert_equals(e.name, "InvalidStateError"); + } + assert_equals(await haveState, "rejected", "promise rejected on same task"); +}, "SLD(rollback) must detect InvalidStateError synchronously when chain is empty"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const p = pc.addIceCandidate(); + const haveState = promiseStateFinal(p); + try { + await p; + assert_unreached("Control. Must not succeed"); + } catch (e) { + assert_equals(e.name, "InvalidStateError"); + } + assert_equals(pc.remoteDescription, null, "no remote desciption"); + assert_equals(await haveState, "rejected", "promise rejected on same task"); +}, "addIceCandidate must detect InvalidStateError synchronously when chain is empty"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver("audio"); + transceiver.stop(); + const p = transceiver.sender.replaceTrack(null); + const haveState = promiseStateFinal(p); + try { + await p; + assert_unreached("Control. Must not succeed"); + } catch (e) { + assert_equals(e.name, "InvalidStateError"); + } + assert_equals(await haveState, "rejected", "promise rejected on same task"); +}, "replaceTrack must detect InvalidStateError synchronously when chain is empty and transceiver is stopped"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver("audio"); + transceiver.stop(); + const parameters = transceiver.sender.getParameters(); + const p = transceiver.sender.setParameters(parameters); + const haveState = promiseStateFinal(p); + try { + await p; + assert_unreached("Control. Must not succeed"); + } catch (e) { + assert_equals(e.name, "InvalidStateError"); + } + assert_equals(await haveState, "rejected", "promise rejected on same task"); +}, "setParameters must detect InvalidStateError synchronously always when transceiver is stopped"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {track} = new RTCPeerConnection().addTransceiver("audio").receiver; + assert_not_equals(track, null); + const p = pc.getStats(track); + const haveState = promiseStateFinal(p); + try { + await p; + assert_unreached("Control. Must not succeed"); + } catch (e) { + assert_equals(e.name, "InvalidAccessError"); + } + assert_equals(await haveState, "rejected", "promise rejected on same task"); +}, "pc.getStats must detect InvalidAccessError synchronously always"); + +// Helper builds on above tests to check if operations queue is empty or not. +// +// Meaning of "empty": Because this helper uses the sloppy promiseStateFinal, +// it may not detect operations on the chain unless they block the current run +// of the event loop. In other words, it may not detect operations on the chain +// that resolve on the emptying of the microtask queue at the end of this run of +// the event loop. + +async function isOperationsChainEmpty(pc) { + let p, error; + const signalingState = pc.signalingState; + if (signalingState == "have-remote-offer") { + p = pc.createOffer(); + } else { + p = pc.createAnswer(); + } + const state = await promiseStateFinal(p); + try { + await p; + // This helper tries to avoid side-effects by always failing, + // but createAnswer above may succeed if chained after an SRD + // that changes the signaling state on us. Ignore that success. + if (signalingState == pc.signalingState) { + assert_unreached("Control. Must not succeed"); + } + } catch (e) { + assert_equals(e.name, "InvalidStateError", + "isOperationsChainEmpty is working"); + } + return state == "rejected"; +} + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + assert_true(await isOperationsChainEmpty(pc), "Empty to start"); +}, "isOperationsChainEmpty detects empty in stable"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + await pc.setLocalDescription(await pc.createOffer()); + assert_true(await isOperationsChainEmpty(pc), "Empty to start"); +}, "isOperationsChainEmpty detects empty in have-local-offer"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + await pc.setRemoteDescription(await pc.createOffer()); + assert_true(await isOperationsChainEmpty(pc), "Empty to start"); +}, "isOperationsChainEmpty detects empty in have-remote-offer"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const p = pc.createOffer(); + assert_false(await isOperationsChainEmpty(pc), "Non-empty chain"); + await p; +}, "createOffer uses operations chain"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + await pc.setRemoteDescription(await pc.createOffer()); + const p = pc.createAnswer(); + assert_false(await isOperationsChainEmpty(pc), "Non-empty chain"); + await p; +}, "createAnswer uses operations chain"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const offer = await pc.createOffer(); + assert_true(await isOperationsChainEmpty(pc), "Empty before"); + const p = pc.setLocalDescription(offer); + assert_false(await isOperationsChainEmpty(pc), "Non-empty chain"); + await p; +}, "setLocalDescription uses operations chain"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const offer = await pc.createOffer(); + assert_true(await isOperationsChainEmpty(pc), "Empty before"); + const p = pc.setRemoteDescription(offer); + assert_false(await isOperationsChainEmpty(pc), "Non-empty chain"); + await p; +}, "setRemoteDescription uses operations chain"); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("video"); + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + const {candidate} = await new Promise(r => pc1.onicecandidate = r); + await pc2.setRemoteDescription(offer); + const p = pc2.addIceCandidate(candidate); + assert_false(await isOperationsChainEmpty(pc2), "Non-empty chain"); + await p; +}, "addIceCandidate uses operations chain"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver("audio"); + await new Promise(r => pc.onnegotiationneeded = r); + assert_true(await isOperationsChainEmpty(pc), "Empty chain"); + await new Promise(r => t.step_timeout(r, 0)); + assert_true(await isOperationsChainEmpty(pc), "Empty chain"); +}, "Firing of negotiationneeded does NOT use operations chain"); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + pc1.addTransceiver("video"); + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + const candidates = []; + for (let c; (c = (await new Promise(r => pc1.onicecandidate = r)).candidate);) { + candidates.push(c); + } + pc2.addTransceiver("video"); + let fired = false; + const p = new Promise(r => pc2.onnegotiationneeded = () => r(fired = true)); + await Promise.all([ + pc2.setRemoteDescription(offer), + ...candidates.map(candidate => pc2.addIceCandidate(candidate)), + pc2.setLocalDescription() + ]); + assert_false(fired, "Negotiationneeded mustn't have fired yet."); + await new Promise(r => t.step_timeout(r, 0)); + assert_true(fired, "Negotiationneeded must have fired by now."); + await p; +}, "Negotiationneeded only fires once operations chain is empty"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver("audio"); + await new Promise(r => pc.onnegotiationneeded = r); + // Note: since the negotiationneeded event is fired from a chained synchronous + // function in the spec, queue a task before doing our precheck. + await new Promise(r => t.step_timeout(r, 0)); + assert_true(await isOperationsChainEmpty(pc), "Empty chain"); + const p = transceiver.sender.replaceTrack(null); + assert_false(await isOperationsChainEmpty(pc), "Non-empty chain"); + await p; +}, "replaceTrack uses operations chain"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver("audio"); + await new Promise(r => pc.onnegotiationneeded = r); + await new Promise(r => t.step_timeout(r, 0)); + assert_true(await isOperationsChainEmpty(pc), "Empty chain"); + const parameters = transceiver.sender.getParameters(); + const p = transceiver.sender.setParameters(parameters); + const haveState = promiseStateFinal(p); + assert_true(await isOperationsChainEmpty(pc), "Empty chain"); + assert_equals(await haveState, "pending", "Method is async"); + await p; +}, "setParameters does NOT use the operations chain"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const p = pc.getStats(); + const haveState = promiseStateFinal(p); + assert_true(await isOperationsChainEmpty(pc), "Empty chain"); + assert_equals(await haveState, "pending", "Method is async"); + await p; +}, "pc.getStats does NOT use the operations chain"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {sender} = pc.addTransceiver("audio"); + await new Promise(r => pc.onnegotiationneeded = r); + await new Promise(r => t.step_timeout(r, 0)); + assert_true(await isOperationsChainEmpty(pc), "Empty chain"); + const p = sender.getStats(); + const haveState = promiseStateFinal(p); + assert_true(await isOperationsChainEmpty(pc), "Empty chain"); + assert_equals(await haveState, "pending", "Method is async"); + await p; +}, "sender.getStats does NOT use the operations chain"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {receiver} = pc.addTransceiver("audio"); + await new Promise(r => pc.onnegotiationneeded = r); + await new Promise(r => t.step_timeout(r, 0)); + assert_true(await isOperationsChainEmpty(pc), "Empty chain"); + const p = receiver.getStats(); + const haveState = promiseStateFinal(p); + assert_true(await isOperationsChainEmpty(pc), "Empty chain"); + assert_equals(await haveState, "pending", "Method is async"); + await p; +}, "receiver.getStats does NOT use the operations chain"); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("video"); + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + const {candidate} = await new Promise(r => pc1.onicecandidate = r); + try { + await pc2.addIceCandidate(candidate); + assert_unreached("Control. Must not succeed"); + } catch (e) { + assert_equals(e.name, "InvalidStateError"); + } + const p = pc2.setRemoteDescription(offer); + await pc2.addIceCandidate(candidate); + await p; +}, "addIceCandidate chains onto SRD, fails before"); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const offer = await pc.createOffer(); + pc.addTransceiver("video"); + await new Promise(r => pc.onnegotiationneeded = r); + const p = (async () => { + await pc.setLocalDescription(); + })(); + await new Promise(r => t.step_timeout(r, 0)); + await pc.setRemoteDescription(offer); + await p; +}, "Operations queue not vulnerable to recursion by chained negotiationneeded"); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("video"); + await Promise.all([ + pc1.createOffer(), + pc1.setLocalDescription({type: "offer"}) + ]); + await Promise.all([ + pc2.setRemoteDescription(pc1.localDescription), + pc2.createAnswer(), + pc2.setLocalDescription({type: "answer"}) + ]); + await pc1.setRemoteDescription(pc2.localDescription); +}, "Pack operations queue with implicit offer and answer"); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const state = (pc, s) => new Promise(r => pc.onsignalingstatechange = + () => pc.signalingState == s && r()); + pc1.addTransceiver("video"); + pc1.createOffer(); + pc1.setLocalDescription({type: "offer"}); + await state(pc1, "have-local-offer"); + pc2.setRemoteDescription(pc1.localDescription); + pc2.createAnswer(); + pc2.setLocalDescription({type: "answer"}); + await state(pc2, "stable"); + await pc1.setRemoteDescription(pc2.localDescription); +}, "Negotiate solely by operations queue and signaling state"); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-perfect-negotiation-helper.js b/testing/web-platform/tests/webrtc/RTCPeerConnection-perfect-negotiation-helper.js new file mode 100644 index 0000000000..ed647bbe78 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-perfect-negotiation-helper.js @@ -0,0 +1,153 @@ +'use strict' + +function peer(other, polite, fail = null) { + const send = (tgt, msg) => tgt.postMessage(JSON.parse(JSON.stringify(msg)), + "*"); + if (!fail) fail = e => send(window.parent, {error: `${e.name}: ${e.message}`}); + const pc = new RTCPeerConnection(); + + if (!window.assert_equals) { + window.assert_equals = (a, b, msg) => a === b || + fail(new Error(`${msg} expected ${b} but got ${a}`)); + } + + const commands = { + async addTransceiver() { + const transceiver = pc.addTransceiver("video"); + await new Promise(r => pc.addEventListener("negotiated", r, {once: true})); + if (!transceiver.currentDirection) { + // Might have just missed the negotiation train. Catch next one. + await new Promise(r => pc.addEventListener("negotiated", r, {once: true})); + } + assert_equals(transceiver.currentDirection, "sendonly", "have direction"); + return pc.getTransceivers().length; + }, + async simpleConnect() { + const p = commands.addTransceiver(); + await new Promise(r => pc.oniceconnectionstatechange = + () => pc.iceConnectionState == "connected" && r()); + return await p; + }, + async getNumTransceivers() { + return pc.getTransceivers().length; + }, + }; + + try { + pc.addEventListener("icecandidate", ({candidate}) => send(other, + {candidate})); + let makingOffer = false, ignoreIceCandidateFailures = false; + let srdAnswerPending = false; + pc.addEventListener("negotiationneeded", async () => { + try { + assert_equals(pc.signalingState, "stable", "negotiationneeded always fires in stable state"); + assert_equals(makingOffer, false, "negotiationneeded not already in progress"); + makingOffer = true; + await pc.setLocalDescription(); + assert_equals(pc.signalingState, "have-local-offer", "negotiationneeded not racing with onmessage"); + assert_equals(pc.localDescription.type, "offer", "negotiationneeded SLD worked"); + send(other, {description: pc.localDescription}); + } catch (e) { + fail(e); + } finally { + makingOffer = false; + } + }); + window.onmessage = async ({data: {description, candidate, run}}) => { + try { + if (description) { + // If we have a setRemoteDescription() answer operation pending, then + // we will be "stable" by the time the next setRemoteDescription() is + // executed, so we count this being stable when deciding whether to + // ignore the offer. + let isStable = + pc.signalingState == "stable" || + (pc.signalingState == "have-local-offer" && srdAnswerPending); + const ignoreOffer = description.type == "offer" && !polite && + (makingOffer || !isStable); + if (ignoreOffer) { + ignoreIceCandidateFailures = true; + return; + } + if (description.type == "answer") + srdAnswerPending = true; + await pc.setRemoteDescription(description); + ignoreIceCandidateFailures = false; + srdAnswerPending = false; + if (description.type == "offer") { + assert_equals(pc.signalingState, "have-remote-offer", "Remote offer"); + assert_equals(pc.remoteDescription.type, "offer", "SRD worked"); + await pc.setLocalDescription(); + assert_equals(pc.signalingState, "stable", "onmessage not racing with negotiationneeded"); + assert_equals(pc.localDescription.type, "answer", "onmessage SLD worked"); + send(other, {description: pc.localDescription}); + } else { + assert_equals(pc.remoteDescription.type, "answer", "Answer was set"); + assert_equals(pc.signalingState, "stable", "answered"); + pc.dispatchEvent(new Event("negotiated")); + } + } else if (candidate) { + try { + await pc.addIceCandidate(candidate); + } catch (e) { + if (!ignoreIceCandidateFailures) throw e; + } + } else if (run) { + send(window.parent, {[run.id]: await commands[run.cmd]() || 0}); + } + } catch (e) { + fail(e); + } + }; + } catch (e) { + fail(e); + } + return pc; +} + +async function setupPeerIframe(t, polite) { + const iframe = document.createElement("iframe"); + t.add_cleanup(() => iframe.remove()); + iframe.srcdoc = + `<html\><script\>(${peer.toString()})(window.parent, ${polite});</script\></html\>`; + document.documentElement.appendChild(iframe); + + const failCatcher = t.step_func(({data}) => + ("error" in data) && assert_unreached(`Error in iframe: ${data.error}`)); + window.addEventListener("message", failCatcher); + t.add_cleanup(() => window.removeEventListener("message", failCatcher)); + await new Promise(r => iframe.onload = r); + return iframe; +} + +function setupPeerTopLevel(t, other, polite) { + const pc = peer(other, polite, t.step_func(e => { throw e; })); + t.add_cleanup(() => { pc.close(); window.onmessage = null; }); +} + +let counter = 0; +async function run(target, cmd) { + const id = `result${counter++}`; + target.postMessage({run: {cmd, id}}, "*"); + return new Promise(r => window.addEventListener("message", + function listen({data}) { + if (!(id in data)) return; + window.removeEventListener("message", listen); + r(data[id]); + })); +} + +let iframe; +async function setupAB(t, politeA, politeB) { + iframe = await setupPeerIframe(t, politeB); + return setupPeerTopLevel(t, iframe.contentWindow, politeA); +} +const runA = cmd => run(window, cmd); +const runB = cmd => run(iframe.contentWindow, cmd); +const runBoth = (cmdA, cmdB = cmdA) => Promise.all([runA(cmdA), runB(cmdB)]); + +async function promise_test_both_roles(f, name) { + promise_test(async t => f(t, await setupAB(t, true, false)), name); + promise_test(async t => f(t, await setupAB(t, false, true)), + `${name} with roles reversed`); +} diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-perfect-negotiation-stress-glare-linear.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-perfect-negotiation-stress-glare-linear.https.html new file mode 100644 index 0000000000..cf8bdf22e2 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-perfect-negotiation-stress-glare-linear.https.html @@ -0,0 +1,23 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title></title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="RTCPeerConnection-perfect-negotiation-helper.js"></script> +<script> +'use strict'; + +promise_test_both_roles(async (t, pc) => { + const ps = []; + for (let i = 10; i > 0; i--) { + ps.push(runBoth("addTransceiver")); + await new Promise(r => t.step_timeout(r, 0)); + } + ps.push(runBoth("addTransceiver")); + await Promise.all(ps); + const [numA, numB] = await runBoth("getNumTransceivers"); + assert_equals(numA, 22, "22 transceivers on side A"); + assert_equals(numB, 22, "22 transceivers on side B"); +}, "Perfect negotiation stress glare linear"); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-perfect-negotiation-stress-glare.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-perfect-negotiation-stress-glare.https.html new file mode 100644 index 0000000000..6134eb2006 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-perfect-negotiation-stress-glare.https.html @@ -0,0 +1,23 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title></title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="RTCPeerConnection-perfect-negotiation-helper.js"></script> +<script> +'use strict'; + +promise_test_both_roles(async (t, pc) => { + const ps = []; + for (let i = 10; i > 0; i--) { + ps.push(runBoth("addTransceiver")); + await new Promise(r => t.step_timeout(r, i - 1)); + } + ps.push(runBoth("addTransceiver")); + await Promise.all(ps); + const [numA, numB] = await runBoth("getNumTransceivers"); + assert_equals(numA, 22, "22 transceivers on side A"); + assert_equals(numB, 22, "22 transceivers on side B"); +}, "Perfect negotiation stress glare"); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-perfect-negotiation.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-perfect-negotiation.https.html new file mode 100644 index 0000000000..d01b116162 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-perfect-negotiation.https.html @@ -0,0 +1,22 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title></title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="RTCPeerConnection-perfect-negotiation-helper.js"></script> +<script> +'use strict'; + +promise_test_both_roles(async (t, pc) => { + assert_equals(await runA("simpleConnect"), 1, "one transceiver"); + assert_equals(await runB("addTransceiver"), 2, "two transceivers"); +}, "Perfect negotiation setup connects"); + +promise_test_both_roles(async (t, pc) => { + await runBoth("addTransceiver"); + const [numA, numB] = await runBoth("getNumTransceivers"); + assert_equals(numA, 2, "two transceivers on side A"); + assert_equals(numB, 2, "two transceivers on side B"); +}, "Perfect negotiation glare"); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-plan-b-is-not-supported.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-plan-b-is-not-supported.html new file mode 100644 index 0000000000..bde6b1b003 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-plan-b-is-not-supported.html @@ -0,0 +1,28 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title></title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +'use strict'; + +promise_test(async t => { + // Plan B is a legacy feature that should not be supported on a modern + // browser. To pass this test you must either ignore sdpSemantics altogether + // (and construct with Unified Plan despite us asking for Plan B) or throw an + // exception. + let pc = null; + try { + pc = new RTCPeerConnection({sdpSemantics:"plan-b"}); + t.add_cleanup(() => pc.close()); + } catch (e) { + // Test passed! + return; + } + // If we did not throw, we must not have gotten what we asked for. If + // sdpSemantics is not recognized by the browser it will be undefined here. + assert_not_equals(pc.getConfiguration().sdpSemantics, "plan-b"); +}, 'Plan B is not supported'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-relay-canvas.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-relay-canvas.https.html new file mode 100644 index 0000000000..78df2ee82d --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-relay-canvas.https.html @@ -0,0 +1,84 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>Relay canvas via PeerConnections</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + +// This test checks that canvas capture works relayed between several peer connections. + +function GreenFrameWebGL(width, height) { + const canvas = + Object.assign(document.createElement('canvas'), {width, height}); + const ctx = canvas.getContext('webgl'); + assert_not_equals(ctx, null, "webgl is a prerequisite for this test"); + requestAnimationFrame(function draw () { + ctx.clearColor(0.0, 1.0, 0.0, 1.0); + ctx.clear(ctx.COLOR_BUFFER_BIT); + requestAnimationFrame(draw); + }); + return canvas.captureStream(); +} + + + +promise_test(async t => { + + // Build a chain + // canvas -track-> pc1 -network-> pcRelayIn -track-> + // pcRelayOut -network-> pc2 -track-> video + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pcRelayIn = new RTCPeerConnection(); + t.add_cleanup(() => pcRelayIn.close()); + + const pcRelayOut = new RTCPeerConnection(); + t.add_cleanup(() => pcRelayOut.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + // Attach canvas to pc1. + const stream = GreenFrameWebGL(640, 480); + const [track] = stream.getTracks(); + pc1.addTrack(track); + + const v = document.createElement('video'); + v.autoplay = true; + + // Setup pc1->pcRelayIn video stream. + const haveTrackEvent1 = new Promise(r => pcRelayIn.ontrack = r); + exchangeIceCandidates(pc1, pcRelayIn); + await pc1.setLocalDescription(); + await pcRelayIn.setRemoteDescription(pc1.localDescription); + await pcRelayIn.setLocalDescription(); + await pc1.setRemoteDescription(pcRelayIn.localDescription); + + // Plug output of pcRelayIn to pcRelayOut. + pcRelayOut.addTrack((await haveTrackEvent1).track); + + // Setup pcRelayOut->pc2 video stream. + const haveTrackEvent2 = new Promise(r => pc2.ontrack = r); + exchangeIceCandidates(pcRelayOut, pc2); + await pcRelayOut.setLocalDescription(); + await pc2.setRemoteDescription(pcRelayOut.localDescription); + await pc2.setLocalDescription(); + await pcRelayOut.setRemoteDescription(pc2.localDescription); + + // Display pc2 received track in video element. + v.srcObject = new MediaStream([(await haveTrackEvent2).track]); + await new Promise(r => v.onloadedmetadata = r); + + // Wait some time to ensure that frames got through. + await new Promise(resolve => t.step_timeout(resolve, 1000)); + + // Uses Helper.js GetVideoSignal to query |v| pixel value at a certain position. + const pixelValue = getVideoSignal(v); + + // Expected value computed based on GetVideoSignal code, which takes green pixel data + // with coefficient 0.72. + assert_approx_equals(pixelValue, 0.72*255, 3); + }, "Two PeerConnections relaying a canvas source"); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-remote-track-mute.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-remote-track-mute.https.html new file mode 100644 index 0000000000..c280a7d44d --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-remote-track-mute.https.html @@ -0,0 +1,132 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection-transceivers.https.html</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +// The following helper functions are called from RTCPeerConnection-helper.js: +// exchangeOffer +// exchangeOfferAndListenToOntrack +// exchangeAnswer +// exchangeAnswerAndListenToOntrack +// addEventListenerPromise +// createPeerConnectionWithCleanup +// createTrackAndStreamWithCleanup +// findTransceiverForSender + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + pc1.addTrack(... await createTrackAndStreamWithCleanup(t)); + const pc2 = createPeerConnectionWithCleanup(t); + exchangeIceCandidates(pc1, pc2); + + const unmuteResolver = new Resolver(); + let remoteTrack = null; + // The unmuting it timing sensitive so we hook up to the event directly + // instead of wrapping it in an EventWatcher which uses promises. + pc2.ontrack = t.step_func(e => { + remoteTrack = e.track; + assert_true(remoteTrack.muted, 'track is muted in ontrack'); + remoteTrack.onunmute = t.step_func(e => { + assert_false(remoteTrack.muted, 'track is unmuted in onunmute'); + unmuteResolver.resolve(); + }); + pc2.ontrack = t.step_func(e => { + assert_unreached('ontrack fired unexpectedly'); + }); + }); + await exchangeOfferAnswer(pc1, pc2); + await unmuteResolver; +}, 'ontrack: track goes from muted to unmuted'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const pc1Sender = pc1.addTrack(... await createTrackAndStreamWithCleanup(t)); + const localTransceiver = findTransceiverForSender(pc1, pc1Sender); + const pc2 = createPeerConnectionWithCleanup(t); + exchangeIceCandidates(pc1, pc2); + + const e = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + // Need to wait for the initial unmute event before renegotiating, otherwise + // there will be no transition from unmuted->muted. + const muteWatcher = new EventWatcher(t, e.track, ['mute', 'unmute']); + const unmutePromise = muteWatcher.wait_for('unmute'); + await exchangeAnswer(pc1, pc2); + await unmutePromise; + + const mutePromise = muteWatcher.wait_for('mute'); + localTransceiver.direction = 'inactive'; + await exchangeOfferAnswer(pc1, pc2); + + await mutePromise; +}, 'Changing transceiver direction to \'inactive\' mutes the remote track'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const pc1Sender = pc1.addTrack(... await createTrackAndStreamWithCleanup(t)); + const localTransceiver = findTransceiverForSender(pc1, pc1Sender); + const pc2 = createPeerConnectionWithCleanup(t); + exchangeIceCandidates(pc1, pc2); + + const e = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + const muteWatcher = new EventWatcher(t, e.track, ['mute', 'unmute']); + await exchangeAnswer(pc1, pc2); + await muteWatcher.wait_for('unmute'); + + const mutePromise = muteWatcher.wait_for('mute'); + localTransceiver.direction = 'inactive'; + await exchangeOfferAnswer(pc1, pc2); + await mutePromise; + + const unmutePromise = muteWatcher.wait_for('unmute'); + localTransceiver.direction = 'sendrecv'; + await exchangeOfferAnswer(pc1, pc2); + + await unmutePromise; +}, 'Changing transceiver direction to \'sendrecv\' unmutes the remote track'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const pc1Sender = pc1.addTrack(... await createTrackAndStreamWithCleanup(t)); + const localTransceiver = findTransceiverForSender(pc1, pc1Sender); + const pc2 = createPeerConnectionWithCleanup(t); + exchangeIceCandidates(pc1, pc2); + + const e = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + // Need to wait for the initial unmute event before closing, otherwise + // there will be no transition from unmuted->muted. + const muteWatcher = new EventWatcher(t, e.track, ['mute', 'unmute']); + const unmutePromise = muteWatcher.wait_for('unmute'); + await exchangeAnswer(pc1, pc2); + await unmutePromise; + + const mutePromise = muteWatcher.wait_for('mute'); + localTransceiver.stop(); + await mutePromise; +}, 'transceiver.stop() on one side (without renegotiation) causes mute events on the other'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const pc1Sender = pc1.addTrack(...await createTrackAndStreamWithCleanup(t)); + const localTransceiver = findTransceiverForSender(pc1, pc1Sender); + const pc2 = createPeerConnectionWithCleanup(t); + exchangeIceCandidates(pc1, pc2); + + const e = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + // Need to wait for the initial unmute event before closing, otherwise + // there will be no transition from unmuted->muted. + const muteWatcher = new EventWatcher(t, e.track, ['mute', 'unmute']); + const unmutePromise = muteWatcher.wait_for('unmute'); + await exchangeAnswer(pc1, pc2); + await unmutePromise; + + const mutePromise = muteWatcher.wait_for('mute'); + pc1.close(); + await mutePromise; +}, 'pc.close() on one side causes mute events on the other'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-removeTrack.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-removeTrack.https.html new file mode 100644 index 0000000000..83095c085a --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-removeTrack.https.html @@ -0,0 +1,338 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.removeTrack</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // generateAnswer + + /* + 5.1. RTCPeerConnection Interface Extensions + partial interface RTCPeerConnection { + ... + void removeTrack(RTCRtpSender sender); + RTCRtpTransceiver addTransceiver((MediaStreamTrack or DOMString) trackOrKind, + optional RTCRtpTransceiverInit init); + }; + */ + + // Before calling removeTrack can be tested, one needs to add MediaStreamTracks to + // a peer connection. There are two ways for adding MediaStreamTrack: addTrack and + // addTransceiver. addTransceiver is a newer API while addTrack has been implemented + // in current browsers for some time. As a result some of the removeTrack tests have + // two versions so that removeTrack can be partially tested without addTransceiver + // and the transceiver APIs being implemented. + + /* + 5.1. removeTrack + 3. If connection's [[isClosed]] slot is true, throw an InvalidStateError. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const transceiver = pc.addTransceiver(track); + const { sender } = transceiver; + + pc.close(); + assert_throws_dom('InvalidStateError', () => pc.removeTrack(sender)); + }, 'addTransceiver - Calling removeTrack when connection is closed should throw InvalidStateError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const sender = pc.addTrack(track, stream); + + pc.close(); + assert_throws_dom('InvalidStateError', () => pc.removeTrack(sender)); + }, 'addTrack - Calling removeTrack when connection is closed should throw InvalidStateError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const transceiver = pc.addTransceiver(track); + const { sender } = transceiver; + + const pc2 = new RTCPeerConnection(); + pc2.close(); + assert_throws_dom('InvalidStateError', () => pc2.removeTrack(sender)); + }, 'addTransceiver - Calling removeTrack on different connection that is closed should throw InvalidStateError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const sender = pc.addTrack(track, stream); + + const pc2 = new RTCPeerConnection(); + pc2.close(); + assert_throws_dom('InvalidStateError', () => pc2.removeTrack(sender)); + }, 'addTrack - Calling removeTrack on different connection that is closed should throw InvalidStateError'); + + /* + 5.1. removeTrack + 4. If sender was not created by connection, throw an InvalidAccessError. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const transceiver = pc.addTransceiver(track); + const { sender } = transceiver; + + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + assert_throws_dom('InvalidAccessError', () => pc2.removeTrack(sender)); + }, 'addTransceiver - Calling removeTrack on different connection should throw InvalidAccessError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const sender = pc.addTrack(track, stream); + + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + assert_throws_dom('InvalidAccessError', () => pc2.removeTrack(sender)); + }, 'addTrack - Calling removeTrack on different connection should throw InvalidAccessError') + + /* + 5.1. removeTrack + 7. Set sender.track to null. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const transceiver = pc.addTransceiver(track); + const { sender } = transceiver; + + assert_equals(sender.track, track); + assert_equals(transceiver.direction, 'sendrecv'); + assert_equals(transceiver.currentDirection, null); + + pc.removeTrack(sender); + assert_equals(sender.track, null); + assert_equals(transceiver.direction, 'recvonly'); + }, 'addTransceiver - Calling removeTrack with valid sender should set sender.track to null'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({ audio: true }); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const sender = pc.addTrack(track, stream); + + assert_equals(sender.track, track); + + pc.removeTrack(sender); + assert_equals(sender.track, null); + }, 'addTrack - Calling removeTrack with valid sender should set sender.track to null'); + + /* + 5.1. removeTrack + 7. Set sender.track to null. + 10. If transceiver.currentDirection is sendrecv set transceiver.direction + to recvonly. + */ + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const transceiver = caller.addTransceiver(track); + const { sender } = transceiver; + + assert_equals(sender.track, track); + assert_equals(transceiver.direction, 'sendrecv'); + assert_equals(transceiver.currentDirection, null); + + const offer = await caller.createOffer(); + await caller.setLocalDescription(offer); + await callee.setRemoteDescription(offer); + callee.addTrack(track, stream); + const answer = await callee.createAnswer(); + await callee.setLocalDescription(answer); + await caller.setRemoteDescription(answer); + assert_equals(transceiver.currentDirection, 'sendrecv'); + + caller.removeTrack(sender); + assert_equals(sender.track, null); + assert_equals(transceiver.direction, 'recvonly'); + assert_equals(transceiver.currentDirection, 'sendrecv', + 'Expect currentDirection to not change'); + }, 'Calling removeTrack with currentDirection sendrecv should set direction to recvonly'); + + /* + 5.1. removeTrack + 7. Set sender.track to null. + 11. If transceiver.currentDirection is sendonly set transceiver.direction + to inactive. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const transceiver = pc.addTransceiver(track, { direction: 'sendonly' }); + const { sender } = transceiver; + + assert_equals(sender.track, track); + assert_equals(transceiver.direction, 'sendonly'); + assert_equals(transceiver.currentDirection, null); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await generateAnswer(offer); + await pc.setRemoteDescription(answer); + assert_equals(transceiver.currentDirection, 'sendonly'); + + pc.removeTrack(sender); + assert_equals(sender.track, null); + assert_equals(transceiver.direction, 'inactive'); + assert_equals(transceiver.currentDirection, 'sendonly', + 'Expect currentDirection to not change'); + }, 'Calling removeTrack with currentDirection sendonly should set direction to inactive'); + + /* + 5.1. removeTrack + 7. Set sender.track to null. + 9. If transceiver.currentDirection is recvonly or inactive, + then abort these steps. + */ + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const transceiver = caller.addTransceiver(track, { direction: 'recvonly' }); + const { sender } = transceiver; + + assert_equals(sender.track, track); + assert_equals(transceiver.direction, 'recvonly'); + assert_equals(transceiver.currentDirection, null); + + const offer = await caller.createOffer(); + await caller.setLocalDescription(offer); + await callee.setRemoteDescription(offer); + callee.addTrack(track, stream); + const answer = await callee.createAnswer(); + await callee.setLocalDescription(answer); + await caller.setRemoteDescription(answer); + assert_equals(transceiver.currentDirection, 'recvonly'); + + caller.removeTrack(sender); + assert_equals(sender.track, null); + assert_equals(transceiver.direction, 'recvonly'); + assert_equals(transceiver.currentDirection, 'recvonly'); + }, 'Calling removeTrack with currentDirection recvonly should not change direction'); + + /* + 5.1. removeTrack + 7. Set sender.track to null. + 9. If transceiver.currentDirection is recvonly or inactive, + then abort these steps. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const transceiver = pc.addTransceiver(track, { direction: 'inactive' }); + const { sender } = transceiver; + + assert_equals(sender.track, track); + assert_equals(transceiver.direction, 'inactive'); + assert_equals(transceiver.currentDirection, null); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await generateAnswer(offer); + await pc.setRemoteDescription(answer); + assert_equals(transceiver.currentDirection, 'inactive'); + + pc.removeTrack(sender); + assert_equals(sender.track, null); + assert_equals(transceiver.direction, 'inactive'); + assert_equals(transceiver.currentDirection, 'inactive'); + }, 'Calling removeTrack with currentDirection inactive should not change direction'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const sender = pc.addTrack(track, stream); + + pc.getTransceivers()[0].stop(); + pc.removeTrack(sender); + assert_equals(sender.track, track); + }, "Calling removeTrack on a stopped transceiver should be a no-op"); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const sender = pc.addTrack(track, stream); + + await sender.replaceTrack(null); + pc.removeTrack(sender); + assert_equals(sender.track, null); +}, "Calling removeTrack on a null track should have no effect"); + + + /* + TODO + 5.1. removeTrack + Stops sending media from sender. The RTCRtpSender will still appear + in getSenders. Doing so will cause future calls to createOffer to + mark the media description for the corresponding transceiver as + recvonly or inactive, as defined in [JSEP] (section 5.2.2.). + + When the other peer stops sending a track in this manner, an ended + event is fired at the MediaStreamTrack object. + + 6. If sender is not in senders (which indicates that it was removed + due to setting an RTCSessionDescription of type "rollback"), + then abort these steps. + 12. Update the negotiation-needed flag for connection. + */ +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-restartIce-onnegotiationneeded.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-restartIce-onnegotiationneeded.https.html new file mode 100644 index 0000000000..4dcce45199 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-restartIce-onnegotiationneeded.https.html @@ -0,0 +1,29 @@ +<!doctype html> +<meta charset=utf-8> +<title></title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +"use strict"; + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + + await pc1.setLocalDescription(await pc1.createOffer()); + pc1.restartIce(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(await pc2.createAnswer()); + await pc1.setRemoteDescription(pc2.localDescription); + // When the setRemoteDescription() promise above is resolved a task should be + // queued to fire the onnegotiationneeded event. Because of this, we should + // have time to hook up the event listener *after* awaiting the SRD promise. + await new Promise(r => pc1.onnegotiationneeded = r); +}, "Negotiation needed when returning to stable does not fire too early"); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-restartIce.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-restartIce.https.html new file mode 100644 index 0000000000..45a04d3a7a --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-restartIce.https.html @@ -0,0 +1,482 @@ +<!doctype html> +<meta charset=utf-8> +<title></title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +"use strict"; + +function getLines(sdp, startsWith) { + const lines = sdp.split("\r\n").filter(l => l.startsWith(startsWith)); + assert_true(lines.length > 0, `One or more ${startsWith} in sdp`); + return lines; +} + +const getUfrags = ({sdp}) => getLines(sdp, "a=ice-ufrag:"); +const getPwds = ({sdp}) => getLines(sdp, "a=ice-pwd:"); + +const negotiators = [ + { + tag: "", + async setOffer(pc) { + await pc.setLocalDescription(await pc.createOffer()); + }, + async setAnswer(pc) { + await pc.setLocalDescription(await pc.createAnswer()); + }, + }, + { + tag: " (perfect negotiation)", + async setOffer(pc) { + await pc.setLocalDescription(); + }, + async setAnswer(pc) { + await pc.setLocalDescription(); + }, + }, +]; + +async function exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator) { + await negotiator.setOffer(pc1); + await pc2.setRemoteDescription(pc1.localDescription); + await negotiator.setAnswer(pc2); + await pc1.setRemoteDescription(pc2.localDescription); // End on pc1. No race +} + +async function exchangeOfferAnswerEndOnSecond(pc1, pc2, negotiator) { + await negotiator.setOffer(pc1); + await pc2.setRemoteDescription(pc1.localDescription); + await pc1.setRemoteDescription(await pc2.createAnswer()); + await pc2.setLocalDescription(pc1.remoteDescription); // End on pc2. No race +} + +async function assertNoNegotiationNeeded(t, pc, state = "stable") { + assert_equals(pc.signalingState, state, `In ${state} state`); + const event = await Promise.race([ + new Promise(r => pc.onnegotiationneeded = r), + new Promise(r => t.step_timeout(r, 10)) + ]); + assert_equals(event, undefined, "No negotiationneeded event"); +} + +// In Chromium, assert_equals() produces test expectations with the values +// compared. Because ufrags are different on each run, this would make Chromium +// test expectations different on each run on tests that failed when comparing +// ufrags. To work around this problem, assert_ufrags_equals() and +// assert_ufrags_not_equals() should be preferred over assert_equals() and +// assert_not_equals(). +function assert_ufrags_equals(x, y, description) { + assert_true(x === y, description); +} +function assert_ufrags_not_equals(x, y, description) { + assert_false(x === y, description); +} + +promise_test(async t => { + const pc = new RTCPeerConnection(); + pc.close(); + pc.restartIce(); + await assertNoNegotiationNeeded(t, pc, "closed"); +}, "restartIce() has no effect on a closed peer connection"); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.restartIce(); + await assertNoNegotiationNeeded(t, pc1); + pc1.addTransceiver("audio"); + await new Promise(r => pc1.onnegotiationneeded = r); + await assertNoNegotiationNeeded(t, pc1); +}, "restartIce() does not trigger negotiation ahead of initial negotiation"); + +// Run remaining tests twice: once for each negotiator + +for (const negotiator of negotiators) { + const {tag} = negotiator; + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + await new Promise(r => pc1.onnegotiationneeded = r); + pc1.restartIce(); + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + await assertNoNegotiationNeeded(t, pc1); + }, `restartIce() has no effect on initial negotiation${tag}`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + await new Promise(r => pc1.onnegotiationneeded = r); + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + pc1.restartIce(); + await new Promise(r => pc1.onnegotiationneeded = r); + }, `restartIce() fires negotiationneeded after initial negotiation${tag}`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + + const [oldUfrag1] = getUfrags(pc1.localDescription); + const [oldUfrag2] = getUfrags(pc2.localDescription); + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + assert_ufrags_equals(getUfrags(pc1.localDescription)[0], oldUfrag1, "control 1"); + assert_ufrags_equals(getUfrags(pc2.localDescription)[0], oldUfrag2, "control 2"); + + pc1.restartIce(); + await new Promise(r => pc1.onnegotiationneeded = r); + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + const [newUfrag1] = getUfrags(pc1.localDescription); + const [newUfrag2] = getUfrags(pc2.localDescription); + assert_ufrags_not_equals(newUfrag1, oldUfrag1, "ufrag 1 changed"); + assert_ufrags_not_equals(newUfrag1, oldUfrag2, "ufrag 2 changed"); + await assertNoNegotiationNeeded(t, pc1); + + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + assert_ufrags_equals(getUfrags(pc1.localDescription)[0], newUfrag1, "Unchanged 1"); + assert_ufrags_equals(getUfrags(pc2.localDescription)[0], newUfrag2, "Unchanged 2"); + }, `restartIce() causes fresh ufrags${tag}`); + + promise_test(async t => { + const config = {bundlePolicy: "max-bundle"}; + const pc1 = new RTCPeerConnection(config); + const pc2 = new RTCPeerConnection(config); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.onicecandidate = e => pc2.addIceCandidate(e.candidate); + pc2.onicecandidate = e => pc1.addIceCandidate(e.candidate); + + // See the explanation below about Chrome's onnegotiationneeded firing + // too early. + const negotiationNeededPromise1 = + new Promise(r => pc1.onnegotiationneeded = r); + pc1.addTransceiver("video"); + pc1.addTransceiver("audio"); + await negotiationNeededPromise1; + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + + const [videoTc, audioTc] = pc1.getTransceivers(); + const [videoTp, audioTp] = + pc1.getTransceivers().map(tc => tc.sender.transport); + assert_equals(pc1.getTransceivers().length, 2, 'transceiver count'); + + // On Chrome, it is possible (likely, even) that videoTc.sender.transport.state + // will be 'connected' by the time we get here. We'll race 2 promises here: + // 1. Resolve after onstatechange is called with connected state. + // 2. If already connected, resolve immediately. + await Promise.race([ + new Promise(r => videoTc.sender.transport.onstatechange = + () => videoTc.sender.transport.state == "connected" && r()), + new Promise(r => videoTc.sender.transport.state == "connected" && r()) + ]); + assert_equals(videoTc.sender.transport.state, "connected"); + + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + assert_equals(videoTp, pc1.getTransceivers()[0].sender.transport, + 'offer/answer retains dtls transport'); + assert_equals(audioTp, pc1.getTransceivers()[1].sender.transport, + 'offer/answer retains dtls transport'); + + const negotiationNeededPromise2 = + new Promise(r => pc1.onnegotiationneeded = r); + pc1.restartIce(); + await negotiationNeededPromise2; + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + + const [newVideoTp, newAudioTp] = + pc1.getTransceivers().map(tc => tc.sender.transport); + assert_equals(videoTp, newVideoTp, 'ice restart retains dtls transport'); + assert_equals(audioTp, newAudioTp, 'ice restart retains dtls transport'); + }, `restartIce() retains dtls transports${tag}`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + await new Promise(r => pc1.onnegotiationneeded = r); + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + + const [oldUfrag1] = getUfrags(pc1.localDescription); + const [oldUfrag2] = getUfrags(pc2.localDescription); + + await negotiator.setOffer(pc1); + pc1.restartIce(); + await pc2.setRemoteDescription(pc1.localDescription); + await negotiator.setAnswer(pc2); + // Several tests in this file initializes the onnegotiationneeded listener + // before the setLocalDescription() or setRemoteDescription() that we expect + // to trigger negotiation needed. This allows Chrome to exercise these tests + // without timing out due to a bug that causes onnegotiationneeded to fire too + // early. + // TODO(https://crbug.com/985797): Once Chrome does not fire ONN too early, + // simply do "await new Promise(...)" instead of + // "await negotiationNeededPromise" here and in other tests in this file. + const negotiationNeededPromise = + new Promise(r => pc1.onnegotiationneeded = r); + await pc1.setRemoteDescription(pc2.localDescription); + assert_ufrags_equals(getUfrags(pc1.localDescription)[0], oldUfrag1, "Unchanged 1"); + assert_ufrags_equals(getUfrags(pc2.localDescription)[0], oldUfrag2, "Unchanged 2"); + await negotiationNeededPromise; + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + const [newUfrag1] = getUfrags(pc1.localDescription); + const [newUfrag2] = getUfrags(pc2.localDescription); + assert_ufrags_not_equals(newUfrag1, oldUfrag1, "ufrag 1 changed"); + assert_ufrags_not_equals(newUfrag1, oldUfrag2, "ufrag 2 changed"); + await assertNoNegotiationNeeded(t, pc1); + }, `restartIce() works in have-local-offer${tag}`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + await new Promise(r => pc1.onnegotiationneeded = r); + await negotiator.setOffer(pc1); + pc1.restartIce(); + await pc2.setRemoteDescription(pc1.localDescription); + await negotiator.setAnswer(pc2); + const negotiationNeededPromise = + new Promise(r => pc1.onnegotiationneeded = r); + await pc1.setRemoteDescription(pc2.localDescription); + const [oldUfrag1] = getUfrags(pc1.localDescription); + const [oldUfrag2] = getUfrags(pc2.localDescription); + await negotiationNeededPromise; + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + const [newUfrag1] = getUfrags(pc1.localDescription); + const [newUfrag2] = getUfrags(pc2.localDescription); + assert_ufrags_not_equals(newUfrag1, oldUfrag1, "ufrag 1 changed"); + assert_ufrags_not_equals(newUfrag1, oldUfrag2, "ufrag 2 changed"); + await assertNoNegotiationNeeded(t, pc1); + }, `restartIce() works in initial have-local-offer${tag}`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + await new Promise(r => pc1.onnegotiationneeded = r); + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + + const [oldUfrag1] = getUfrags(pc1.localDescription); + const [oldUfrag2] = getUfrags(pc2.localDescription); + + await negotiator.setOffer(pc2); + await pc1.setRemoteDescription(pc2.localDescription); + pc1.restartIce(); + await pc2.setRemoteDescription(await pc1.createAnswer()); + const negotiationNeededPromise = + new Promise(r => pc1.onnegotiationneeded = r); + await pc1.setLocalDescription(pc2.remoteDescription); // End on pc1. No race + assert_ufrags_equals(getUfrags(pc1.localDescription)[0], oldUfrag1, "Unchanged 1"); + assert_ufrags_equals(getUfrags(pc2.localDescription)[0], oldUfrag2, "Unchanged 2"); + await negotiationNeededPromise; + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + const [newUfrag1] = getUfrags(pc1.localDescription); + const [newUfrag2] = getUfrags(pc2.localDescription); + assert_ufrags_not_equals(newUfrag1, oldUfrag1, "ufrag 1 changed"); + assert_ufrags_not_equals(newUfrag1, oldUfrag2, "ufrag 2 changed"); + await assertNoNegotiationNeeded(t, pc1); + }, `restartIce() works in have-remote-offer${tag}`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc2.addTransceiver("audio"); + await negotiator.setOffer(pc2); + await pc1.setRemoteDescription(pc2.localDescription); + pc1.restartIce(); + await pc2.setRemoteDescription(await pc1.createAnswer()); + await pc1.setLocalDescription(pc2.remoteDescription); // End on pc1. No race + await assertNoNegotiationNeeded(t, pc1); + }, `restartIce() does nothing in initial have-remote-offer${tag}`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + await new Promise(r => pc1.onnegotiationneeded = r); + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + + const [oldUfrag1] = getUfrags(pc1.localDescription); + const [oldUfrag2] = getUfrags(pc2.localDescription); + + pc1.restartIce(); + await new Promise(r => pc1.onnegotiationneeded = r); + const negotiationNeededPromise = + new Promise(r => pc1.onnegotiationneeded = r); + await exchangeOfferAnswerEndOnSecond(pc2, pc1, negotiator); + assert_ufrags_equals(getUfrags(pc1.localDescription)[0], oldUfrag1, "nothing yet 1"); + assert_ufrags_equals(getUfrags(pc2.localDescription)[0], oldUfrag2, "nothing yet 2"); + await negotiationNeededPromise; + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + const [newUfrag1] = getUfrags(pc1.localDescription); + const [newUfrag2] = getUfrags(pc2.localDescription); + assert_ufrags_not_equals(newUfrag1, oldUfrag1, "ufrag 1 changed"); + assert_ufrags_not_equals(newUfrag2, oldUfrag2, "ufrag 2 changed"); + await assertNoNegotiationNeeded(t, pc1); + }, `restartIce() survives remote offer${tag}`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + await new Promise(r => pc1.onnegotiationneeded = r); + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + + const [oldUfrag1] = getUfrags(pc1.localDescription); + const [oldUfrag2] = getUfrags(pc2.localDescription); + + pc1.restartIce(); + pc2.restartIce(); + await new Promise(r => pc1.onnegotiationneeded = r); + await exchangeOfferAnswerEndOnSecond(pc2, pc1, negotiator); + const [newUfrag1] = getUfrags(pc1.localDescription); + const [newUfrag2] = getUfrags(pc2.localDescription); + assert_ufrags_not_equals(newUfrag1, oldUfrag1, "ufrag 1 changed"); + assert_ufrags_not_equals(newUfrag1, oldUfrag2, "ufrag 2 changed"); + await assertNoNegotiationNeeded(t, pc1); + + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + assert_ufrags_equals(getUfrags(pc1.localDescription)[0], newUfrag1, "Unchanged 1"); + assert_ufrags_equals(getUfrags(pc2.localDescription)[0], newUfrag2, "Unchanged 2"); + await assertNoNegotiationNeeded(t, pc1); + }, `restartIce() is satisfied by remote ICE restart${tag}`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + await new Promise(r => pc1.onnegotiationneeded = r); + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + + const [oldUfrag1] = getUfrags(pc1.localDescription); + const [oldUfrag2] = getUfrags(pc2.localDescription); + + pc1.restartIce(); + await new Promise(r => pc1.onnegotiationneeded = r); + await pc1.setLocalDescription(await pc1.createOffer({iceRestart: false})); + await pc2.setRemoteDescription(pc1.localDescription); + await negotiator.setAnswer(pc2); + await pc1.setRemoteDescription(pc2.localDescription); + const [newUfrag1] = getUfrags(pc1.localDescription); + const [newUfrag2] = getUfrags(pc2.localDescription); + assert_ufrags_not_equals(newUfrag1, oldUfrag1, "ufrag 1 changed"); + assert_ufrags_not_equals(newUfrag1, oldUfrag2, "ufrag 2 changed"); + await assertNoNegotiationNeeded(t, pc1); + }, `restartIce() trumps {iceRestart: false}${tag}`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + await new Promise(r => pc1.onnegotiationneeded = r); + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + + const [oldUfrag1] = getUfrags(pc1.localDescription); + const [oldUfrag2] = getUfrags(pc2.localDescription); + + pc1.restartIce(); + await new Promise(r => pc1.onnegotiationneeded = r); + await negotiator.setOffer(pc1); + const negotiationNeededPromise = + new Promise(r => pc1.onnegotiationneeded = r); + await pc1.setLocalDescription({type: "rollback"}); + await negotiationNeededPromise; + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + const [newUfrag1] = getUfrags(pc1.localDescription); + const [newUfrag2] = getUfrags(pc2.localDescription); + assert_ufrags_not_equals(newUfrag1, oldUfrag1, "ufrag 1 changed"); + assert_ufrags_not_equals(newUfrag1, oldUfrag2, "ufrag 2 changed"); + await assertNoNegotiationNeeded(t, pc1); + }, `restartIce() survives rollback${tag}`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection({bundlePolicy: "max-compat"}); + const pc2 = new RTCPeerConnection({bundlePolicy: "max-compat"}); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + pc1.addTransceiver("video"); + await new Promise(r => pc1.onnegotiationneeded = r); + await exchangeOfferAnswerEndOnFirst(pc1, pc2, negotiator); + + const oldUfrags1 = getUfrags(pc1.localDescription); + const oldUfrags2 = getUfrags(pc2.localDescription); + const oldPwds2 = getPwds(pc2.localDescription); + + pc1.restartIce(); + await new Promise(r => pc1.onnegotiationneeded = r); + + // Engineer a partial ICE restart from pc2 + pc2.restartIce(); + await negotiator.setOffer(pc2); + { + let {type, sdp} = pc2.localDescription; + // Restore both old ice-ufrag and old ice-pwd to trigger a partial restart + sdp = sdp.replace(getUfrags({sdp})[0], oldUfrags2[0]); + sdp = sdp.replace(getPwds({sdp})[0], oldPwds2[0]); + const newUfrags2 = getUfrags({sdp}); + const newPwds2 = getPwds({sdp}); + assert_ufrags_equals(newUfrags2[0], oldUfrags2[0], "control ufrag match"); + assert_ufrags_equals(newPwds2[0], oldPwds2[0], "control pwd match"); + assert_ufrags_not_equals(newUfrags2[1], oldUfrags2[1], "control ufrag non-match"); + assert_ufrags_not_equals(newPwds2[1], oldPwds2[1], "control pwd non-match"); + await pc1.setRemoteDescription({type, sdp}); + } + const negotiationNeededPromise = + new Promise(r => pc1.onnegotiationneeded = r); + await negotiator.setAnswer(pc1); + const newUfrags1 = getUfrags(pc1.localDescription); + assert_ufrags_equals(newUfrags1[0], oldUfrags1[0], "Unchanged 1"); + assert_ufrags_not_equals(newUfrags1[1], oldUfrags1[1], "Restarted 2"); + await negotiationNeededPromise; + await negotiator.setOffer(pc1); + const newestUfrags1 = getUfrags(pc1.localDescription); + assert_ufrags_not_equals(newestUfrags1[0], oldUfrags1[0], "Restarted 1"); + assert_ufrags_not_equals(newestUfrags1[1], oldUfrags1[1], "Restarted 2"); + await assertNoNegotiationNeeded(t, pc1); + }, `restartIce() survives remote offer containing partial restart${tag}`); + +} + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setDescription-transceiver.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setDescription-transceiver.html new file mode 100644 index 0000000000..9bbab30d56 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setDescription-transceiver.html @@ -0,0 +1,295 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Set Session Description - Transceiver Tests</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // generateAnswer + + /* + 4.3.2. Interface Definition + + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + Promise<void> setLocalDescription( + RTCSessionDescriptionInit description); + + Promise<void> setRemoteDescription( + RTCSessionDescriptionInit description); + ... + }; + + 4.6.2. RTCSessionDescription Class + dictionary RTCSessionDescriptionInit { + required RTCSdpType type; + DOMString sdp = ""; + }; + + 4.6.1. RTCSdpType + enum RTCSdpType { + "offer", + "pranswer", + "answer", + "rollback" + }; + + 5.4. RTCRtpTransceiver Interface + + interface RTCRtpTransceiver { + readonly attribute DOMString? mid; + [SameObject] + readonly attribute RTCRtpSender sender; + [SameObject] + readonly attribute RTCRtpReceiver receiver; + readonly attribute RTCRtpTransceiverDirection direction; + readonly attribute RTCRtpTransceiverDirection? currentDirection; + ... + }; + */ + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 7. If description is set as a local description, then run the following steps for + each media description in description that is not yet associated with an + RTCRtpTransceiver object: + 1. Let transceiver be the RTCRtpTransceiver used to create the media + description. + 2. Set transceiver's mid value to the mid of the corresponding media + description. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + assert_equals(transceiver.mid, null); + + return pc.createOffer() + .then(offer => { + assert_equals(transceiver.mid, null, + 'Expect transceiver.mid to still be null after createOffer'); + + return pc.setLocalDescription(offer) + .then(() => { + assert_equals(typeof transceiver.mid, 'string', + 'Expect transceiver.mid to set to valid string value'); + + assert_equals(offer.sdp.includes(`\r\na=mid:${transceiver.mid}`), true, + 'Expect transceiver mid to be found in offer SDP'); + }); + }); + }, 'setLocalDescription(offer) with m= section should assign mid to corresponding transceiver'); + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 8. If description is set as a remote description, then run the following steps + for each media description in description: + 2. If no suitable transceiver is found (transceiver is unset), run the following + steps: + 1. Create an RTCRtpSender, sender, from the media description. + 2. Create an RTCRtpReceiver, receiver, from the media description. + 3. Create an RTCRtpTransceiver with sender, receiver and direction, and let + transceiver be the result. + 3. Set transceiver's mid value to the mid of the corresponding media description. + */ + promise_test(t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + + t.add_cleanup(() => pc2.close()); + + const transceiver1 = pc1.addTransceiver('audio'); + assert_array_equals(pc1.getTransceivers(), [transceiver1]); + assert_array_equals(pc2.getTransceivers(), []); + + return pc1.createOffer() + .then(offer => { + return Promise.all([ + pc1.setLocalDescription(offer), + pc2.setRemoteDescription(offer) + ]) + .then(() => { + const transceivers = pc2.getTransceivers(); + assert_equals(transceivers.length, 1, + 'Expect new transceiver added to pc2 after setRemoteDescription'); + + const [ transceiver2 ] = transceivers; + + assert_equals(typeof transceiver2.mid, 'string', + 'Expect transceiver2.mid to be set'); + + assert_equals(transceiver1.mid, transceiver2.mid, + 'Expect transceivers of both side to have the same mid'); + + assert_equals(offer.sdp.includes(`\r\na=mid:${transceiver2.mid}`), true, + 'Expect transceiver mid to be found in offer SDP'); + }); + }); + }, 'setRemoteDescription(offer) with m= section and no existing transceiver should create corresponding transceiver'); + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 9. If description is of type "rollback", then run the following steps: + 1. If the mid value of an RTCRtpTransceiver was set to a non-null value by + the RTCSessionDescription that is being rolled back, set the mid value + of that transceiver to null, as described by [JSEP] (section 4.1.8.2.). + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + assert_equals(transceiver.mid, null); + + return pc.createOffer() + .then(offer => { + assert_equals(transceiver.mid, null); + return pc.setLocalDescription(offer); + }) + .then(() => { + assert_not_equals(transceiver.mid, null); + return pc.setLocalDescription({ type: 'rollback' }); + }) + .then(() => { + assert_equals(transceiver.mid, null, + 'Expect transceiver.mid to become null again after rollback'); + }); + }, 'setLocalDescription(rollback) should unset transceiver.mid'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver1 = pc.addTransceiver('audio'); + assert_equals(transceiver1.mid, null); + + return pc.createOffer() + .then(offer => + pc.setLocalDescription(offer) + .then(() => generateAnswer(offer))) + .then(answer => pc.setRemoteDescription(answer)) + .then(() => { + // pc is back to stable state + // create another transceiver + const transceiver2 = pc.addTransceiver('video'); + + assert_not_equals(transceiver1.mid, null); + assert_equals(transceiver2.mid, null); + + return pc.createOffer() + .then(offer => pc.setLocalDescription(offer)) + .then(() => { + assert_not_equals(transceiver1.mid, null); + assert_not_equals(transceiver2.mid, null, + 'Expect transceiver2.mid to become set'); + + return pc.setLocalDescription({ type: 'rollback' }); + }) + .then(() => { + assert_not_equals(transceiver1.mid, null, + 'Expect transceiver1.mid to stay set'); + + assert_equals(transceiver2.mid, null, + 'Expect transceiver2.mid to be rolled back to null'); + }); + }) + }, 'setLocalDescription(rollback) should only unset transceiver mids associated with current round'); + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 9. If description is of type "rollback", then run the following steps: + 2. If an RTCRtpTransceiver was created by applying the RTCSessionDescription + that is being rolled back, and a track has not been attached to it via + addTrack, remove that transceiver from connection's set of transceivers, + as described by [JSEP] (section 4.1.8.2.). + */ + promise_test(t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver('audio'); + + return pc1.createOffer() + .then(offer => pc2.setRemoteDescription(offer)) + .then(() => { + const transceivers = pc2.getTransceivers(); + assert_equals(transceivers.length, 1); + const [ transceiver ] = transceivers; + + assert_equals(typeof transceiver.mid, 'string', + 'Expect transceiver.mid to be set'); + + return pc2.setRemoteDescription({ type: 'rollback' }) + .then(() => { + assert_equals(transceiver.mid, null, + 'Expect transceiver.mid to be unset'); + + assert_array_equals(pc2.getTransceivers(), [], + `Expect transceiver to be removed from pc2's transceiver list`); + }); + }); + }, 'setRemoteDescription(rollback) should remove newly created transceiver from transceiver list'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver('audio'); + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + + await pc2.setRemoteDescription(offer); + pc2.getTransceivers()[0].stop(); + const answer = await pc2.createAnswer(); + + await pc1.setRemoteDescription(answer); + + assert_equals(pc1.getTransceivers()[0].currentDirection, 'inactive', 'A stopped m-line should give an inactive transceiver'); + }, 'setRemoteDescription should set transceiver inactive if its corresponding m section is rejected'); + + /* + TODO + - Steps for transceiver direction is added to tip of tree draft, but not yet + published as editor's draft + + 4.3.1.6. Set the RTCSessionSessionDescription + 8. If description is set as a remote description, then run the following steps + for each media description in description: + 1. As described by [JSEP] (section 5.9.), attempt to find an existing + RTCRtpTransceiver object, transceiver, to represent the media description. + 3. If the media description has no MID, and transceiver's mid is unset, generate + a random value as described in [JSEP] (section 5.9.). + 4. If the direction of the media description is sendrecv or sendonly, and + transceiver.receiver.track has not yet been fired in a track event, process + the remote track for the media description, given transceiver. + 5. If the media description is rejected, and transceiver is not already stopped, + stop the RTCRtpTransceiver transceiver. + + [JSEP] + 5.9. Applying a Remote Description + - If the m= section is not associated with any RtpTransceiver + (possibly because it was dissociated in the previous step), + either find an RtpTransceiver or create one according to the + following steps: + + - If the m= section is sendrecv or recvonly, and there are + RtpTransceivers of the same type that were added to the + PeerConnection by addTrack and are not associated with any + m= section and are not stopped, find the first (according to + the canonical order described in Section 5.2.1) such + RtpTransceiver. + + - If no RtpTransceiver was found in the previous step, create + one with a recvonly direction. + */ +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-answer.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-answer.html new file mode 100644 index 0000000000..32e2332635 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-answer.html @@ -0,0 +1,230 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.setLocalDescription</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // generateAnswer + // assert_session_desc_similar + + /* + 4.3.2. Interface Definition + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + Promise<void> setRemoteDescription( + RTCSessionDescriptionInit description); + + readonly attribute RTCSessionDescription? remoteDescription; + readonly attribute RTCSessionDescription? currentRemoteDescription; + readonly attribute RTCSessionDescription? pendingRemoteDescription; + ... + }; + + 4.6.2. RTCSessionDescription Class + dictionary RTCSessionDescriptionInit { + required RTCSdpType type; + DOMString sdp = ""; + }; + + 4.6.1. RTCSdpType + enum RTCSdpType { + "offer", + "pranswer", + "answer", + "rollback" + }; + */ + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 2.2.2. If description is set as a local description, then run one of the following + steps: + + - If description is of type "answer", then this completes an offer answer + negotiation. + + Set connection's currentLocalDescription to description and + currentRemoteDescription to the value of pendingRemoteDescription. + + Set both pendingRemoteDescription and pendingLocalDescription to null. + + Finally set connection's signaling state to stable. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return generateVideoReceiveOnlyOffer(pc) + .then(offer => + pc.setRemoteDescription(offer) + .then(() => pc.createAnswer()) + .then(answer => + pc.setLocalDescription(answer) + .then(() => { + assert_equals(pc.signalingState, 'stable'); + assert_session_desc_similar(pc.localDescription, answer); + assert_session_desc_similar(pc.remoteDescription, offer); + + assert_session_desc_similar(pc.currentLocalDescription, answer); + assert_session_desc_similar(pc.currentRemoteDescription, offer); + + assert_equals(pc.pendingLocalDescription, null); + assert_equals(pc.pendingRemoteDescription, null); + + assert_array_equals(states, ['have-remote-offer', 'stable']); + }))); + }, 'setLocalDescription() with valid answer should succeed'); + + /* + 4.3.2. setLocalDescription + 3. Let lastAnswer be the result returned by the last call to createAnswer. + 4. If description.sdp is null and description.type is answer, set description.sdp + to lastAnswer. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return generateVideoReceiveOnlyOffer(pc) + .then(offer => + pc.setRemoteDescription(offer) + .then(() => pc.createAnswer()) + .then(answer => + pc.setLocalDescription({ type: 'answer' }) + .then(() => { + assert_equals(pc.signalingState, 'stable'); + assert_session_desc_similar(pc.localDescription, answer); + assert_session_desc_similar(pc.remoteDescription, offer); + + assert_session_desc_similar(pc.currentLocalDescription, answer); + assert_session_desc_similar(pc.currentRemoteDescription, offer); + + assert_equals(pc.pendingLocalDescription, null); + assert_equals(pc.pendingRemoteDescription, null); + }))); + }, 'setLocalDescription() with type answer and null sdp should use lastAnswer generated from createAnswer'); + + /* + 4.3.2. setLocalDescription + 3. Let lastAnswer be the result returned by the last call to createAnswer. + 7. If description.type is answer and description.sdp does not match lastAnswer, + reject the promise with a newly created InvalidModificationError and abort these + steps. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return generateVideoReceiveOnlyOffer(pc) + .then(offer => + pc.setRemoteDescription(offer) + .then(() => generateAnswer(offer)) + .then(answer => pc.setLocalDescription(answer)) + .then(() => t.unreached_func("setLocalDescription should have rejected"), + (error) => assert_equals(error.name, 'InvalidModificationError'))); + }, 'setLocalDescription() with answer not created by own createAnswer() should reject with InvalidModificationError'); + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 2.3. If the description's type is invalid for the current signaling state of + connection, then reject p with a newly created InvalidStateError and abort + these steps. + + [jsep] + 5.5. If the type is "pranswer" or "answer", the PeerConnection + state MUST be either "have-remote-offer" or "have-local-pranswer". + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.createOffer() + .then(offer => + promise_rejects_dom(t, 'InvalidModificationError', + pc.setLocalDescription({ type: 'answer', sdp: offer.sdp }))); + }, 'Calling setLocalDescription(answer) from stable state should reject with InvalidModificationError'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.createOffer() + .then(offer => + pc.setLocalDescription(offer) + .then(() => generateAnswer(offer))) + .then(answer => + promise_rejects_dom(t, 'InvalidModificationError', + pc.setLocalDescription(answer))); + }, 'Calling setLocalDescription(answer) from have-local-offer state should reject with InvalidModificationError'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver('audio', { direction: 'recvonly' }); + const offer = await pc1.createOffer(); + await pc2.setRemoteDescription(offer); + const answer = await pc2.createAnswer(); // [[LastAnswer]] slot set + await pc2.setRemoteDescription({type: "rollback"}); + pc2.addTransceiver('video', { direction: 'recvonly' }); + await pc2.createOffer(); // [[LastOffer]] slot set + await pc2.setRemoteDescription(offer); + await pc2.setLocalDescription(answer); // Should check against [[LastAnswer]], not [[LastOffer]] + }, "Setting previously generated answer after a call to createOffer should work"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver('audio', { direction: 'recvonly' }); + await pc2.setRemoteDescription(await pc1.createOffer()); + const answer = await pc2.createAnswer(); + const sldPromise = pc2.setLocalDescription(answer); + + assert_equals(pc2.signalingState, "have-remote-offer", "signalingState should not be set synchronously after a call to sLD"); + + assert_equals(pc2.pendingLocalDescription, null, "pendingLocalDescription should never be set due to sLD(answer)"); + assert_not_equals(pc2.pendingRemoteDescription, null, "pendingRemoteDescription should not be set synchronously after a call to sLD"); + assert_equals(pc2.pendingRemoteDescription.type, "offer"); + assert_equals(pc2.remoteDescription.sdp, pc2.pendingRemoteDescription.sdp); + assert_equals(pc2.currentLocalDescription, null, "currentLocalDescription should not be set synchronously after a call to sLD"); + assert_equals(pc2.currentRemoteDescription, null, "currentRemoteDescription should not be set synchronously after a call to sLD"); + + const stablePromise = new Promise(resolve => { + pc2.onsignalingstatechange = () => { + resolve(pc2.signalingState); + } + }); + const raceValue = await Promise.race([stablePromise, sldPromise]); + assert_equals(raceValue, "stable", "signalingstatechange event should fire before sLD resolves"); + assert_equals(pc2.pendingLocalDescription, null, "pendingLocalDescription should never be set due to sLD(answer)"); + assert_equals(pc2.pendingRemoteDescription, null, "pendingRemoteDescription should be updated before the signalingstatechange event"); + assert_not_equals(pc2.currentLocalDescription, null, "currentLocalDescription should be updated before the signalingstatechange event"); + assert_equals(pc2.currentLocalDescription.type, "answer"); + assert_equals(pc2.currentLocalDescription.sdp, pc2.localDescription.sdp); + assert_not_equals(pc2.currentRemoteDescription, null, "currentRemoteDescription should be updated before the signalingstatechange event"); + assert_equals(pc2.currentRemoteDescription.type, "offer"); + assert_equals(pc2.currentRemoteDescription.sdp, pc2.remoteDescription.sdp); + + await sldPromise; + }, "setLocalDescription(answer) should update internal state with a queued task, in the right order"); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-offer.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-offer.html new file mode 100644 index 0000000000..88f1de5ed8 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-offer.html @@ -0,0 +1,229 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.setLocalDescription</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // generateDataChannelOffer + // assert_session_desc_not_similar + // assert_session_desc_similar + + /* + 4.3.2. Interface Definition + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + Promise<void> setRemoteDescription( + RTCSessionDescriptionInit description); + + readonly attribute RTCSessionDescription? remoteDescription; + readonly attribute RTCSessionDescription? currentRemoteDescription; + readonly attribute RTCSessionDescription? pendingRemoteDescription; + ... + }; + + 4.6.2. RTCSessionDescription Class + dictionary RTCSessionDescriptionInit { + required RTCSdpType type; + DOMString sdp = ""; + }; + + 4.6.1. RTCSdpType + enum RTCSdpType { + "offer", + "pranswer", + "answer", + "rollback" + }; + */ + + /* + 4.3.2. setLocalDescription + 2. Let lastOffer be the result returned by the last call to createOffer. + 5. If description.sdp is null and description.type is offer, set description.sdp + to lastOffer. + + 4.3.1.6. Set the RTCSessionSessionDescription + 2.2.2. If description is set as a local description, then run one of the following + steps: + - If description is of type "offer", set connection.pendingLocalDescription + to description and signaling state to have-local-offer. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return generateAudioReceiveOnlyOffer(pc) + .then(offer => + pc.setLocalDescription(offer) + .then(() => { + assert_equals(pc.signalingState, 'have-local-offer'); + assert_session_desc_similar(pc.localDescription, offer); + assert_session_desc_similar(pc.pendingLocalDescription, offer); + assert_equals(pc.currentLocalDescription, null); + + assert_array_equals(states, ['have-local-offer']); + })); + }, 'setLocalDescription with valid offer should succeed'); + + /* + 4.3.2. setLocalDescription + 2. Let lastOffer be the result returned by the last call to createOffer. + 5. If description.sdp is null and description.type is offer, set description.sdp + to lastOffer. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + return generateAudioReceiveOnlyOffer(pc) + .then(offer => + pc.setLocalDescription({ type: 'offer' }) + .then(() => { + assert_equals(pc.signalingState, 'have-local-offer'); + assert_session_desc_similar(pc.localDescription, offer); + assert_session_desc_similar(pc.pendingLocalDescription, offer); + assert_equals(pc.currentLocalDescription, null); + })); + }, 'setLocalDescription with type offer and null sdp should use lastOffer generated from createOffer'); + + /* + 4.3.2. setLocalDescription + 2. Let lastOffer be the result returned by the last call to createOffer. + 6. If description.type is offer and description.sdp does not match lastOffer, + reject the promise with a newly created InvalidModificationError and abort + these steps. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const pc2 = new RTCPeerConnection(); + + t.add_cleanup(() => pc2.close()); + + return generateDataChannelOffer(pc) + .then(offer => pc2.setLocalDescription(offer)) + .then(() => t.unreached_func("setLocalDescription should have rejected"), + (error) => assert_equals(error.name, 'InvalidModificationError')); + }, 'setLocalDescription() with offer not created by own createOffer() should reject with InvalidModificationError'); + + promise_test(t => { + // Create first offer with audio line, then second offer with + // both audio and video line. Since the second offer is the + // last offer, setLocalDescription would reject when setting + // with the first offer + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + return generateAudioReceiveOnlyOffer(pc) + .then(offer1 => + generateVideoReceiveOnlyOffer(pc) + .then(offer2 => { + assert_session_desc_not_similar(offer1, offer2); + return promise_rejects_dom(t, 'InvalidModificationError', + pc.setLocalDescription(offer1)); + })); + }, 'Set created offer other than last offer should reject with InvalidModificationError'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return generateAudioReceiveOnlyOffer(pc) + .then(offer1 => + pc.setLocalDescription(offer1) + .then(() => + generateVideoReceiveOnlyOffer(pc) + .then(offer2 => + pc.setLocalDescription(offer2) + .then(() => { + assert_session_desc_not_similar(offer1, offer2); + assert_equals(pc.signalingState, 'have-local-offer'); + assert_session_desc_similar(pc.localDescription, offer2); + assert_session_desc_similar(pc.pendingLocalDescription, offer2); + assert_equals(pc.currentLocalDescription, null); + + assert_array_equals(states, ['have-local-offer']); + })))); + }, 'Creating and setting offer multiple times should succeed'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver('audio', { direction: 'recvonly' }); + const offer = await pc1.createOffer(); // [[LastOffer]] set + pc2.addTransceiver('video', { direction: 'recvonly' }); + const offer2 = await pc2.createOffer(); + await pc1.setRemoteDescription(offer2); + await pc1.createAnswer(); // [[LastAnswer]] set + await pc1.setRemoteDescription({type: "rollback"}); + await pc1.setLocalDescription(offer); + }, "Setting previously generated offer after a call to createAnswer should work"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver('audio', { direction: 'recvonly' }); + await pc1.setLocalDescription(await pc1.createOffer()); + + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + await pc2.setRemoteDescription(offer); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + + assert_equals(pc1.getTransceivers().length, 1); + assert_equals(pc1.getTransceivers()[0].receiver.track.kind, "audio"); + assert_equals(pc2.getTransceivers().length, 1); + assert_equals(pc2.getTransceivers()[0].receiver.track.kind, "audio"); + }, "Negotiation works when there has been a repeated setLocalDescription(offer)"); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + pc.addTransceiver('audio', { direction: 'recvonly' }); + const sldPromise = pc.setLocalDescription(await pc.createOffer()); + + assert_equals(pc.signalingState, "stable", "signalingState should not be set synchronously after a call to sLD"); + + assert_equals(pc.pendingLocalDescription, null, "pendingRemoteDescription should never be set due to sLD"); + assert_equals(pc.pendingRemoteDescription, null, "pendingLocalDescription should not be set synchronously after a call to sLD"); + assert_equals(pc.currentLocalDescription, null, "currentLocalDescription should not be set synchronously after a call to sLD"); + assert_equals(pc.currentRemoteDescription, null, "currentRemoteDescription should not be set synchronously after a call to sLD"); + + const statePromise = new Promise(resolve => { + pc.onsignalingstatechange = () => { + resolve(pc.signalingState); + } + }); + const raceValue = await Promise.race([statePromise, sldPromise]); + assert_equals(raceValue, "have-local-offer", "signalingstatechange event should fire before sLD resolves"); + assert_equals(pc.pendingRemoteDescription, null, "pendingRemoteDescription should never be set due to sLD"); + assert_not_equals(pc.pendingLocalDescription, null, "pendingLocalDescription should be updated before the signalingstatechange event"); + assert_equals(pc.pendingLocalDescription.type, "offer"); + assert_equals(pc.pendingLocalDescription.sdp, pc.localDescription.sdp); + assert_equals(pc.currentLocalDescription, null, "currentLocalDescription should never be updated due to sLD(offer)"); + assert_equals(pc.currentRemoteDescription, null, "currentRemoteDescription should never be updated due to sLD(offer)"); + + await sldPromise; + }, "setLocalDescription(offer) should update internal state with a queued task, in the right order"); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-parameterless.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-parameterless.https.html new file mode 100644 index 0000000000..5a7a76319a --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-parameterless.https.html @@ -0,0 +1,170 @@ +<!doctype html> +<meta charset=utf-8> +<title></title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +"use strict"; + +const kSmallTimeoutMs = 100; + +promise_test(async t => { + const offerer = new RTCPeerConnection(); + t.add_cleanup(() => offerer.close()); + + const signalingStateChangeEvent + = new EventWatcher(t, offerer, 'signalingstatechange') + .wait_for('signalingstatechange'); + await offerer.setLocalDescription(); + await signalingStateChangeEvent; + assert_equals(offerer.signalingState, 'have-local-offer'); +}, "Parameterless SLD() in 'stable' goes to 'have-local-offer'"); + +promise_test(async t => { + const offerer = new RTCPeerConnection(); + t.add_cleanup(() => offerer.close()); + + await offerer.setLocalDescription(); + assert_not_equals(offerer.pendingLocalDescription, null); +}, "Parameterless SLD() in 'stable' sets pendingLocalDescription"); + +promise_test(async t => { + const offerer = new RTCPeerConnection(); + t.add_cleanup(() => offerer.close()); + + const transceiver = offerer.addTransceiver('audio'); + assert_equals(transceiver.mid, null); + await offerer.setLocalDescription(); + assert_not_equals(transceiver.mid, null); +}, "Parameterless SLD() in 'stable' assigns transceiver.mid"); + +promise_test(async t => { + const offerer = new RTCPeerConnection(); + t.add_cleanup(() => offerer.close()); + const answerer = new RTCPeerConnection(); + t.add_cleanup(() => answerer.close()); + + await answerer.setRemoteDescription(await offerer.createOffer()); + const signalingStateChangeEvent + = new EventWatcher(t, answerer, 'signalingstatechange') + .wait_for('signalingstatechange'); + await answerer.setLocalDescription(); + await signalingStateChangeEvent; + assert_equals(answerer.signalingState, 'stable'); +}, "Parameterless SLD() in 'have-remote-offer' goes to 'stable'"); + +promise_test(async t => { + const offerer = new RTCPeerConnection(); + t.add_cleanup(() => offerer.close()); + const answerer = new RTCPeerConnection(); + t.add_cleanup(() => answerer.close()); + + await answerer.setRemoteDescription(await offerer.createOffer()); + await answerer.setLocalDescription(); + assert_not_equals(answerer.currentLocalDescription, null); +}, "Parameterless SLD() in 'have-remote-offer' sets currentLocalDescription"); + +promise_test(async t => { + const offerer = new RTCPeerConnection(); + t.add_cleanup(() => offerer.close()); + const answerer = new RTCPeerConnection(); + t.add_cleanup(() => answerer.close()); + + offerer.addTransceiver('audio'); + const onTransceiverPromise = new Promise(resolve => + answerer.ontrack = e => resolve(e.transceiver)); + await answerer.setRemoteDescription(await offerer.createOffer()); + const transceiver = await onTransceiverPromise; + await answerer.setLocalDescription(); + assert_equals(transceiver.currentDirection, 'recvonly'); +}, "Parameterless SLD() in 'have-remote-offer' sets " + + "transceiver.currentDirection"); + +promise_test(async t => { + const offerer = new RTCPeerConnection(); + t.add_cleanup(() => offerer.close()); + + const offer = await offerer.createOffer(); + await offerer.setLocalDescription(); + // assert_true() is used rather than assert_equals() so that if the assertion + // fails, the -expected.txt file is not different on each run. + assert_true(offerer.pendingLocalDescription.sdp == offer.sdp, + "offerer.pendingLocalDescription.sdp == offer.sdp"); +}, "Parameterless SLD() uses [[LastCreatedOffer]] if it is still valid"); + +promise_test(async t => { + const offerer = new RTCPeerConnection(); + t.add_cleanup(() => offerer.close()); + const answerer = new RTCPeerConnection(); + t.add_cleanup(() => answerer.close()); + + await answerer.setRemoteDescription(await offerer.createOffer()); + const answer = await answerer.createAnswer(); + await answerer.setLocalDescription(); + // assert_true() is used rather than assert_equals() so that if the assertion + // fails, the -expected.txt file is not different on each run. + assert_true(answerer.currentLocalDescription.sdp == answer.sdp, + "answerer.currentLocalDescription.sdp == answer.sdp"); +}, "Parameterless SLD() uses [[LastCreatedAnswer]] if it is still valid"); + +promise_test(async t => { + const offerer = new RTCPeerConnection(); + offerer.close(); + try { + await offerer.setLocalDescription(); + assert_not_reached(); + } catch (e) { + assert_equals(e.name, "InvalidStateError"); + } +}, "Parameterless SLD() rejects with InvalidStateError if already closed"); + +promise_test(async t => { + const offerer = new RTCPeerConnection(); + t.add_cleanup(() => offerer.close()); + + const p = Promise.race([ + offerer.setLocalDescription(), + new Promise(r => t.step_timeout(() => r("timeout"), kSmallTimeoutMs)) + ]); + offerer.close(); + assert_equals(await p, "timeout"); +}, "Parameterless SLD() never settles if closed while pending"); + +promise_test(async t => { + const offerer = new RTCPeerConnection(); + t.add_cleanup(() => offerer.close()); + const answerer = new RTCPeerConnection(); + t.add_cleanup(() => answerer.close()); + + // Implicitly create an offer. + await offerer.setLocalDescription(); + await answerer.setRemoteDescription(offerer.pendingLocalDescription); + // Implicitly create an answer. + await answerer.setLocalDescription(); + await offerer.setRemoteDescription(answerer.currentLocalDescription); +}, "Parameterless SLD() in a full O/A exchange succeeds"); + +promise_test(async t => { + const answerer = new RTCPeerConnection(); + try { + await answerer.setRemoteDescription(); + assert_not_reached(); + } catch (e) { + assert_equals(e.name, "TypeError"); + } +}, "Parameterless SRD() rejects with TypeError."); + +promise_test(async t => { + const offerer = new RTCPeerConnection(); + const {sdp} = await offerer.createOffer(); + new RTCSessionDescription({type: "offer", sdp}); + try { + new RTCSessionDescription({sdp}); + assert_not_reached(); + } catch (e) { + assert_equals(e.name, "TypeError"); + } +}, "RTCSessionDescription constructed without type throws TypeError"); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-pranswer.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-pranswer.html new file mode 100644 index 0000000000..01845f09b1 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-pranswer.html @@ -0,0 +1,166 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.setLocalDescription pranswer</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // assert_session_desc_similar + + /* + 4.3.2. Interface Definition + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + Promise<void> setLocalDescription( + RTCSessionDescriptionInit description); + + readonly attribute RTCSessionDescription? localDescription; + readonly attribute RTCSessionDescription? currentLocalDescription; + readonly attribute RTCSessionDescription? pendingLocalDescription; + + Promise<void> setRemoteDescription( + RTCSessionDescriptionInit description); + + readonly attribute RTCSessionDescription? remoteDescription; + readonly attribute RTCSessionDescription? currentRemoteDescription; + readonly attribute RTCSessionDescription? pendingRemoteDescription; + ... + }; + + 4.6.2. RTCSessionDescription Class + dictionary RTCSessionDescriptionInit { + required RTCSdpType type; + DOMString sdp = ""; + }; + + 4.6.1. RTCSdpType + enum RTCSdpType { + "offer", + "pranswer", + "answer", + "rollback" + }; + */ + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 2.3. If the description's type is invalid for the current signaling state of + connection, then reject p with a newly created InvalidStateError and abort + these steps. + + [jsep] + 5.5. If the type is "pranswer" or "answer", the PeerConnection + state MUST be either "have-remote-offer" or "have-local-pranswer". + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.createOffer() + .then(offer => + promise_rejects_dom(t, 'InvalidStateError', + pc.setLocalDescription({ type: 'pranswer', sdp: offer.sdp }))); + }, 'setLocalDescription(pranswer) from stable state should reject with InvalidStateError'); + + /* + 4.3.1.6 Set the RTCSessionSessionDescription + 2.2.2. If description is set as a local description, then run one of the + following steps: + - If description is of type "pranswer", then set + connection.pendingLocalDescription to description and signaling state to + have-local-pranswer. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return generateVideoReceiveOnlyOffer(pc) + .then(offer => + pc.setRemoteDescription(offer) + .then(() => pc.createAnswer()) + .then(answer => { + const pranswer = { type: 'pranswer', sdp: answer.sdp }; + + return pc.setLocalDescription(pranswer) + .then(() => { + assert_equals(pc.signalingState, 'have-local-pranswer'); + + assert_session_desc_similar(pc.remoteDescription, offer); + assert_session_desc_similar(pc.pendingRemoteDescription, offer); + assert_equals(pc.currentRemoteDescription, null); + + assert_session_desc_similar(pc.localDescription, pranswer); + assert_session_desc_similar(pc.pendingLocalDescription, pranswer); + assert_equals(pc.currentLocalDescription, null); + + + assert_array_equals(states, ['have-remote-offer', 'have-local-pranswer']); + }); + })); + }, 'setLocalDescription(pranswer) should succeed'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return generateVideoReceiveOnlyOffer(pc) + .then(offer => + pc.setRemoteDescription(offer) + .then(() => pc.createAnswer()) + .then(answer => { + const pranswer = { type: 'pranswer', sdp: answer.sdp }; + + return pc.setLocalDescription(pranswer) + .then(() => pc.setLocalDescription(pranswer)) + .then(() => { + assert_array_equals(states, ['have-remote-offer', 'have-local-pranswer']); + }); + })); + }, 'setLocalDescription(pranswer) can be applied multiple times while still in have-local-pranswer'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return generateVideoReceiveOnlyOffer(pc) + .then(offer => + pc.setRemoteDescription(offer) + .then(() => pc.createAnswer()) + .then(answer => { + const pranswer = { type: 'pranswer', sdp: answer.sdp }; + + return pc.setLocalDescription(pranswer) + .then(() => pc.setLocalDescription(answer)) + .then(() => { + assert_equals(pc.signalingState, 'stable'); + assert_session_desc_similar(pc.localDescription, answer); + assert_session_desc_similar(pc.remoteDescription, offer); + + assert_session_desc_similar(pc.currentLocalDescription, answer); + assert_session_desc_similar(pc.currentRemoteDescription, offer); + + assert_equals(pc.pendingLocalDescription, null); + assert_equals(pc.pendingRemoteDescription, null); + + assert_array_equals(states, ['have-remote-offer', 'have-local-pranswer', 'stable']); + }); + })); + }, 'setLocalDescription(answer) from have-local-pranswer state should succeed'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-rollback.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-rollback.html new file mode 100644 index 0000000000..787edc92e7 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription-rollback.html @@ -0,0 +1,167 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.setLocalDescription rollback</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // assert_session_desc_similar + // generateAudioReceiveOnlyOffer + + /* + 4.3.2. Interface Definition + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + Promise<void> setLocalDescription( + RTCSessionDescriptionInit description); + + readonly attribute RTCSessionDescription? localDescription; + readonly attribute RTCSessionDescription? currentLocalDescription; + readonly attribute RTCSessionDescription? pendingLocalDescription; + + Promise<void> setRemoteDescription( + RTCSessionDescriptionInit description); + + readonly attribute RTCSessionDescription? remoteDescription; + readonly attribute RTCSessionDescription? currentRemoteDescription; + readonly attribute RTCSessionDescription? pendingRemoteDescription; + ... + }; + + 4.6.2. RTCSessionDescription Class + dictionary RTCSessionDescriptionInit { + required RTCSdpType type; + DOMString sdp = ""; + }; + + 4.6.1. RTCSdpType + enum RTCSdpType { + "offer", + "pranswer", + "answer", + "rollback" + }; + */ + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 2.2.2. If description is set as a local description, then run one of the + following steps: + - If description is of type "rollback", then this is a rollback. Set + connection.pendingLocalDescription to null and signaling state to stable. + */ + promise_test(t=> { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return pc.createOffer() + .then(offer => pc.setLocalDescription(offer)) + .then(() => { + assert_equals(pc.signalingState, 'have-local-offer'); + assert_not_equals(pc.localDescription, null); + assert_not_equals(pc.pendingLocalDescription, null); + assert_equals(pc.currentLocalDescription, null); + + return pc.setLocalDescription({ type: 'rollback' }); + }) + .then(() => { + assert_equals(pc.signalingState, 'stable'); + assert_equals(pc.localDescription, null); + assert_equals(pc.pendingLocalDescription, null); + assert_equals(pc.currentLocalDescription, null); + + assert_array_equals(states, ['have-local-offer', 'stable']); + }); + }, 'setLocalDescription(rollback) from have-local-offer state should reset back to stable state'); + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 2.3. If the description's type is invalid for the current signaling state of + connection, then reject p with a newly created InvalidStateError and abort + these steps. Note that this implies that once the answerer has performed + setLocalDescription with his answer, this cannot be rolled back. + + [jsep] + 4.1.8.2. Rollback + - Rollback can only be used to cancel proposed changes; + there is no support for rolling back from a stable state to a + previous stable state + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + return promise_rejects_dom(t, 'InvalidStateError', + pc.setLocalDescription({ type: 'rollback' })); + }, `setLocalDescription(rollback) from stable state should reject with InvalidStateError`); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + return generateAudioReceiveOnlyOffer(pc) + .then(offer => + pc.setRemoteDescription(offer) + .then(() => pc.createAnswer())) + .then(answer => pc.setLocalDescription(answer)) + .then(() => { + return promise_rejects_dom(t, 'InvalidStateError', + pc.setLocalDescription({ type: 'rollback' })); + }); + }, `setLocalDescription(rollback) after setting answer description should reject with InvalidStateError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const offer = await generateAudioReceiveOnlyOffer(pc); + await pc.setRemoteDescription(offer); + await promise_rejects_dom(t, 'InvalidStateError', pc.setLocalDescription({ type: 'rollback' })); + }, `setLocalDescription(rollback) after setting a remote offer should reject with InvalidStateError`); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + return pc.createOffer() + .then(offer => pc.setLocalDescription(offer)) + .then(() => pc.setLocalDescription({ + type: 'rollback', + sdp: '!<Invalid SDP Content>;' + })); + }, `setLocalDescription(rollback) should ignore invalid sdp content and succeed`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + pc.addTransceiver('audio', { direction: 'recvonly' }); + await pc.setLocalDescription(await pc.createOffer()); + const sldPromise = pc.setLocalDescription({type: "rollback"}); + + assert_equals(pc.signalingState, "have-local-offer", "signalingState should not be set synchronously after a call to sLD"); + + assert_not_equals(pc.pendingLocalDescription, null, "pendingLocalDescription should not be set synchronously after a call to sLD"); + assert_equals(pc.pendingLocalDescription.type, "offer"); + assert_equals(pc.pendingLocalDescription.sdp, pc.localDescription.sdp); + assert_equals(pc.pendingRemoteDescription, null, "pendingRemoteDescription should never be set due to sLD(offer)"); + + const stablePromise = new Promise(resolve => { + pc.onsignalingstatechange = () => { + resolve(pc.signalingState); + } + }); + const raceValue = await Promise.race([stablePromise, sldPromise]); + assert_equals(raceValue, "stable", "signalingstatechange event should fire before sLD resolves"); + assert_equals(pc.pendingLocalDescription, null, "pendingLocalDescription should be updated before the signalingstatechange event"); + assert_equals(pc.pendingRemoteDescription, null, "pendingRemoteDescription should never be set due to sLD(offer)"); + + await sldPromise; + }, "setLocalDescription(rollback) should update internal state with a queued tassk, in the right order"); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription.html new file mode 100644 index 0000000000..c4671c3008 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setLocalDescription.html @@ -0,0 +1,152 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.setLocalDescription</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // generateDataChannelOffer + // assert_session_desc_not_similar + // assert_session_desc_similar + + /* + 4.3.2. Interface Definition + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + Promise<void> setRemoteDescription( + RTCSessionDescriptionInit description); + + readonly attribute RTCSessionDescription? remoteDescription; + readonly attribute RTCSessionDescription? currentRemoteDescription; + readonly attribute RTCSessionDescription? pendingRemoteDescription; + ... + }; + + 4.6.2. RTCSessionDescription Class + dictionary RTCSessionDescriptionInit { + required RTCSdpType type; + DOMString sdp = ""; + }; + + 4.6.1. RTCSdpType + enum RTCSdpType { + "offer", + "pranswer", + "answer", + "rollback" + }; + */ + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return generateAudioReceiveOnlyOffer(pc) + .then(offer1 => + pc.setLocalDescription(offer1) + .then(() => generateAnswer(offer1)) + .then(answer => pc.setRemoteDescription(answer)) + .then(() => { + pc.createDataChannel('test'); + return generateVideoReceiveOnlyOffer(pc); + }) + .then(offer2 => + pc.setLocalDescription(offer2) + .then(() => { + assert_equals(pc.signalingState, 'have-local-offer'); + assert_session_desc_not_similar(offer1, offer2); + assert_session_desc_similar(pc.localDescription, offer2); + assert_session_desc_similar(pc.currentLocalDescription, offer1); + assert_session_desc_similar(pc.pendingLocalDescription, offer2); + + assert_array_equals(states, ['have-local-offer', 'stable', 'have-local-offer']); + }))); + }, 'Calling createOffer() and setLocalDescription() again after one round of local-offer/remote-answer should succeed'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const states = []; + pc1.addEventListener('signalingstatechange', () => states.push(pc1.signalingState)); + + assert_equals(pc1.localDescription, null); + assert_equals(pc1.currentLocalDescription, null); + assert_equals(pc1.pendingLocalDescription, null); + + pc1.createDataChannel('test'); + const offer = await pc1.createOffer(); + + assert_equals(pc1.localDescription, null); + assert_equals(pc1.currentLocalDescription, null); + assert_equals(pc1.pendingLocalDescription, null); + + await pc1.setLocalDescription(offer); + + assert_session_desc_similar(pc1.localDescription, offer); + assert_equals(pc1.currentLocalDescription, null); + assert_session_desc_similar(pc1.pendingLocalDescription, offer); + + await pc2.setRemoteDescription(offer); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + + assert_equals(pc1.signalingState, 'stable'); + assert_session_desc_similar(pc1.localDescription, offer); + assert_session_desc_similar(pc1.currentLocalDescription, offer); + assert_equals(pc1.pendingLocalDescription, null); + + const stream = await getNoiseStream({audio:true}); + pc2.addTrack(stream.getTracks()[0], stream); + + const reoffer = await pc2.createOffer(); + await pc2.setLocalDescription(reoffer); + await pc1.setRemoteDescription(reoffer); + const reanswer = await pc1.createAnswer(); + await pc1.setLocalDescription(reanswer); + + assert_session_desc_similar(pc1.localDescription, reanswer); + assert_session_desc_similar(pc1.currentLocalDescription, reanswer); + assert_equals(pc1.pendingLocalDescription, null); + }, 'Switching role from answerer to offerer after going back to stable state should succeed'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const offer = await pc.createOffer(); + let eventSequence = ''; + const signalingstatechangeResolver = new Resolver(); + pc.onsignalingstatechange = () => { + eventSequence += 'onsignalingstatechange;'; + signalingstatechangeResolver.resolve(); + }; + await pc.setLocalDescription(offer); + eventSequence += 'setLocalDescription;'; + await signalingstatechangeResolver; + assert_equals(eventSequence, 'onsignalingstatechange;setLocalDescription;'); + }, 'onsignalingstatechange fires before setLocalDescription resolves'); + + /* + TODO + 4.3.2. setLocalDescription + 4. If description.sdp is null and description.type is pranswer, set description.sdp + to lastAnswer. + 7. If description.type is pranswer and description.sdp does not match lastAnswer, + reject the promise with a newly created InvalidModificationError and abort these + steps. + */ + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-answer.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-answer.html new file mode 100644 index 0000000000..7306311b0a --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-answer.html @@ -0,0 +1,123 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.setRemoteDescription - answer</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // generateAnswer() + // assert_session_desc_similar() + + /* + 4.3.2. Interface Definition + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + Promise<void> setRemoteDescription( + RTCSessionDescriptionInit description); + + readonly attribute RTCSessionDescription? remoteDescription; + readonly attribute RTCSessionDescription? currentRemoteDescription; + readonly attribute RTCSessionDescription? pendingRemoteDescription; + ... + }; + + 4.6.2. RTCSessionDescription Class + dictionary RTCSessionDescriptionInit { + required RTCSdpType type; + DOMString sdp = ""; + }; + + 4.6.1. RTCSdpType + enum RTCSdpType { + "offer", + "pranswer", + "answer", + "rollback" + }; + */ + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 2.2.3. Otherwise, if description is set as a remote description, then run one of + the following steps: + - If description is of type "answer", then this completes an offer answer + negotiation. + + Set connection's currentRemoteDescription to description and + currentLocalDescription to the value of pendingLocalDescription. + + Set both pendingRemoteDescription and pendingLocalDescription to null. + + Finally setconnection's signaling state to stable. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return generateVideoReceiveOnlyOffer(pc) + .then(offer => + pc.setLocalDescription(offer) + .then(() => generateAnswer(offer)) + .then(answer => + pc.setRemoteDescription(answer) + .then(() => { + assert_equals(pc.signalingState, 'stable'); + + assert_session_desc_similar(pc.localDescription, offer); + assert_session_desc_similar(pc.remoteDescription, answer); + + assert_session_desc_similar(pc.currentLocalDescription, offer); + assert_session_desc_similar(pc.currentRemoteDescription, answer); + + assert_equals(pc.pendingLocalDescription, null); + assert_equals(pc.pendingRemoteDescription, null); + + assert_array_equals(states, ['have-local-offer', 'stable']); + }))); + }, 'setRemoteDescription() with valid state and answer should succeed'); + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 2.1.3. If the description's type is invalid for the current signaling state of + connection, then reject p with a newly created InvalidStateError and abort + these steps. + + [JSEP] + 5.6. If the type is "answer", the PeerConnection state MUST be either + "have-local-offer" or "have-remote-pranswer". + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.createOffer() + .then(offer => + promise_rejects_dom(t, 'InvalidStateError', + pc.setRemoteDescription({ type: 'answer', sdp: offer.sdp }))); + }, 'Calling setRemoteDescription(answer) from stable state should reject with InvalidStateError'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.createOffer() + .then(offer => + pc.setRemoteDescription(offer) + .then(() => generateAnswer(offer))) + .then(answer => + promise_rejects_dom(t, 'InvalidStateError', + pc.setRemoteDescription(answer))); + }, 'Calling setRemoteDescription(answer) from have-remote-offer state should reject with InvalidStateError'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-nomsid.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-nomsid.html new file mode 100644 index 0000000000..8a86bb0c8e --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-nomsid.html @@ -0,0 +1,40 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.setRemoteDescription - legacy streams without a=msid lines</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +'use strict'; + +const FINGERPRINT_SHA256 = '00:00:00:00:00:00:00:00:00:00:00:00:00' + + ':00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00'; +const ICEUFRAG = 'someufrag'; +const ICEPWD = 'somelongpwdwithenoughrandomness'; +const SDP_BOILERPLATE = 'v=0\r\n' + + 'o=- 166855176514521964 2 IN IP4 127.0.0.1\r\n' + + 's=-\r\n' + + 't=0 0\r\n'; +const MINIMAL_AUDIO_MLINE = + 'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' + + 'c=IN IP4 0.0.0.0\r\n' + + 'a=rtcp:9 IN IP4 0.0.0.0\r\n' + + 'a=ice-ufrag:' + ICEUFRAG + '\r\n' + + 'a=ice-pwd:' + ICEPWD + '\r\n' + + 'a=fingerprint:sha-256 ' + FINGERPRINT_SHA256 + '\r\n' + + 'a=setup:actpass\r\n' + + 'a=mid:0\r\n' + + 'a=sendrecv\r\n' + + 'a=rtcp-mux\r\n' + + 'a=rtcp-rsize\r\n' + + 'a=rtpmap:111 opus/48000/2\r\n'; + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const haveOntrack = new Promise(r => pc.ontrack = r); + await pc.setRemoteDescription({type: 'offer', sdp: SDP_BOILERPLATE + MINIMAL_AUDIO_MLINE}); + assert_equals((await haveOntrack).streams.length, 1); + }, 'setRemoteDescription with an SDP without a=msid lines triggers ontrack with a default stream.'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-offer.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-offer.html new file mode 100644 index 0000000000..d5acb7e1c9 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-offer.html @@ -0,0 +1,356 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.setRemoteDescription - offer</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // The following helper functions are called from RTCPeerConnection-helper.js: + // assert_session_desc_similar() + // generateAudioReceiveOnlyOffer + + /* + 4.3.2. Interface Definition + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + Promise<void> setRemoteDescription( + RTCSessionDescriptionInit description); + + readonly attribute RTCSessionDescription? remoteDescription; + readonly attribute RTCSessionDescription? currentRemoteDescription; + readonly attribute RTCSessionDescription? pendingRemoteDescription; + ... + }; + + 4.6.2. RTCSessionDescription Class + dictionary RTCSessionDescriptionInit { + required RTCSdpType type; + DOMString sdp = ""; + }; + + 4.6.1. RTCSdpType + enum RTCSdpType { + "offer", + "pranswer", + "answer", + "rollback" + }; + */ + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 2.2.3. Otherwise, if description is set as a remote description, then run one of + the following steps: + - If description is of type "offer", set connection.pendingRemoteDescription + attribute to description and signaling state to have-remote-offer. + */ + + promise_test(t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + pc1.createDataChannel('datachannel'); + + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const states = []; + pc2.addEventListener('signalingstatechange', () => states.push(pc2.signalingState)); + + return pc1.createOffer() + .then(offer => { + return pc2.setRemoteDescription(offer) + .then(() => { + assert_equals(pc2.signalingState, 'have-remote-offer'); + assert_session_desc_similar(pc2.remoteDescription, offer); + assert_session_desc_similar(pc2.pendingRemoteDescription, offer); + assert_equals(pc2.currentRemoteDescription, null); + + assert_array_equals(states, ['have-remote-offer']); + }); + }); + }, 'setRemoteDescription with valid offer should succeed'); + + promise_test(t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + pc1.createDataChannel('datachannel'); + + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const states = []; + pc2.addEventListener('signalingstatechange', () => states.push(pc2.signalingState)); + + return pc1.createOffer() + .then(offer => { + return pc2.setRemoteDescription(offer) + .then(() => pc2.setRemoteDescription(offer)) + .then(() => { + assert_equals(pc2.signalingState, 'have-remote-offer'); + assert_session_desc_similar(pc2.remoteDescription, offer); + assert_session_desc_similar(pc2.pendingRemoteDescription, offer); + assert_equals(pc2.currentRemoteDescription, null); + + assert_array_equals(states, ['have-remote-offer']); + }); + }); + }, 'setRemoteDescription multiple times should succeed'); + + promise_test(t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + pc1.createDataChannel('datachannel'); + + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const states = []; + pc2.addEventListener('signalingstatechange', () => states.push(pc2.signalingState)); + + return pc1.createOffer() + .then(offer1 => { + return pc1.setLocalDescription(offer1) + .then(()=> { + return generateAudioReceiveOnlyOffer(pc1) + .then(offer2 => { + assert_session_desc_not_similar(offer1, offer2); + + return pc2.setRemoteDescription(offer1) + .then(() => pc2.setRemoteDescription(offer2)) + .then(() => { + assert_equals(pc2.signalingState, 'have-remote-offer'); + assert_session_desc_similar(pc2.remoteDescription, offer2); + assert_session_desc_similar(pc2.pendingRemoteDescription, offer2); + assert_equals(pc2.currentRemoteDescription, null); + + assert_array_equals(states, ['have-remote-offer']); + }); + }); + }); + }); + }, 'setRemoteDescription multiple times with different offer should succeed'); + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 2.1.4. If the content of description is not valid SDP syntax, then reject p with + an RTCError (with errorDetail set to "sdp-syntax-error" and the + sdpLineNumber attribute set to the line number in the SDP where the syntax + error was detected) and abort these steps. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.setRemoteDescription({ + type: 'offer', + sdp: 'Invalid SDP' + }) + .then(() => { + assert_unreached('Expect promise to be rejected'); + }, err => { + assert_equals(err.errorDetail, 'sdp-syntax-error', + 'Expect error detail field to set to sdp-syntax-error'); + + assert_true(err instanceof RTCError, + 'Expect err to be instance of RTCError'); + }); + }, 'setRemoteDescription(offer) with invalid SDP should reject with RTCError'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + await pc1.setLocalDescription(await pc1.createOffer()); + await pc1.setRemoteDescription(await pc2.createOffer()); + assert_equals(pc1.signalingState, 'have-remote-offer'); + }, 'setRemoteDescription(offer) from have-local-offer should roll back and succeed'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + await pc1.setLocalDescription(await pc1.createOffer()); + const p = pc1.setRemoteDescription(await pc2.createOffer()); + await new Promise(r => pc1.onsignalingstatechange = r); + assert_equals(pc1.signalingState, 'stable'); + assert_equals(pc1.pendingLocalDescription, null); + assert_equals(pc1.pendingRemoteDescription, null); + await new Promise(r => pc1.onsignalingstatechange = r); + assert_equals(pc1.signalingState, 'have-remote-offer'); + assert_equals(pc1.pendingLocalDescription, null); + assert_equals(pc1.pendingRemoteDescription.type, 'offer'); + await p; + }, 'setRemoteDescription(offer) from have-local-offer fires signalingstatechange twice'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver('audio', { direction: 'recvonly' }); + const srdPromise = pc2.setRemoteDescription(await pc1.createOffer()); + + assert_equals(pc2.signalingState, "stable", "signalingState should not be set synchronously after a call to sRD"); + + assert_equals(pc2.pendingRemoteDescription, null, "pendingRemoteDescription should not be set synchronously after a call to sRD"); + assert_equals(pc2.currentRemoteDescription, null, "currentRemoteDescription should not be set synchronously after a call to sRD"); + + const statePromise = new Promise(resolve => { + pc2.onsignalingstatechange = () => { + resolve(pc2.signalingState); + } + }); + + const raceValue = await Promise.race([statePromise, srdPromise]); + assert_equals(raceValue, "have-remote-offer", "signalingstatechange event should fire before sRD resolves"); + assert_not_equals(pc2.pendingRemoteDescription, null, "pendingRemoteDescription should be updated before the signalingstatechange event"); + assert_equals(pc2.pendingRemoteDescription.type, "offer"); + assert_equals(pc2.pendingRemoteDescription.sdp, pc2.remoteDescription.sdp); + assert_equals(pc2.currentRemoteDescription, null, "currentRemoteDescription should not be set after a call to sRD(offer)"); + + await srdPromise; + }, "setRemoteDescription(offer) in stable should update internal state with a queued task, in the right order"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc2.addTransceiver('audio', { direction: 'recvonly' }); + await pc2.setLocalDescription(await pc2.createOffer()); + + // Implicit rollback! + pc1.addTransceiver('audio', { direction: 'recvonly' }); + const srdPromise = pc2.setRemoteDescription(await pc1.createOffer()); + + assert_equals(pc2.signalingState, "have-local-offer", "signalingState should not be set synchronously after a call to sRD"); + + assert_equals(pc2.pendingRemoteDescription, null, "pendingRemoteDescription should not be set synchronously after a call to sRD"); + assert_not_equals(pc2.pendingLocalDescription, null, "pendingLocalDescription should not be set synchronously after a call to sRD"); + assert_equals(pc2.pendingLocalDescription.type, "offer"); + assert_equals(pc2.pendingLocalDescription.sdp, pc2.localDescription.sdp); + + // First, we should go through stable (the implicit rollback part) + const stablePromise = new Promise(resolve => { + pc2.onsignalingstatechange = () => { + resolve(pc2.signalingState); + } + }); + + let raceValue = await Promise.race([stablePromise, srdPromise]); + assert_equals(raceValue, "stable", "signalingstatechange event should fire before sRD resolves"); + assert_equals(pc2.pendingLocalDescription, null, "pendingLocalDescription should be updated before the signalingstatechange event"); + assert_equals(pc2.pendingRemoteDescription, null, "pendingRemoteDescription should be updated before the signalingstatechange event"); + + const haveRemoteOfferPromise = new Promise(resolve => { + pc2.onsignalingstatechange = () => { + resolve(pc2.signalingState); + } + }); + + raceValue = await Promise.race([haveRemoteOfferPromise, srdPromise]); + assert_equals(raceValue, "have-remote-offer", "signalingstatechange event should fire before sRD resolves"); + assert_not_equals(pc2.pendingRemoteDescription, null, "pendingRemoteDescription should be updated before the signalingstatechange event"); + assert_equals(pc2.pendingRemoteDescription.type, "offer"); + assert_equals(pc2.pendingRemoteDescription.sdp, pc2.remoteDescription.sdp); + assert_equals(pc2.pendingLocalDescription, null, "pendingLocalDescription should be updated before the signalingstatechange event"); + + await srdPromise; + }, "setRemoteDescription(offer) in have-local-offer should update internal state with a queued task, in the right order"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + await pc1.setLocalDescription(await pc1.createOffer()); + const offer = await pc2.createOffer(); + const p1 = pc1.setLocalDescription({type: 'rollback'}); + await new Promise(r => pc1.onsignalingstatechange = r); + assert_equals(pc1.signalingState, 'stable'); + const p2 = pc1.addIceCandidate(); + const p3 = pc1.setRemoteDescription(offer); + await promise_rejects_dom(t, 'InvalidStateError', p2); + await p1; + await p3; + assert_equals(pc1.signalingState, 'have-remote-offer'); + }, 'Naive rollback approach is not glare-proof (control)'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + await pc1.setLocalDescription(await pc1.createOffer()); + const p = pc1.setRemoteDescription(await pc2.createOffer()); + await new Promise(r => pc1.onsignalingstatechange = r); + assert_equals(pc1.signalingState, 'stable'); + await pc1.addIceCandidate(); + await p; + assert_equals(pc1.signalingState, 'have-remote-offer'); + }, 'setRemoteDescription(offer) from have-local-offer is glare-proof'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + await pc1.setLocalDescription(await pc1.createOffer()); + const p = pc1.setRemoteDescription({type: 'offer', sdp: 'Invalid SDP'}); + await new Promise(r => pc1.onsignalingstatechange = r); + assert_equals(pc1.signalingState, 'stable'); + assert_equals(pc1.pendingLocalDescription, null); + assert_equals(pc1.pendingRemoteDescription, null); + await promise_rejects_dom(t, 'RTCError', p); + }, 'setRemoteDescription(invalidOffer) from have-local-offer does not undo rollback'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver('video'); + const offer = await pc1.createOffer(); + await pc2.setRemoteDescription(offer); + assert_equals(pc2.getTransceivers().length, 1); + await pc2.setRemoteDescription(offer); + assert_equals(pc2.getTransceivers().length, 1); + await pc1.setLocalDescription(offer); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + }, 'repeated sRD(offer) works'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver('video'); + await exchangeOfferAnswer(pc1, pc2); + await waitForIceGatheringState(pc1, ['complete']); + await exchangeOfferAnswer(pc1, pc2); + await waitForIceStateChange(pc2, ['connected', 'completed']); + }, 'sRD(reoffer) with candidates and without trickle works'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver('video'); + const offer = await pc1.createOffer(); + const srdPromise = pc2.setRemoteDescription(offer); + assert_equals(pc2.getTransceivers().length, 0); + await srdPromise; + assert_equals(pc2.getTransceivers().length, 1); + }, 'Transceivers added by sRD(offer) should not show up until sRD resolves'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-pranswer.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-pranswer.html new file mode 100644 index 0000000000..1f8bde0f29 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-pranswer.html @@ -0,0 +1,166 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.setRemoteDescription pranswer</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // generateAnswer + // assert_session_desc_similar + + /* + 4.3.2. Interface Definition + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + Promise<void> setLocalDescription( + RTCSessionDescriptionInit description); + + readonly attribute RTCSessionDescription? localDescription; + readonly attribute RTCSessionDescription? currentLocalDescription; + readonly attribute RTCSessionDescription? pendingLocalDescription; + + Promise<void> setRemoteDescription( + RTCSessionDescriptionInit description); + + readonly attribute RTCSessionDescription? remoteDescription; + readonly attribute RTCSessionDescription? currentRemoteDescription; + readonly attribute RTCSessionDescription? pendingRemoteDescription; + ... + }; + + 4.6.2. RTCSessionDescription Class + dictionary RTCSessionDescriptionInit { + required RTCSdpType type; + DOMString sdp = ""; + }; + + 4.6.1. RTCSdpType + enum RTCSdpType { + "offer", + "pranswer", + "answer", + "rollback" + }; + */ + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 2.1.3. If the description's type is invalid for the current signaling state of + connection, then reject p with a newly created InvalidStateError and abort + these steps. + + [JSEP] + 5.6. If the type is "pranswer" or "answer", the PeerConnection state MUST be either + "have-local-offer" or "have-remote-pranswer". + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.createOffer() + .then(offer => + promise_rejects_dom(t, 'InvalidStateError', + pc.setRemoteDescription({ type: 'pranswer', sdp: offer.sdp }))); + }, 'setRemoteDescription(pranswer) from stable state should reject with InvalidStateError'); + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 2.2.3. Otherwise, if description is set as a remote description, then run one + of the following steps: + - If description is of type "pranswer", then set + connection.pendingRemoteDescription to description and signaling state + to have-remote-pranswer. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return generateVideoReceiveOnlyOffer(pc) + .then(offer => + pc.setLocalDescription(offer) + .then(() => generateAnswer(offer)) + .then(answer => { + const pranswer = { type: 'pranswer', sdp: answer.sdp }; + + return pc.setRemoteDescription(pranswer) + .then(() => { + assert_equals(pc.signalingState, 'have-remote-pranswer'); + + assert_session_desc_similar(pc.localDescription, offer); + assert_session_desc_similar(pc.pendingLocalDescription, offer); + assert_equals(pc.currentLocalDescription, null); + + assert_session_desc_similar(pc.remoteDescription, pranswer); + assert_session_desc_similar(pc.pendingRemoteDescription, pranswer); + assert_equals(pc.currentRemoteDescription, null); + + assert_array_equals(states, ['have-local-offer', 'have-remote-pranswer']); + }); + })); + }, 'setRemoteDescription(pranswer) from have-local-offer state should succeed'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return generateVideoReceiveOnlyOffer(pc) + .then(offer => + pc.setLocalDescription(offer) + .then(() => generateAnswer(offer)) + .then(answer => { + const pranswer = { type: 'pranswer', sdp: answer.sdp }; + + return pc.setRemoteDescription(pranswer) + .then(() => pc.setRemoteDescription(pranswer)) + .then(() => { + assert_array_equals(states, ['have-local-offer', 'have-remote-pranswer']); + }); + })); + }, 'setRemoteDescription(pranswer) multiple times should succeed'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return generateVideoReceiveOnlyOffer(pc) + .then(offer => + pc.setLocalDescription(offer) + .then(() => generateAnswer(offer)) + .then(answer => { + const pranswer = { type: 'pranswer', sdp: answer.sdp }; + + return pc.setRemoteDescription(pranswer) + .then(() => pc.setRemoteDescription(answer)) + .then(() => { + assert_equals(pc.signalingState, 'stable'); + + assert_session_desc_similar(pc.localDescription, offer); + assert_session_desc_similar(pc.currentLocalDescription, offer); + assert_equals(pc.pendingLocalDescription, null); + + assert_session_desc_similar(pc.remoteDescription, answer); + assert_session_desc_similar(pc.currentRemoteDescription, answer); + assert_equals(pc.pendingRemoteDescription, null); + + assert_array_equals(states, ['have-local-offer', 'have-remote-pranswer', 'stable']); + }); + })); + }, 'setRemoteDescription(answer) from have-remote-pranswer state should succeed'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-replaceTrack.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-replaceTrack.https.html new file mode 100644 index 0000000000..217326bfae --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-replaceTrack.https.html @@ -0,0 +1,115 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.setRemoteDescription - replaceTrack</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // The following helper functions are called from RTCPeerConnection-helper.js: + // getUserMediaTracksAndStreams + + async_test(t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + getUserMediaTracksAndStreams(2) + .then(t.step_func(([tracks, streams]) => { + const sender = caller.addTrack(tracks[0], streams[0]); + return sender.replaceTrack(tracks[1]) + .then(t.step_func(() => { + assert_equals(sender.track, tracks[1]); + t.done(); + })); + })) + .catch(t.step_func(reason => { + assert_unreached(reason); + })); + }, 'replaceTrack() sets the track attribute to a new track.'); + + async_test(t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + getUserMediaTracksAndStreams(1) + .then(t.step_func(([tracks, streams]) => { + const sender = caller.addTrack(tracks[0], streams[0]); + return sender.replaceTrack(null) + .then(t.step_func(() => { + assert_equals(sender.track, null); + t.done(); + })); + })) + .catch(t.step_func(reason => { + assert_unreached(reason); + })); + }, 'replaceTrack() sets the track attribute to null.'); + + async_test(t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + getUserMediaTracksAndStreams(2) + .then(t.step_func(([tracks, streams]) => { + const sender = caller.addTrack(tracks[0], streams[0]); + assert_equals(sender.track, tracks[0]); + sender.replaceTrack(tracks[1]); + // replaceTrack() is asynchronous, there should be no synchronously + // observable effects. + assert_equals(sender.track, tracks[0]); + t.done(); + })) + .catch(t.step_func(reason => { + assert_unreached(reason); + })); + }, 'replaceTrack() does not set the track synchronously.'); + + async_test(t => { + const expectedException = 'InvalidStateError'; + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + getUserMediaTracksAndStreams(2) + .then(t.step_func(([tracks, streams]) => { + const sender = caller.addTrack(tracks[0], streams[0]); + caller.close(); + return sender.replaceTrack(tracks[1]) + .then(t.step_func(() => { + assert_unreached('Expected replaceTrack() to be rejected with ' + + expectedException + ' but the promise was resolved.'); + }), + t.step_func(e => { + assert_equals(e.name, expectedException); + t.done(); + })); + })) + .catch(t.step_func(reason => { + assert_unreached(reason); + })); + }, 'replaceTrack() rejects when the peer connection is closed.'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const [tracks, streams] = await getUserMediaTracksAndStreams(2); + const sender = caller.addTrack(tracks[0], streams[0]); + caller.removeTrack(sender); + await sender.replaceTrack(tracks[1]); + assert_equals(sender.track, tracks[1], "Make sure track gets updated"); + }, 'replaceTrack() does not reject when invoked after removeTrack().'); + + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const [tracks, streams] = await getUserMediaTracksAndStreams(2); + const sender = caller.addTrack(tracks[0], streams[0]); + let p = sender.replaceTrack(tracks[1]) + caller.removeTrack(sender); + await p; + assert_equals(sender.track, tracks[1], "Make sure track gets updated"); + }, 'replaceTrack() does not reject after a subsequent removeTrack().'); + + // TODO(hbos): Verify that replaceTrack() changes what media is received on + // the remote end of two connected peer connections. For video tracks, this + // requires Chromium's video tag to update on receiving frames when running + // content_shell. https://crbug.com/793808 + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-rollback.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-rollback.html new file mode 100644 index 0000000000..0e6213d708 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-rollback.html @@ -0,0 +1,602 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.setRemoteDescription rollback</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // assert_session_desc_similar + // generateAudioReceiveOnlyOffer + // generateDataChannelOffer + + /* + 4.3.2. Interface Definition + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + Promise<void> setLocalDescription( + RTCSessionDescriptionInit description); + + readonly attribute RTCSessionDescription? localDescription; + readonly attribute RTCSessionDescription? currentLocalDescription; + readonly attribute RTCSessionDescription? pendingLocalDescription; + + Promise<void> setRemoteDescription( + RTCSessionDescriptionInit description); + + readonly attribute RTCSessionDescription? remoteDescription; + readonly attribute RTCSessionDescription? currentRemoteDescription; + readonly attribute RTCSessionDescription? pendingRemoteDescription; + ... + }; + + 4.6.2. RTCSessionDescription Class + dictionary RTCSessionDescriptionInit { + required RTCSdpType type; + DOMString sdp = ""; + }; + + 4.6.1. RTCSdpType + enum RTCSdpType { + "offer", + "pranswer", + "answer", + "rollback" + }; + */ + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 2.2.3. Otherwise, if description is set as a remote description, then run one + of the following steps: + - If description is of type "rollback", then this is a rollback. + Set connection.pendingRemoteDescription to null and signaling state to stable. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const states = []; + pc.addEventListener('signalingstatechange', () => states.push(pc.signalingState)); + + return generateDataChannelOffer(pc) + .then(offer => pc.setRemoteDescription(offer)) + .then(() => { + assert_equals(pc.signalingState, 'have-remote-offer'); + assert_not_equals(pc.remoteDescription, null); + assert_not_equals(pc.pendingRemoteDescription, null); + assert_equals(pc.currentRemoteDescription, null); + + return pc.setRemoteDescription({type: 'rollback'}); + }) + .then(() => { + assert_equals(pc.signalingState, 'stable'); + assert_equals(pc.remoteDescription, null); + assert_equals(pc.pendingRemoteDescription, null); + assert_equals(pc.currentRemoteDescription, null); + + assert_array_equals(states, ['have-remote-offer', 'stable']); + }); + }, 'setRemoteDescription(rollback) in have-remote-offer state should revert to stable state'); + + /* + 4.3.1.6. Set the RTCSessionSessionDescription + 2.3. If the description's type is invalid for the current signaling state of + connection, then reject p with a newly created InvalidStateError and abort + these steps. + + [jsep] + 4.1.8.2. Rollback + - Rollback can only be used to cancel proposed changes; + there is no support for rolling back from a stable state to a + previous stable state + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + return promise_rejects_dom(t, 'InvalidStateError', + pc.setRemoteDescription({type: 'rollback'})); + }, `setRemoteDescription(rollback) from stable state should reject with InvalidStateError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + await pc.setLocalDescription(); + await promise_rejects_dom(t, 'InvalidStateError', pc.setRemoteDescription({ type: 'rollback' })); + }, `setRemoteDescription(rollback) after setting a local offer should reject with InvalidStateError`); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + return generateAudioReceiveOnlyOffer(pc) + .then(offer => pc.setRemoteDescription(offer)) + .then(() => pc.setRemoteDescription({ + type: 'rollback', + sdp: '!<Invalid SDP Content>;' + })); + }, `setRemoteDescription(rollback) should ignore invalid sdp content and succeed`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + // We don't use this right away + pc1.addTransceiver('audio', { direction: 'recvonly' }); + const offer1 = await pc1.createOffer(); + + // Create offer from pc2, apply and rollback on pc1 + pc2.addTransceiver('audio', { direction: 'recvonly' }); + const offer2 = await pc2.createOffer(); + await pc1.setRemoteDescription(offer2); + await pc1.setRemoteDescription({type: "rollback"}); + + // Then try applying pc1's old offer + await pc1.setLocalDescription(offer1); + }, `local offer created before setRemoteDescription(remote offer) then rollback should still be usable`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + pc1.addTrack(stream.getTracks()[0], stream); + + // We don't use this right away. pc1 has provisionally decided that the + // (only) transceiver is bound to level 0. + const offer1 = await pc1.createOffer(); + + // Create offer from pc2, apply and rollback on pc1 + pc2.addTransceiver('audio', { direction: 'recvonly' }); + pc2.addTransceiver('video', { direction: 'recvonly' }); + const offer2 = await pc2.createOffer(); + // pc1 now should change its mind about what level its video transceiver is + // bound to. It was 0, now it is 1. + await pc1.setRemoteDescription(offer2); + + // Rolling back should put things back the way they were. + await pc1.setRemoteDescription({type: "rollback"}); + + // Then try applying pc1's old offer + await pc1.setLocalDescription(offer1); + }, "local offer created before setRemoteDescription(remote offer) with different transceiver level assignments then rollback should still be usable"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + pc1.addTrack(stream.getTracks()[0], stream); + + await pc2.setRemoteDescription(await pc1.createOffer()); + assert_equals(pc2.getTransceivers().length, 1); + await pc2.setRemoteDescription({type: "rollback"}); + assert_equals(pc2.getTransceivers().length, 0); + }, "rollback of a remote offer should remove a transceiver"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + pc1.addTrack(stream.getTracks()[0], stream); + + await pc2.setRemoteDescription(await pc1.createOffer()); + assert_equals(pc2.getTransceivers().length, 1); + + const stream2 = await getNoiseStream({video: true}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + const track = stream2.getVideoTracks()[0]; + await pc2.getTransceivers()[0].sender.replaceTrack(track); + + await pc2.setRemoteDescription({type: "rollback"}); + assert_equals(pc2.getTransceivers().length, 0); + }, "rollback of a remote offer should remove touched transceiver"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + pc1.addTrack(stream.getTracks()[0], stream); + + await pc2.setRemoteDescription(await pc1.createOffer()); + assert_equals(pc2.getTransceivers().length, 1); + + const stream2 = await getNoiseStream({video: true}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + pc2.addTrack(stream2.getTracks()[0], stream2); + + await pc2.setRemoteDescription({type: "rollback"}); + assert_equals(pc2.getTransceivers().length, 1); + assert_equals(pc2.getTransceivers()[0].mid, null); + assert_equals(pc2.getTransceivers()[0].receiver.transport, null); + }, "rollback of a remote offer should keep a transceiver"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + pc1.addTrack(stream.getTracks()[0], stream); + + const stream2 = await getNoiseStream({video: true}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + pc2.addTrack(stream2.getTracks()[0], stream2); + + await pc2.setRemoteDescription(await pc1.createOffer()); + assert_equals(pc2.getTransceivers().length, 1); + + await pc2.setRemoteDescription({type: "rollback"}); + assert_equals(pc2.getTransceivers().length, 1); + assert_equals(pc2.getTransceivers()[0].mid, null); + assert_equals(pc2.getTransceivers()[0].receiver.transport, null); + }, "rollback of a remote offer should keep a transceiver created by addtrack"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + pc1.addTrack(stream.getTracks()[0], stream); + + await pc2.setRemoteDescription(await pc1.createOffer()); + assert_equals(pc2.getTransceivers().length, 1); + + const stream2 = await getNoiseStream({video: true}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + pc2.addTrack(stream2.getTracks()[0], stream2); + await pc2.getTransceivers()[0].sender.replaceTrack(null); + await pc2.setRemoteDescription({type: "rollback"}); + assert_equals(pc2.getTransceivers().length, 1); + }, "rollback of a remote offer should keep a transceiver without tracks"); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + pc.addTrack(stream.getTracks()[0], stream); + + const states = []; + const signalingstatechangeResolver = new Resolver(); + pc.onsignalingstatechange = () => { + states.push(pc.signalingState); + signalingstatechangeResolver.resolve(); + }; + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + assert_not_equals(pc.getTransceivers()[0].sender.transport, null); + await pc.setLocalDescription({type: "rollback"}); + assert_equals(pc.getTransceivers().length, 1); + assert_equals(pc.getTransceivers()[0].mid, null) + assert_equals(pc.getTransceivers()[0].sender.transport, null); + await pc.setLocalDescription(offer); + assert_equals(pc.getTransceivers().length, 1); + await signalingstatechangeResolver.promise; + assert_array_equals(states, ['have-local-offer', 'stable', 'have-local-offer']); + }, "explicit rollback of local offer should remove transceivers and transport"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const states = []; + const signalingstatechangeResolver = new Resolver(); + pc1.onsignalingstatechange = () => { + states.push(pc1.signalingState); + signalingstatechangeResolver.resolve(); + }; + const stream1 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream1.getTracks().forEach(track => track.stop())); + pc1.addTransceiver(stream1.getTracks()[0], stream1); + + const stream2 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + pc2.addTransceiver(stream2.getTracks()[0], stream2); + + await pc1.setLocalDescription(await pc1.createOffer()); + pc1.onnegotiationneeded = t.step_func(() => assert_true(false, "There should be no negotiationneeded event right now")); + await pc1.setRemoteDescription(await pc2.createOffer()); + await pc1.setLocalDescription(await pc1.createAnswer()); + await signalingstatechangeResolver.promise; + assert_array_equals(states, ['have-local-offer', 'stable', 'have-remote-offer', 'stable']); + await new Promise(r => pc1.onnegotiationneeded = r); + }, "when using addTransceiver, implicit rollback of a local offer should visit stable state, but not fire negotiationneeded until we settle in stable"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const states = []; + const signalingstatechangeResolver = new Resolver(); + pc1.onsignalingstatechange = () => { + states.push(pc1.signalingState); + signalingstatechangeResolver.resolve(); + }; + const stream1 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream1.getTracks().forEach(track => track.stop())); + pc1.addTrack(stream1.getTracks()[0], stream1); + + const stream2 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + pc2.addTrack(stream2.getTracks()[0], stream2); + + await pc1.setLocalDescription(await pc1.createOffer()); + pc1.onnegotiationneeded = t.step_func(() => assert_true(false, "There should be no negotiationneeded event in this test")); + await pc1.setRemoteDescription(await pc2.createOffer()); + await pc1.setLocalDescription(await pc1.createAnswer()); + assert_array_equals(states, ['have-local-offer', 'stable', 'have-remote-offer', 'stable']); + await new Promise(r => t.step_timeout(r, 0)); + }, "when using addTrack, implicit rollback of a local offer should visit stable state, but not fire negotiationneeded"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + pc1.addTrack(stream.getTracks()[0], stream); + + await pc1.setLocalDescription(await pc1.createOffer()); + await pc2.setRemoteDescription(pc1.pendingLocalDescription); + + await pc2.setLocalDescription(await pc2.createAnswer()); + await pc1.setRemoteDescription(pc2.localDescription); + + // In stable state add video on both end and make sure video transceiver is not killed. + + const stream1 = await getNoiseStream({video: true}); + t.add_cleanup(() => stream1.getTracks().forEach(track => track.stop())); + pc1.addTrack(stream1.getTracks()[0], stream1); + await pc1.setLocalDescription(await pc1.createOffer()); + + const stream2 = await getNoiseStream({video: true}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + pc2.addTrack(stream2.getTracks()[0], stream2); + const offer2 = await pc2.createOffer(); + await pc2.setRemoteDescription(pc1.pendingLocalDescription); + await pc2.setRemoteDescription({type: "rollback"}); + assert_equals(pc2.getTransceivers().length, 2); + await pc2.setLocalDescription(offer2); + }, "rollback of a remote offer to negotiated stable state should enable " + + "applying of a local offer"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + pc1.addTrack(stream.getTracks()[0], stream); + + await pc1.setLocalDescription(await pc1.createOffer()); + await pc2.setRemoteDescription(pc1.pendingLocalDescription); + + await pc2.setLocalDescription(await pc2.createAnswer()); + await pc1.setRemoteDescription(pc2.localDescription); + + // Both ends want to add video at the same time. pc2 rolls back. + + const stream2 = await getNoiseStream({video: true}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + pc2.addTrack(stream2.getTracks()[0], stream2); + await pc2.setLocalDescription(await pc2.createOffer()); + assert_equals(pc2.getTransceivers().length, 2); + assert_not_equals(pc2.getTransceivers()[1].sender.transport, null); + await pc2.setLocalDescription({type: "rollback"}); + assert_equals(pc2.getTransceivers().length, 2); + // Rollback didn't touch audio transceiver and transport is intact. + assert_not_equals(pc2.getTransceivers()[0].sender.transport, null); + // Video transport got killed. + assert_equals(pc2.getTransceivers()[1].sender.transport, null); + const stream1 = await getNoiseStream({video: true}); + t.add_cleanup(() => stream1.getTracks().forEach(track => track.stop())); + pc1.addTrack(stream1.getTracks()[0], stream1); + await pc1.setLocalDescription(await pc1.createOffer()); + await pc2.setRemoteDescription(pc1.pendingLocalDescription); + }, "rollback of a local offer to negotiated stable state should enable " + + "applying of a remote offer"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + pc1.addTrack(stream.getTracks()[0], stream); + + await pc1.setLocalDescription(await pc1.createOffer()); + await pc2.setRemoteDescription(pc1.pendingLocalDescription); + + await pc2.setLocalDescription(await pc2.createAnswer()); + await pc1.setRemoteDescription(pc2.localDescription); + + // pc1 adds video and pc2 adds audio. pc2 rolls back. + assert_equals(pc2.getTransceivers()[0].direction, "recvonly"); + const stream2 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + pc2.addTrack(stream2.getTracks()[0], stream2); + assert_equals(pc2.getTransceivers()[0].direction, "sendrecv"); + await pc2.setLocalDescription(await pc2.createOffer()); + assert_equals(pc2.getTransceivers()[0].direction, "sendrecv"); + await pc2.setLocalDescription({type: "rollback"}); + assert_equals(pc2.getTransceivers().length, 1); + // setLocalDescription didn't change direction. So direction remains "sendrecv" + assert_equals(pc2.getTransceivers()[0].direction, "sendrecv"); + // Rollback didn't touch audio transceiver and transport is intact. Still can receive audio. + assert_not_equals(pc2.getTransceivers()[0].receiver.transport, null); + const stream1 = await getNoiseStream({video: true}); + t.add_cleanup(() => stream1.getTracks().forEach(track => track.stop())); + pc1.addTrack(stream1.getTracks()[0], stream1); + await pc1.setLocalDescription(await pc1.createOffer()); + await pc2.setRemoteDescription(pc1.pendingLocalDescription); + }, "rollback a local offer with audio direction change to negotiated " + + "stable state and then add video receiver"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver('video', {direction: 'sendonly'}); + pc2.addTransceiver('video', {direction: 'sendonly'}); + await pc1.setLocalDescription(await pc1.createOffer()); + const pc1FirstMid = pc1.getTransceivers()[0].mid; + await pc2.setLocalDescription(await pc2.createOffer()); + const pc2FirstMid = pc2.getTransceivers()[0].mid; + // I don't think it is mandated that this has to be true, but any implementation I know of would + // have predictable mids (e.g. 0, 1, 2...) so pc1 and pc2 should offer with the same mids. + assert_equals(pc1FirstMid, pc2FirstMid); + await pc1.setRemoteDescription(pc2.pendingLocalDescription); + // We've implicitly rolled back and the SRD caused a second transceiver to be created. + // As such, the first transceiver's mid will now be null, and the second transceiver's mid will + // match the remote offer. + assert_equals(pc1.getTransceivers().length, 2); + assert_equals(pc1.getTransceivers()[0].mid, null); + assert_equals(pc1.getTransceivers()[1].mid, pc2FirstMid); + // If we now do an offer the first transceiver will get a different mid than in the first + // pc1.createOffer()! + pc1.setLocalDescription(await pc1.createAnswer()); + await pc1.setLocalDescription(await pc1.createOffer()); + assert_not_equals(pc1.getTransceivers()[0].mid, pc1FirstMid); + }, "two transceivers with same mids"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const stream = await getNoiseStream({audio: true, video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const audio = stream.getAudioTracks()[0]; + pc1.addTrack(audio, stream); + const video = stream.getVideoTracks()[0]; + pc1.addTrack(video, stream); + + let remoteStream = null; + pc2.ontrack = e => { remoteStream = e.streams[0]; } + await pc2.setRemoteDescription(await pc1.createOffer()); + assert_true(remoteStream != null); + let remoteTracks = remoteStream.getTracks(); + const removedTracks = []; + remoteStream.onremovetrack = e => { removedTracks.push(e.track.id); } + await pc2.setRemoteDescription({type: "rollback"}); + assert_equals(removedTracks.length, 2, + "Rollback should have removed two tracks"); + assert_true(removedTracks.includes(remoteTracks[0].id), + "First track should be removed"); + assert_true(removedTracks.includes(remoteTracks[1].id), + "Second track should be removed"); + + }, "onremovetrack fires during remote rollback"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream1 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream1.getTracks().forEach(track => track.stop())); + pc1.addTrack(stream1.getTracks()[0], stream1); + + const offer1 = await pc1.createOffer(); + + const remoteStreams = []; + pc2.ontrack = e => { remoteStreams.push(e.streams[0]); } + + await pc1.setLocalDescription(offer1); + await pc2.setRemoteDescription(pc1.pendingLocalDescription); + await pc2.setLocalDescription(await pc2.createAnswer()); + await pc1.setRemoteDescription(pc2.localDescription); + + assert_equals(remoteStreams.length, 1, "Number of remote streams"); + assert_equals(remoteStreams[0].getTracks().length, 1, "Number of remote tracks"); + const track = remoteStreams[0].getTracks()[0]; + + const stream2 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + pc1.getTransceivers()[0].sender.setStreams(stream2); + + const offer2 = await pc1.createOffer(); + await pc2.setRemoteDescription(offer2); + + assert_equals(remoteStreams.length, 2); + assert_equals(remoteStreams[0].getTracks().length, 0); + assert_equals(remoteStreams[1].getTracks()[0].id, track.id); + await pc2.setRemoteDescription({type: "rollback"}); + assert_equals(remoteStreams.length, 3); + assert_equals(remoteStreams[0].id, remoteStreams[2].id); + assert_equals(remoteStreams[1].getTracks().length, 0); + assert_equals(remoteStreams[2].getTracks().length, 1); + assert_equals(remoteStreams[2].getTracks()[0].id, track.id); + + }, "rollback of a remote offer with stream changes"); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc2.addTransceiver('audio'); + const offer = await pc2.createOffer(); + await pc1.setRemoteDescription(offer); + const [transceiver] = pc1.getTransceivers(); + pc1.setRemoteDescription({type:'rollback'}); + pc1.removeTrack(transceiver.sender); + }, 'removeTrack() with a sender being rolled back does not crash or throw'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver('video'); + const channel = pc2.createDataChannel('dummy'); + await pc2.setLocalDescription(await pc2.createOffer()); + await pc2.setRemoteDescription(await pc1.createOffer()); + assert_equals(pc2.signalingState, 'have-remote-offer'); + await pc2.setLocalDescription(await pc2.createAnswer()); + await pc2.setLocalDescription(await pc2.createOffer()); + assert_equals(channel.readyState, 'connecting'); + }, 'Implicit rollback with only a datachannel works'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-simulcast.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-simulcast.https.html new file mode 100644 index 0000000000..98b5d2bab7 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-simulcast.https.html @@ -0,0 +1,50 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.setRemoteDescription rollback</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; +// Test for https://github.com/w3c/webrtc-pc/pull/2155 +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const [track, stream] = await getTrackFromUserMedia('video'); + t.add_cleanup(() => track.stop()); + + pc.addTrack(track, stream); + + const offer_sdp = `v=0 +o=- 3840232462471583827 2 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE 0 +a=msid-semantic: WMS +m=video 9 UDP/TLS/RTP/SAVPF 96 +c=IN IP4 0.0.0.0 +a=rtcp:9 IN IP4 0.0.0.0 +a=ice-ufrag:Li6+ +a=ice-pwd:3C05CTZBRQVmGCAq7hVasHlT +a=ice-options:trickle +a=fingerprint:sha-256 5B:D3:8E:66:0E:7D:D3:F3:8E:E6:80:28:19:FC:55:AD:58:5D:B9:3D:A8:DE:45:4A:E7:87:02:F8:3C:0B:3B:B3 +a=setup:actpass +a=mid:0 +a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id +a=recvonly +a=rtcp-mux +a=rtpmap:96 VP8/90000 +a=rtcp-fb:96 goog-remb +a=rtcp-fb:96 transport-cc +a=rtcp-fb:96 ccm fir +a=rid:foo recv +a=rid:bar recv +a=rid:baz recv +a=simulcast:recv foo;bar;baz +`; + + await pc.setRemoteDescription({type: 'offer', sdp: offer_sdp}); + const transceivers = pc.getTransceivers(); + assert_equals(transceivers.length, 1, 'Expected exactly one transceiver'); +}, 'createAnswer() attaches to an existing transceiver with a remote simulcast offer'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-tracks.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-tracks.https.html new file mode 100644 index 0000000000..d2ee646e2c --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription-tracks.https.html @@ -0,0 +1,385 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection.prototype.setRemoteDescription - add/remove remote tracks</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // The following helper functions are called from RTCPeerConnection-helper.js: + // addEventListenerPromise + // exchangeOffer + // exchangeOfferAnswer + // Resolver + + // These tests are concerned with the observable consequences of processing + // the addition or removal of remote tracks, including events firing and the + // states of RTCPeerConnection, MediaStream and MediaStreamTrack. + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const localStream = + await getNoiseStream({audio: true}); + t.add_cleanup(() => localStream.getTracks().forEach(track => track.stop())); + caller.addTrack(localStream.getTracks()[0]); + const ontrackPromise = addEventListenerPromise(t, callee, 'track', e => { + assert_equals(e.streams.length, 0, 'No remote stream created.'); + }); + await exchangeOffer(caller, callee); + await ontrackPromise; + }, 'addTrack() with a track and no stream makes ontrack fire with a track and no stream.'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const localStream = + await getNoiseStream({audio: true}); + t.add_cleanup(() => localStream.getTracks().forEach(track => track.stop())); + caller.addTrack(localStream.getTracks()[0], localStream); + const ontrackPromise = addEventListenerPromise(t, callee, 'track', e => { + assert_equals(e.streams.length, 1, 'Created a single remote stream.'); + assert_equals(e.streams[0].id, localStream.id, + 'Local and remote stream IDs match.'); + assert_array_equals(e.streams[0].getTracks(), [e.track], + 'The remote stream contains the remote track.'); + }); + await exchangeOffer(caller, callee); + await ontrackPromise; + }, 'addTrack() with a track and a stream makes ontrack fire with a track and a stream.'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + let eventSequence = ''; + const localStream = + await getNoiseStream({audio: true}); + t.add_cleanup(() => localStream.getTracks().forEach(track => track.stop())); + caller.addTrack(localStream.getTracks()[0], localStream); + const ontrackPromise = addEventListenerPromise(t, callee, 'track', e => { + eventSequence += 'ontrack;'; + }); + await exchangeOffer(caller, callee); + eventSequence += 'setRemoteDescription;'; + await ontrackPromise; + assert_equals(eventSequence, 'ontrack;setRemoteDescription;'); + }, 'ontrack fires before setRemoteDescription resolves.'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const localStreams = await Promise.all([ + getNoiseStream({audio: true}), + getNoiseStream({audio: true}), + ]); + t.add_cleanup(() => localStreams.forEach((stream) => + stream.getTracks().forEach((track) => track.stop()))); + caller.addTrack(localStreams[0].getTracks()[0], localStreams[0]); + caller.addTrack(localStreams[1].getTracks()[0], localStreams[0]); + let ontrackEventsFired = 0; + const ontrackEventResolvers = [ new Resolver(), new Resolver() ]; + callee.ontrack = t.step_func(e => { + ontrackEventResolvers[ontrackEventsFired++].resolve(e); + }); + await exchangeOffer(caller, callee); + let firstTrackEvent = await ontrackEventResolvers[0]; + assert_equals(firstTrackEvent.streams.length, 1, + 'First ontrack fires with a single stream.'); + assert_equals(firstTrackEvent.streams[0].id, + localStreams[0].id, + 'First ontrack\'s stream ID matches local stream.'); + let secondTrackEvent = await ontrackEventResolvers[1]; + assert_equals(secondTrackEvent.streams.length, 1, + 'Second ontrack fires with a single stream.'); + assert_equals(secondTrackEvent.streams[0].id, + localStreams[0].id, + 'Second ontrack\'s stream ID matches local stream.'); + assert_array_equals(firstTrackEvent.streams, secondTrackEvent.streams, + 'ontrack was fired with the same streams both times.'); + + assert_equals(firstTrackEvent.streams[0].getTracks().length, 2, "stream should have two tracks"); + assert_true(firstTrackEvent.streams[0].getTracks().includes(firstTrackEvent.track), "remoteStream should have the first track"); + assert_true(firstTrackEvent.streams[0].getTracks().includes(secondTrackEvent.track), "remoteStream should have the second track"); + assert_equals(ontrackEventsFired, 2, 'Unexpected number of track events.'); + + assert_equals(ontrackEventsFired, 2, 'Unexpected number of track events.'); + }, 'addTrack() with two tracks and one stream makes ontrack fire twice with the tracks and shared stream.'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + let eventSequence = ''; + const localStreams = await Promise.all([ + getNoiseStream({audio: true}), + getNoiseStream({audio: true}), + ]); + t.add_cleanup(() => localStreams.forEach((stream) => + stream.getTracks().forEach((track) => track.stop()))); + caller.addTrack(localStreams[0].getTracks()[0], localStreams[0]); + const remoteStreams = []; + callee.ontrack = e => { + if (!remoteStreams.includes(e.streams[0])) + remoteStreams.push(e.streams[0]); + }; + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + assert_equals(remoteStreams.length, 1, 'One remote stream created.'); + assert_equals(remoteStreams[0].id, localStreams[0].id, + 'First local and remote streams have the same ID.'); + const firstRemoteTrack = remoteStreams[0].getTracks()[0]; + const onaddtrackPromise = addEventListenerPromise(t, remoteStreams[0], 'addtrack'); + caller.addTrack(localStreams[1].getTracks()[0], localStreams[0]); + await exchangeOffer(caller, callee); + const e = await onaddtrackPromise; + assert_equals(remoteStreams[0].getTracks().length, 2, 'stream has two tracks'); + assert_not_equals(e.track.id, firstRemoteTrack.id, + 'addtrack event has a new track'); + assert_equals(remoteStreams.length, 1, 'Still a single remote stream.'); + }, 'addTrack() for an existing stream makes stream.onaddtrack fire.'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + let eventSequence = ''; + const localStreams = await Promise.all([ + getNoiseStream({audio: true}), + getNoiseStream({audio: true}), + ]); + t.add_cleanup(() => localStreams.forEach((stream) => + stream.getTracks().forEach((track) => track.stop()))); + caller.addTrack(localStreams[0].getTracks()[0], localStreams[0]); + const remoteStreams = []; + callee.ontrack = e => { + if (!remoteStreams.includes(e.streams[0])) + remoteStreams.push(e.streams[0]); + }; + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + assert_equals(remoteStreams.length, 1, 'One remote stream created.'); + const onaddtrackPromise = + addEventListenerPromise(t, remoteStreams[0], 'addtrack', e => { + eventSequence += 'stream.onaddtrack;'; + }); + caller.addTrack(localStreams[1].getTracks()[0], localStreams[0]); + await exchangeOffer(caller, callee); + eventSequence += 'setRemoteDescription;'; + await onaddtrackPromise; + assert_equals(remoteStreams.length, 1, 'Still a single remote stream.'); + assert_equals(eventSequence, 'stream.onaddtrack;setRemoteDescription;'); + }, 'stream.onaddtrack fires before setRemoteDescription resolves.'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const localStreams = await Promise.all([ + getNoiseStream({audio: true}), + getNoiseStream({audio: true}), + ]); + t.add_cleanup(() => localStreams.forEach((stream) => + stream.getTracks().forEach((track) => track.stop()))); + caller.addTrack(localStreams[0].getTracks()[0], + localStreams[0], localStreams[1]); + const ontrackPromise = addEventListenerPromise(t, callee, 'track', e => { + assert_equals(e.streams.length, 2, 'Two remote stream created.'); + assert_array_equals(e.streams[0].getTracks(), [e.track], + 'First remote stream == [remote track].'); + assert_array_equals(e.streams[1].getTracks(), [e.track], + 'Second remote stream == [remote track].'); + assert_equals(e.streams[0].id, localStreams[0].id, + 'First local and remote stream IDs match.'); + assert_equals(e.streams[1].id, localStreams[1].id, + 'Second local and remote stream IDs match.'); + }); + await exchangeOffer(caller, callee); + await ontrackPromise; + }, 'addTrack() with a track and two streams makes ontrack fire with a track and two streams.'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const localStream = + await getNoiseStream({audio: true}); + t.add_cleanup(() => localStream.getTracks().forEach(track => track.stop())); + caller.addTrack(localStream.getTracks()[0], localStream); + const ontrackPromise = addEventListenerPromise(t, callee, 'track', e => { + assert_array_equals(callee.getReceivers(), [e.receiver], + 'getReceivers() == [e.receiver].'); + }); + await exchangeOffer(caller, callee); + await ontrackPromise; + }, 'ontrack\'s receiver matches getReceivers().'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const localStream = + await getNoiseStream({audio: true}); + t.add_cleanup(() => localStream.getTracks().forEach(track => track.stop())); + const sender = caller.addTrack(localStream.getTracks()[0], localStream); + const ontrackPromise = addEventListenerPromise(t, callee, 'track'); + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + await ontrackPromise; + assert_equals(callee.getReceivers().length, 1, 'One receiver created.'); + caller.removeTrack(sender); + await exchangeOffer(caller, callee); + assert_equals(callee.getReceivers().length, 1, 'Receiver not removed.'); + }, 'removeTrack() does not remove the receiver.'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const localStream = + await getNoiseStream({audio: true}); + t.add_cleanup(() => localStream.getTracks().forEach(track => track.stop())); + const [track] = localStream.getTracks(); + const sender = caller.addTrack(track, localStream); + const ontrackPromise = addEventListenerPromise(t, callee, 'track', e => { + assert_equals(e.streams.length, 1); + return e.streams[0]; + }); + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + const remoteStream = await ontrackPromise; + const remoteTrack = remoteStream.getTracks()[0]; + const onremovetrackPromise = + addEventListenerPromise(t, remoteStream, 'removetrack', e => { + assert_equals(e.track, remoteTrack); + assert_equals(remoteStream.getTracks().length, 0, + 'Remote stream emptied of tracks.'); + }); + caller.removeTrack(sender); + await exchangeOffer(caller, callee); + await onremovetrackPromise; + }, 'removeTrack() makes stream.onremovetrack fire and the track to be removed from the stream.'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + let eventSequence = ''; + const localStream = + await getNoiseStream({audio: true}); + t.add_cleanup(() => localStream.getTracks().forEach(track => track.stop())); + const sender = caller.addTrack(localStream.getTracks()[0], localStream); + const ontrackPromise = addEventListenerPromise(t, callee, 'track', e => { + assert_equals(e.streams.length, 1); + return e.streams[0]; + }); + exchangeIceCandidates(caller, callee); + await exchangeOfferAnswer(caller, callee); + const remoteStream = await ontrackPromise; + const remoteTrack = remoteStream.getTracks()[0]; + const onremovetrackPromise = + addEventListenerPromise(t, remoteStream, 'removetrack', e => { + eventSequence += 'stream.onremovetrack;'; + }); + caller.removeTrack(sender); + await exchangeOffer(caller, callee); + eventSequence += 'setRemoteDescription;'; + await onremovetrackPromise; + assert_equals(eventSequence, 'stream.onremovetrack;setRemoteDescription;'); + }, 'stream.onremovetrack fires before setRemoteDescription resolves.'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const localStream = + await getNoiseStream({audio: true}); + t.add_cleanup(() => localStream.getTracks().forEach(track => track.stop())); + const sender = caller.addTrack(localStream.getTracks()[0], localStream); + exchangeIceCandidates(caller, callee); + const e = await exchangeOfferAndListenToOntrack(t, caller, callee); + const remoteTrack = e.track; + + // Need to wait for unmute, otherwise there's no event for the transition + // back to muted. + const onunmutePromise = + addEventListenerPromise(t, remoteTrack, 'unmute', () => { + assert_false(remoteTrack.muted); + }); + await exchangeAnswer(caller, callee); + await onunmutePromise; + + const onmutePromise = + addEventListenerPromise(t, remoteTrack, 'mute', () => { + assert_true(remoteTrack.muted); + }); + caller.removeTrack(sender); + await exchangeOffer(caller, callee); + await onmutePromise; + }, 'removeTrack() makes track.onmute fire and the track to be muted.'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + let eventSequence = ''; + const localStream = + await getNoiseStream({audio: true}); + t.add_cleanup(() => localStream.getTracks().forEach(track => track.stop())); + const sender = caller.addTrack(localStream.getTracks()[0], localStream); + const ontrackPromise = addEventListenerPromise(t, callee, 'track', e => { + assert_equals(e.streams.length, 1); + return e.streams[0]; + }); + exchangeIceCandidates(caller, callee); + const e = await exchangeOfferAndListenToOntrack(t, caller, callee); + const remoteTrack = e.track; + + // Need to wait for unmute, otherwise there's no event for the transition + // back to muted. + const onunmutePromise = + addEventListenerPromise(t, remoteTrack, 'unmute', () => { + assert_false(remoteTrack.muted); + }); + await exchangeAnswer(caller, callee); + await onunmutePromise; + + const onmutePromise = + addEventListenerPromise(t, remoteTrack, 'mute', () => { + eventSequence += 'track.onmute;'; + }); + caller.removeTrack(sender); + await exchangeOffer(caller, callee); + eventSequence += 'setRemoteDescription;'; + await onmutePromise; + assert_equals(eventSequence, 'track.onmute;setRemoteDescription;'); + }, 'track.onmute fires before setRemoteDescription resolves.'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const sender = pc.addTrack(stream.getTracks()[0]); + pc.removeTrack(sender); + pc.removeTrack(sender); + }, 'removeTrack() twice is safe.'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription.html new file mode 100644 index 0000000000..c170f766bd --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-setRemoteDescription.html @@ -0,0 +1,171 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.setRemoteDescription</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // assert_session_desc_not_similar() + // assert_session_desc_similar() + + /* + 4.3.2. Interface Definition + [Constructor(optional RTCConfiguration configuration)] + interface RTCPeerConnection : EventTarget { + Promise<void> setRemoteDescription( + RTCSessionDescriptionInit description); + + readonly attribute RTCSessionDescription? remoteDescription; + readonly attribute RTCSessionDescription? currentRemoteDescription; + readonly attribute RTCSessionDescription? pendingRemoteDescription; + ... + }; + + 4.6.2. RTCSessionDescription Class + dictionary RTCSessionDescriptionInit { + required RTCSdpType type; + DOMString sdp = ""; + }; + + 4.6.1. RTCSdpType + enum RTCSdpType { + "offer", + "pranswer", + "answer", + "rollback" + }; + */ + + /* + 4.6.1. enum RTCSdpType + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + // SDP is validated after WebIDL validation + try { + await pc.setRemoteDescription({ type: 'bogus', sdp: 'bogus' }); + t.unreached_func("Should have rejected."); + } catch (e) { + assert_throws_js(TypeError, () => { throw e }); + } + }, 'setRemoteDescription with invalid type and invalid SDP should reject with TypeError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + // SDP is validated after validating type + try { + await pc.setRemoteDescription({ type: 'answer', sdp: 'invalid' }); + t.unreached_func("Should have rejected."); + } catch (e) { + assert_throws_dom('InvalidStateError', () => { throw e }); + } + }, 'setRemoteDescription() with invalid SDP and stable state should reject with InvalidStateError'); + + /* Dedicated signalingstate events test. */ + + promise_test(async t => { + const pc = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + t.add_cleanup(() => pc2.close()); + + let eventCount = 0; + const states = [ + 'stable', 'have-local-offer', 'stable', 'have-remote-offer', + ]; + pc.onsignalingstatechange = t.step_func(() => + assert_equals(pc.signalingState, states[++eventCount])); + + const assert_state = state => { + assert_equals(state, pc.signalingState); + assert_equals(state, states[eventCount]); + }; + + const offer = await generateAudioReceiveOnlyOffer(pc); + assert_state('stable'); + await pc.setLocalDescription(offer); + assert_state('have-local-offer'); + await pc2.setRemoteDescription(offer); + await exchangeAnswer(pc, pc2); + assert_state('stable'); + await exchangeOffer(pc2, pc); + assert_state('have-remote-offer'); + }, 'Negotiation should fire signalingsstate events'); + + /* Operations after returning to stable state */ + + promise_test(async t => { + const pc = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + t.add_cleanup(() => pc2.close()); + + const offer1 = await generateAudioReceiveOnlyOffer(pc2); + await pc2.setLocalDescription(offer1); + await pc.setRemoteDescription(offer1); + await exchangeAnswer(pc2, pc); + const offer2 = await generateVideoReceiveOnlyOffer(pc2); + await pc2.setLocalDescription(offer2); + await pc.setRemoteDescription(offer2); + assert_session_desc_not_similar(offer1, offer2); + assert_session_desc_similar(pc.remoteDescription, offer2); + assert_session_desc_similar(pc.currentRemoteDescription, offer1); + assert_session_desc_similar(pc.pendingRemoteDescription, offer2); + }, 'Calling setRemoteDescription() again after one round of remote-offer/local-answer should succeed'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + t.add_cleanup(() => pc2.close()); + + const offer = await generateAudioReceiveOnlyOffer(pc); + await pc.setLocalDescription(offer); + await pc2.setRemoteDescription(offer); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc.setRemoteDescription(answer); + await exchangeOffer(pc2, pc); + assert_equals(pc.remoteDescription.sdp, pc.pendingRemoteDescription.sdp); + assert_session_desc_similar(pc.remoteDescription, offer); + assert_session_desc_similar(pc.currentRemoteDescription, answer); + }, 'Switching role from offerer to answerer after going back to stable state should succeed'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const offer = await pc.createOffer(); + const p = Promise.race([ + pc.setRemoteDescription(offer), + new Promise(r => t.step_timeout(() => r("timeout"), 200)) + ]); + pc.close(); + assert_equals(await p, "timeout"); + assert_equals(pc.signalingState, "closed", "In closed state"); + }, 'Closing on setRemoteDescription() neither resolves nor rejects'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const p = Promise.race([ + pc.setRemoteDescription(offer), + new Promise(r => t.step_timeout(() => r("timeout"), 200)) + ]); + pc.close(); + assert_equals(await p, "timeout"); + assert_equals(pc.signalingState, "closed", "In closed state"); + }, 'Closing on rollback neither resolves nor rejects'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-transceivers.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-transceivers.https.html new file mode 100644 index 0000000000..bb8ec2fe2b --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-transceivers.https.html @@ -0,0 +1,509 @@ +<!doctype html> +<meta name="timeout" content="long"/> +<meta charset=utf-8> +<title>RTCPeerConnection-transceivers.https.html</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +// The following helper functions are called from RTCPeerConnection-helper.js: +// exchangeOffer +// exchangeOfferAndListenToOntrack +// exchangeAnswer +// exchangeAnswerAndListenToOntrack +// addEventListenerPromise +// createPeerConnectionWithCleanup +// createTrackAndStreamWithCleanup +// findTransceiverForSender + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const sender = pc.addTrack(track, stream); + const transceiver = findTransceiverForSender(pc, sender); + assert_true(transceiver instanceof RTCRtpTransceiver); + assert_true(transceiver.sender instanceof RTCRtpSender); + assert_equals(transceiver.sender, sender); +}, 'addTrack: creates a transceiver for the sender'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = findTransceiverForSender(pc, pc.addTrack(track, stream)); + assert_array_equals(pc.getTransceivers(), [transceiver], + 'pc.getTransceivers() equals [transceiver]'); + assert_array_equals(pc.getSenders(), [transceiver.sender], + 'pc.getSenders() equals [transceiver.sender]'); + assert_array_equals(pc.getReceivers(), [transceiver.receiver], + 'pc.getReceivers() equals [transceiver.receiver]'); +}, 'addTrack: "transceiver == {sender,receiver}"'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = findTransceiverForSender(pc, pc.addTrack(track, stream)); + assert_true(transceiver.sender.track instanceof MediaStreamTrack, + 'transceiver.sender.track instanceof MediaStreamTrack'); + assert_equals(transceiver.sender.track, track, + 'transceiver.sender.track == track'); +}, 'addTrack: transceiver.sender is associated with the track'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = findTransceiverForSender(pc, pc.addTrack(track, stream)); + assert_true(transceiver.receiver instanceof RTCRtpReceiver, + 'transceiver.receiver instanceof RTCRtpReceiver'); + assert_true(transceiver.receiver.track instanceof MediaStreamTrack, + 'transceiver.receiver.track instanceof MediaStreamTrack'); + assert_not_equals(transceiver.receiver.track, track, + 'transceiver.receiver.track != track'); +}, 'addTrack: transceiver.receiver has its own track'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = findTransceiverForSender(pc, pc.addTrack(track, stream)); + assert_true(transceiver.receiver.track.muted); +}, 'addTrack: transceiver.receiver\'s track is muted'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = findTransceiverForSender(pc, pc.addTrack(track, stream)); + assert_equals(transceiver.mid, null); +}, 'addTrack: transceiver is not associated with an m-section'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = findTransceiverForSender(pc, pc.addTrack(track, stream)); + // `stopped` is non-standard. Move to external/wpt/webrtc/legacy/? + assert_false(transceiver.stopped); +}, 'addTrack: transceiver is not stopped'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = findTransceiverForSender(pc, pc.addTrack(track, stream)); + assert_equals(transceiver.direction, 'sendrecv'); +}, 'addTrack: transceiver\'s direction is sendrecv'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = findTransceiverForSender(pc, pc.addTrack(track, stream)); + assert_equals(transceiver.currentDirection, null); +}, 'addTrack: transceiver\'s currentDirection is null'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = findTransceiverForSender(pc, pc.addTrack(track, stream)); + await pc.setLocalDescription(await pc.createOffer()); + assert_not_equals(transceiver.mid, null, 'transceiver.mid != null'); +}, 'setLocalDescription(offer): transceiver gets associated with an m-section'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = findTransceiverForSender(pc, pc.addTrack(track, stream)); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + let sdp = offer.sdp; + let sdpMidLineStart = sdp.indexOf('a=mid:'); + let sdpMidLineEnd = sdp.indexOf('\r\n', sdpMidLineStart); + assert_true(sdpMidLineStart != -1 && sdpMidLineEnd != -1, + 'Failed to parse offer SDP for a=mid'); + let parsedMid = sdp.substring(sdpMidLineStart + 6, sdpMidLineEnd); + assert_equals(transceiver.mid, parsedMid, 'transceiver.mid == parsedMid'); +}, 'setLocalDescription(offer): transceiver.mid matches the offer SDP'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + pc1.addTrack(... await createTrackAndStreamWithCleanup(t)); + const pc2 = createPeerConnectionWithCleanup(t); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + assert_true(trackEvent instanceof RTCTrackEvent, + 'trackEvent instanceof RTCTrackEvent'); + assert_true(trackEvent.track instanceof MediaStreamTrack, + 'trackEvent.track instanceof MediaStreamTrack'); +}, 'setRemoteDescription(offer): ontrack fires with a track'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + pc1.addTrack(track, stream); + const pc2 = createPeerConnectionWithCleanup(t); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + assert_true(trackEvent.track instanceof MediaStreamTrack, + 'trackEvent.track instanceof MediaStreamTrack'); + assert_equals(trackEvent.streams.length, 1, + 'trackEvent contains a single stream'); + assert_true(trackEvent.streams[0] instanceof MediaStream, + 'trackEvent has a MediaStream'); + assert_equals(trackEvent.streams[0].id, stream.id, + 'trackEvent.streams[0].id == stream.id'); +}, 'setRemoteDescription(offer): ontrack\'s stream.id is the same as stream.id'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + pc1.addTrack(... await createTrackAndStreamWithCleanup(t)); + const pc2 = createPeerConnectionWithCleanup(t); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + assert_true(trackEvent.transceiver instanceof RTCRtpTransceiver, + 'trackEvent.transceiver instanceof RTCRtpTransceiver'); +}, 'setRemoteDescription(offer): ontrack fires with a transceiver.'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = findTransceiverForSender(pc1, pc1.addTrack(track, stream)); + const pc2 = createPeerConnectionWithCleanup(t); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + assert_equals(transceiver.mid, trackEvent.transceiver.mid); +}, 'setRemoteDescription(offer): transceiver.mid is the same on both ends'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + pc1.addTrack(... await createTrackAndStreamWithCleanup(t)); + const pc2 = createPeerConnectionWithCleanup(t); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + const transceiver = trackEvent.transceiver; + assert_array_equals(pc2.getTransceivers(), [transceiver], + 'pc2.getTransceivers() equals [transceiver]'); + assert_array_equals(pc2.getSenders(), [transceiver.sender], + 'pc2.getSenders() equals [transceiver.sender]'); + assert_array_equals(pc2.getReceivers(), [transceiver.receiver], + 'pc2.getReceivers() equals [transceiver.receiver]'); +}, 'setRemoteDescription(offer): "transceiver == {sender,receiver}"'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + pc1.addTrack(... await createTrackAndStreamWithCleanup(t)); + const pc2 = createPeerConnectionWithCleanup(t); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + assert_equals(trackEvent.transceiver.direction, 'recvonly'); +}, 'setRemoteDescription(offer): transceiver.direction is recvonly'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + pc1.addTrack(... await createTrackAndStreamWithCleanup(t)); + const pc2 = createPeerConnectionWithCleanup(t); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + assert_equals(trackEvent.transceiver.currentDirection, null); +}, 'setRemoteDescription(offer): transceiver.currentDirection is null'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + pc1.addTrack(... await createTrackAndStreamWithCleanup(t)); + const pc2 = createPeerConnectionWithCleanup(t); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + // `stopped` is non-standard. Move to external/wpt/webrtc/legacy/? + assert_false(trackEvent.transceiver.stopped); +}, 'setRemoteDescription(offer): transceiver.stopped is false'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + pc1.addTrack(... await createTrackAndStreamWithCleanup(t)); + const pc2 = createPeerConnectionWithCleanup(t); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + const transceiver = trackEvent.transceiver; + assert_equals(transceiver.currentDirection, null, + 'SRD(offer): transceiver.currentDirection is null'); + await pc2.setLocalDescription(await pc2.createAnswer()); + assert_equals(transceiver.currentDirection, 'recvonly', + 'SLD(answer): transceiver.currentDirection is recvonly'); +}, 'setLocalDescription(answer): transceiver.currentDirection is recvonly'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = findTransceiverForSender(pc1, pc1.addTrack(track, stream)); + const pc2 = createPeerConnectionWithCleanup(t); + await exchangeOffer(pc1, pc2); + assert_equals(transceiver.currentDirection, null, + 'SLD(offer): transceiver.currentDirection is null'); + await exchangeAnswer(pc1, pc2); + assert_equals(transceiver.currentDirection, 'sendonly', + 'SRD(answer): transceiver.currentDirection is sendonly'); +}, 'setLocalDescription(answer): transceiver.currentDirection is sendonly'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = pc.addTransceiver(track); + assert_true(transceiver instanceof RTCRtpTransceiver); + assert_true(transceiver.sender instanceof RTCRtpSender); + assert_true(transceiver.receiver instanceof RTCRtpReceiver); + assert_equals(transceiver.sender.track, track); +}, 'addTransceiver(track): creates a transceiver for the track'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = pc.addTransceiver(track); + assert_array_equals(pc.getTransceivers(), [transceiver], + 'pc.getTransceivers() equals [transceiver]'); + assert_array_equals(pc.getSenders(), [transceiver.sender], + 'pc.getSenders() equals [transceiver.sender]'); + assert_array_equals(pc.getReceivers(), [transceiver.receiver], + 'pc.getReceivers() equals [transceiver.receiver]'); +}, 'addTransceiver(track): "transceiver == {sender,receiver}"'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = pc.addTransceiver(track, {direction:'inactive'}); + assert_equals(transceiver.direction, 'inactive'); +}, 'addTransceiver(track, init): initialize direction to inactive'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const otherPc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = pc.addTransceiver(track, { + sendEncodings: [{active:false}] + }); + + // Negotiate parameters. + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + await otherPc.setRemoteDescription(offer); + const answer = await otherPc.createAnswer(); + await otherPc.setLocalDescription(answer); + await pc.setRemoteDescription(answer); + + const params = transceiver.sender.getParameters(); + assert_false(params.encodings[0].active); +}, 'addTransceiver(track, init): initialize sendEncodings[0].active to false'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + const [track] = await createTrackAndStreamWithCleanup(t); + pc1.addTransceiver(track, {streams:[]}); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + assert_equals(trackEvent.streams.length, 0, 'trackEvent.streams.length == 0'); +}, 'addTransceiver(0 streams): ontrack fires with no stream'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + const [track] = await createTrackAndStreamWithCleanup(t); + const stream = new MediaStream(); + pc1.addTransceiver(track, {streams:[stream]}); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + assert_equals(trackEvent.streams.length, 1, 'trackEvent.streams.length == 1'); + assert_equals(trackEvent.streams[0].id, stream.id, + 'trackEvent.streams[0].id == stream.id'); +}, 'addTransceiver(1 stream): ontrack fires with corresponding stream'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + const [track] = await createTrackAndStreamWithCleanup(t); + const stream0 = new MediaStream(); + const stream1 = new MediaStream(); + pc1.addTransceiver(track, {streams:[stream0, stream1]}); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + assert_equals(trackEvent.streams.length, 2, 'trackEvent.streams.length == 2'); + assert_equals(trackEvent.streams[0].id, stream0.id, + 'trackEvent.streams[0].id == stream0.id'); + assert_equals(trackEvent.streams[1].id, stream1.id, + 'trackEvent.streams[1].id == stream1.id'); +}, 'addTransceiver(2 streams): ontrack fires with corresponding two streams'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + const [track] = await createTrackAndStreamWithCleanup(t); + pc1.addTrack(track); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + assert_equals(trackEvent.streams.length, 0, 'trackEvent.streams.length == 0'); +}, 'addTrack(0 streams): ontrack fires with no stream'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + const [track] = await createTrackAndStreamWithCleanup(t); + const stream = new MediaStream(); + pc1.addTrack(track, stream); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + assert_equals(trackEvent.streams.length, 1, 'trackEvent.streams.length == 1'); + assert_equals(trackEvent.streams[0].id, stream.id, + 'trackEvent.streams[0].id == stream.id'); +}, 'addTrack(1 stream): ontrack fires with corresponding stream'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + const [track] = await createTrackAndStreamWithCleanup(t); + const stream0 = new MediaStream(); + const stream1 = new MediaStream(); + pc1.addTrack(track, stream0, stream1); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + assert_equals(trackEvent.streams.length, 2, 'trackEvent.streams.length == 2'); + assert_equals(trackEvent.streams[0].id, stream0.id, + 'trackEvent.streams[0].id == stream0.id'); + assert_equals(trackEvent.streams[1].id, stream1.id, + 'trackEvent.streams[1].id == stream1.id'); +}, 'addTrack(2 streams): ontrack fires with corresponding two streams'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = pc.addTransceiver('audio'); + assert_equals(transceiver.direction, 'sendrecv'); +}, 'addTransceiver(\'audio\'): creates a transceiver with direction sendrecv'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = pc.addTransceiver('audio'); + assert_equals(transceiver.receiver.track.kind, 'audio'); +}, 'addTransceiver(\'audio\'): transceiver.receiver.track.kind == \'audio\''); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = pc.addTransceiver('video'); + assert_equals(transceiver.receiver.track.kind, 'video'); +}, 'addTransceiver(\'video\'): transceiver.receiver.track.kind == \'video\''); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = pc.addTransceiver('audio'); + assert_equals(transceiver.sender.track, null); +}, 'addTransceiver(\'audio\'): transceiver.sender.track == null'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = pc.addTransceiver('audio'); + assert_equals(transceiver.currentDirection, null); +}, 'addTransceiver(\'audio\'): transceiver.currentDirection is null'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const transceiver = pc.addTransceiver('audio'); + // `stopped` is non-standard. Move to external/wpt/webrtc/legacy/? + assert_false(transceiver.stopped); +}, 'addTransceiver(\'audio\'): transceiver.stopped is false'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t, 'audio'); + const transceiver = pc.addTransceiver('audio'); + const sender = pc.addTrack(track, stream); + assert_equals(sender, transceiver.sender, 'sender == transceiver.sender'); + assert_equals(sender.track, track, 'sender.track == track'); +}, 'addTrack reuses reusable transceivers'); + +promise_test(async t => { + const pc = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t, 'audio'); + const t1 = pc.addTransceiver('audio'); + const t2 = pc.addTransceiver(track); + assert_not_equals(t2, t1, 't2 != t1'); + assert_equals(t2.sender.track, track, 't2.sender.track == track'); +}, 'addTransceiver does not reuse reusable transceivers'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t); + const pc1Transceiver = findTransceiverForSender(pc1, pc1.addTrack(track, stream)); + const pc2TrackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + const pc2Transceiver = pc2TrackEvent.transceiver; + assert_equals(pc2Transceiver.direction, 'recvonly', + 'pc2Transceiver.direction is recvonly after SRD(offer)'); + const pc2Sender = pc2.addTrack(track, stream); + assert_equals(pc2Transceiver.sender, pc2Sender, + 'pc2Transceiver.sender == sender'); + assert_equals(pc2Transceiver.direction, 'sendrecv', + 'pc2Transceiver.direction is sendrecv after addTrack()'); + assert_equals(pc2Transceiver.currentDirection, null, + 'pc2Transceiver.currentDirection is null before answer'); + const pc1TrackEvent = await exchangeAnswerAndListenToOntrack(t, pc1, pc2); + assert_equals(pc2Transceiver.currentDirection, 'sendrecv', + 'pc2Transceiver.currentDirection is sendrecv after SLD(answer)'); + assert_equals(pc1TrackEvent.transceiver, pc1Transceiver, + 'Answer: pc1.ontrack fires with the existing transceiver.'); + assert_equals(pc1Transceiver.currentDirection, 'sendrecv', + 'pc1Transceiver.currentDirection is sendrecv'); + assert_equals(pc2.getTransceivers().length, 1, + 'pc2.getTransceivers().length == 1'); + assert_equals(pc1.getTransceivers().length, 1, + 'pc1.getTransceivers().length == 1'); +}, 'Can setup two-way call using a single transceiver'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const pc2 = createPeerConnectionWithCleanup(t); + const [track, stream] = await createTrackAndStreamWithCleanup(t, 'audio'); + const transceiver = pc1.addTransceiver(track); + await exchangeOffer(pc1, pc2); + await exchangeAnswer(pc1, pc2); + assert_equals(transceiver.currentDirection, 'sendonly'); + // `stopped` is non-standard. Move to external/wpt/webrtc/legacy/? + assert_false(transceiver.stopped); + pc1.close(); + assert_equals(transceiver.currentDirection, 'stopped'); + assert_true(transceiver.stopped); +}, 'Closing the PC stops the transceivers'); + +promise_test(async t => { + const pc1 = createPeerConnectionWithCleanup(t); + const pc1Sender = pc1.addTrack(... await createTrackAndStreamWithCleanup(t)); + const localTransceiver = findTransceiverForSender(pc1, pc1Sender); + const pc2 = createPeerConnectionWithCleanup(t); + exchangeIceCandidates(pc1, pc2); + + const e = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + await exchangeAnswer(pc1, pc2); + localTransceiver.direction = 'inactive'; + await exchangeOfferAnswer(pc1, pc2); + + localTransceiver.direction = 'sendrecv'; + await exchangeOfferAndListenToOntrack(t, pc1, pc2); +}, 'Changing transceiver direction to \'sendrecv\' makes ontrack fire'); + +// Regression test coverage for https://crbug.com/950280. +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + const pc2Promise = pc2.createOffer() + .then((offer) => { return pc1.setRemoteDescription(offer); }) + .then(() => { return pc1.createAnswer(); }) + .then((answer) => { return pc1.setLocalDescription(answer); }); + pc1.addTransceiver('audio', { direction: 'recvonly' }); + const pc1Promise = pc1.createOffer() + .then(() => { pc1.addTrack(pc1.getReceivers()[0].track); }); + await Promise.all([pc1Promise, pc2Promise]); + assert_equals(pc1.getSenders()[0].track, pc1.getReceivers()[0].track); +}, 'transceiver.sender.track does not revert to an old state'); + +// Regression test coverage for https://crbug.com/950280. +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + const pc2Promise = pc2.createOffer() + .then((offer) => { return pc1.setRemoteDescription(offer); }) + .then(() => { return pc1.createAnswer(); }); + pc1.addTransceiver('audio', { direction: 'recvonly' }); + const pc1Promise = pc1.createOffer() + .then(() => { pc1.getTransceivers()[0].direction = 'inactive'; }); + await Promise.all([pc1Promise, pc2Promise]); + assert_equals(pc1.getTransceivers()[0].direction, 'inactive'); +}, 'transceiver.direction does not revert to an old state'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-transport-stats.https.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-transport-stats.https.html new file mode 100644 index 0000000000..3dfba16c56 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-transport-stats.https.html @@ -0,0 +1,46 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection a=setup SDP parameter test</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="./third_party/sdp/sdp.js"></script> +<script> +'use strict'; + +// Tests for correct behavior of the transport-stats. +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + pc1.createDataChannel('wpt'); + await pc1.setLocalDescription(); + const stats = await pc1.getStats(); + let transportStats; + stats.forEach(report => { + if (report.type === 'transport') { + transportStats = report; + } + }); + assert_equals(transportStats.dtlsState, 'new'); + assert_equals(transportStats.dtlsRole, 'unknown'); +}, 'DTLS statistics on transport-stats after setLocalDescription'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + pc1.createDataChannel('wpt'); + await pc1.setLocalDescription(); + const sections = SDPUtils.splitSections(pc1.localDescription.sdp); + const iceParameters = SDPUtils.getIceParameters(sections[1], sections[0]); + const stats = await pc1.getStats(); + let transportStats; + stats.forEach(report => { + if (report.type === 'transport') { + transportStats = report; + } + }); + assert_equals(transportStats.iceRole, 'controlling'); + assert_equals(transportStats.iceLocalUsernameFragment, iceParameters.usernameFragment); + assert_equals(transportStats.iceState, 'new'); + assert_equals(transportStats.selectedCandidatePairChanges, 0); +}, 'ICE statistics on transport-stats after setLocalDescription'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnection-videoDetectorTest.html b/testing/web-platform/tests/webrtc/RTCPeerConnection-videoDetectorTest.html new file mode 100644 index 0000000000..6786bd49ed --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnection-videoDetectorTest.html @@ -0,0 +1,84 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection Video detector test</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +// This test verifies that the helper function "detectSignal" from +// RTCPeerConnectionHelper, which is used to detect changes in a video +// signal, performs properly for a range of "signal" values. + +// If it fails, it indicates that the video codec used in this particular +// browser at this time doesn't reproduce the luma signal reliably enough +// for this particular application, which may lead to other tests that +// use the "detectSignal" helper failing without an obvious cause. + +// The most likely failure is timeout - which will happen if the +// luma value detected doesn't settle within the margin of error before +// the test times out. + +async function signalSettlementTime(t, v, sender, signal, backgroundTrack) { + const detectionStream = await getNoiseStream({video: {signal}}); + const [detectionTrack] = detectionStream.getTracks(); + try { + await sender.replaceTrack(detectionTrack); + const framesBefore = v.getVideoPlaybackQuality().totalVideoFrames; + await detectSignal(t, v, signal); + const framesAfter = v.getVideoPlaybackQuality().totalVideoFrames; + await sender.replaceTrack(backgroundTrack); + await detectSignal(t, v, 100); + return framesAfter - framesBefore; + } finally { + detectionTrack.stop(); + } +} + +promise_test(async t => { + const v = document.createElement('video'); + v.autoplay = true; + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const stream1 = await getNoiseStream({video: {signal: 100}}); + const [track1] = stream1.getTracks(); + t.add_cleanup(() => track1.stop()); + + const sender = pc1.addTrack(track1); + const haveTrackEvent = new Promise(r => pc2.ontrack = r); + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + v.srcObject = new MediaStream([(await haveTrackEvent).track]); + await new Promise(r => v.onloadedmetadata = r); + // The basic signal is a track with signal 100. We replace this + // with tracks with signal from 0 to 255 and see if they are all + // reliably detected. + await detectSignal(t, v, 100); + // A few buffered frames are received with the old content, and a few + // frames may not have settled on exactly the right value. In testing, + // this test passes with maxFrames = 3; give a little more margin. + const maxFrames = 7; + // Test values 0 and 255 + let maxCount = await signalSettlementTime(t, v, sender, 0, track1); + assert_less_than(maxCount, maxFrames, + 'Should get the black value within ' + maxFrames + ' frames'); + maxCount = Math.max( + await signalSettlementTime(t, v, sender, 255, track1), maxCount); + assert_less_than(maxCount, maxFrames, + 'Should get the white value within ' + maxFrames + ' frames'); + // Test a set of other values - far enough apart to make the test fast. + for (let signal = 2; signal <= 255; signal += 47) { + if (Math.abs(signal - 100) > 10) { + const count = await signalSettlementTime(t, v, sender, signal, track1); + maxCount = Math.max(count, maxCount); + assert_less_than(maxCount, 10, + 'Should get value ' + signal + ' within ' + maxFrames + ' frames'); + } + } + assert_less_than(maxCount, 10, 'Should get the right value within 10 frames'); +}, 'Signal detector detects track change within reasonable time'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnectionIceErrorEvent.html b/testing/web-platform/tests/webrtc/RTCPeerConnectionIceErrorEvent.html new file mode 100644 index 0000000000..4434cfd28b --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnectionIceErrorEvent.html @@ -0,0 +1,26 @@ +<!doctype html> +<meta charset="utf-8"> +<html> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> + +test(() => { + init = { + address: "168.3.4.5", + port: 4711, + url: "turn:turn.example.org", + errorCode: 703, + errorText: "Test error" + }; + event = new RTCPeerConnectionIceErrorEvent('type', init); + assert_equals(event.type, 'type'); + assert_equals(event.address, '168.3.4.5'); + assert_equals(event.port, 4711); + assert_equals(event.url, "turn:turn.example.org"); + assert_equals(event.errorCode, 703); + assert_equals(event.errorText, "Test error"); +}, 'RTCPeerConnectionIceErrorEvent constructed from init parameters'); + +</script> +</html> diff --git a/testing/web-platform/tests/webrtc/RTCPeerConnectionIceEvent-constructor.html b/testing/web-platform/tests/webrtc/RTCPeerConnectionIceEvent-constructor.html new file mode 100644 index 0000000000..447002dca1 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCPeerConnectionIceEvent-constructor.html @@ -0,0 +1,126 @@ +<!doctype html> +<meta charset="utf-8"> +<!-- +4.8.2 RTCPeerConnectionIceEvent + + The icecandidate event of the RTCPeerConnection uses the RTCPeerConnectionIceEvent interface. + +--> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +/* +RTCPeerConnectionIceEvent + +[Constructor(DOMString type, optional RTCPeerConnectionIceEventInit eventInitDict)] + +interface RTCPeerConnectionIceEvent : Event { + readonly attribute RTCIceCandidate? candidate; + readonly attribute DOMString? url; +}; + */ +test(() => { + assert_throws_js(TypeError, () => { + new RTCPeerConnectionIceEvent(); + }); +}, "RTCPeerConnectionIceEvent with no arguments throws TypeError"); + +test(() => { + const event = new RTCPeerConnectionIceEvent("type"); + /* + candidate of type RTCIceCandidate, readonly, nullable + url of type DOMString, readonly, nullable + */ + assert_equals(event.candidate, null); + assert_equals(event.url, null); + + /* + Firing an RTCPeerConnectionIceEvent event named e with an RTCIceCandidate + candidate means that an event with the name e, which does not bubble + (except where otherwise stated) and is not cancelable + (except where otherwise stated), + */ + assert_false(event.bubbles); + assert_false(event.cancelable); + +}, "RTCPeerConnectionIceEvent with no eventInitDict (default)"); + +test(() => { + const event = new RTCPeerConnectionIceEvent("type", {}); + + /* + candidate of type RTCIceCandidate, readonly, nullable + url of type DOMString, readonly, nullable + */ + assert_equals(event.candidate, null); + assert_equals(event.url, null); + + /* + Firing an RTCPeerConnectionIceEvent event named e with an RTCIceCandidate + candidate means that an event with the name e, which does not bubble + (except where otherwise stated) and is not cancelable + (except where otherwise stated), + */ + assert_false(event.bubbles); + assert_false(event.cancelable); + +}, "RTCPeerConnectionIceEvent with empty object as eventInitDict (default)"); + +test(() => { + const event = new RTCPeerConnectionIceEvent("type", { + candidate: null + }); + assert_equals(event.candidate, null); +}, "RTCPeerConnectionIceEvent.candidate is null when constructed with { candidate: null }"); + +test(() => { + const event = new RTCPeerConnectionIceEvent("type", { + candidate: undefined + }); + assert_equals(event.candidate, null); +}, "RTCPeerConnectionIceEvent.candidate is null when constructed with { candidate: undefined }"); + + +/* + +4.8.1 RTCIceCandidate Interface + +The RTCIceCandidate() constructor takes a dictionary argument, candidateInitDict, +whose content is used to initialize the new RTCIceCandidate object. When run, if +both the sdpMid and sdpMLineIndex dictionary members are null, throw a TypeError. +*/ +const candidate = ""; +const sdpMid = "sdpMid"; +const sdpMLineIndex = 1; +const usernameFragment = ""; +const url = "foo.bar"; + +test(() => { + const iceCandidate = new RTCIceCandidate({ candidate, sdpMid, sdpMLineIndex, usernameFragment }); + const event = new RTCPeerConnectionIceEvent("type", { + candidate: iceCandidate, + url, + }); + + assert_equals(event.candidate, iceCandidate); + assert_false(event.bubbles); + assert_false(event.cancelable); +}, "RTCPeerConnectionIceEvent with RTCIceCandidate"); + +test(() => { + const plain = { candidate, sdpMid, sdpMLineIndex, usernameFragment }; + assert_throws_js(TypeError, () => new RTCPeerConnectionIceEvent("type", { candidate: plain })); +}, "RTCPeerConnectionIceEvent with non RTCIceCandidate object throws"); + +test(() => { + const event = new RTCPeerConnectionIceEvent("type", { + candidate: null, + bubbles: true, + cancelable: true, + }); + + assert_true(event.bubbles); + assert_true(event.cancelable); +}, "RTCPeerConnectionIceEvent bubbles and cancelable"); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpCapabilities-helper.js b/testing/web-platform/tests/webrtc/RTCRtpCapabilities-helper.js new file mode 100644 index 0000000000..fb297c35fb --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpCapabilities-helper.js @@ -0,0 +1,52 @@ +'use strict' + +// Test is based on the following editor draft: +// https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + +// This file depends on dictionary-helper.js which should +// be loaded from the main HTML file. + +/* + 5.2. RTCRtpSender Interface + dictionary RTCRtpCapabilities { + sequence<RTCRtpCodecCapability> codecs; + sequence<RTCRtpHeaderExtensionCapability> headerExtensions; + }; + + dictionary RTCRtpCodecCapability { + DOMString mimeType; + unsigned long clockRate; + unsigned short channels; + DOMString sdpFmtpLine; + }; + + dictionary RTCRtpHeaderExtensionCapability { + DOMString uri; + }; + */ + +function validateRtpCapabilities(capabilities) { + assert_array_field(capabilities, 'codecs'); + for(const codec of capabilities.codecs) { + validateCodecCapability(codec); + } + + assert_greater_than(capabilities.codecs.length, 0, + 'Expect at least one codec capability available'); + + assert_array_field(capabilities, 'headerExtensions'); + for(const headerExt of capabilities.headerExtensions) { + validateHeaderExtensionCapability(headerExt); + } +} + +function validateCodecCapability(codec) { + assert_optional_string_field(codec, 'mimeType'); + assert_optional_unsigned_int_field(codec, 'clockRate'); + assert_optional_unsigned_int_field(codec, 'channels'); + assert_optional_string_field(codec, 'sdpFmtpLine'); +} + +function validateHeaderExtensionCapability(headerExt) { + assert_optional_string_field(headerExt, 'uri'); +} diff --git a/testing/web-platform/tests/webrtc/RTCRtpParameters-codecs.html b/testing/web-platform/tests/webrtc/RTCRtpParameters-codecs.html new file mode 100644 index 0000000000..f5fa65e2ac --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpParameters-codecs.html @@ -0,0 +1,206 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpParameters codecs</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="dictionary-helper.js"></script> +<script src="RTCRtpParameters-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCRtpParameters-helper.js: + // doOfferAnswerExchange + // validateSenderRtpParameters + + /* + 5.2. RTCRtpSender Interface + interface RTCRtpSender { + Promise<void> setParameters(optional RTCRtpParameters parameters); + RTCRtpParameters getParameters(); + }; + + dictionary RTCRtpParameters { + DOMString transactionId; + sequence<RTCRtpEncodingParameters> encodings; + sequence<RTCRtpHeaderExtensionParameters> headerExtensions; + RTCRtcpParameters rtcp; + sequence<RTCRtpCodecParameters> codecs; + }; + + dictionary RTCRtpCodecParameters { + [readonly] + unsigned short payloadType; + + [readonly] + DOMString mimeType; + + [readonly] + unsigned long clockRate; + + [readonly] + unsigned short channels; + + [readonly] + DOMString sdpFmtpLine; + }; + + getParameters + - The codecs sequence is populated based on the codecs that have been negotiated + for sending, and which the user agent is currently capable of sending. + + If setParameters has removed or reordered codecs, getParameters MUST return + the shortened/reordered list. However, every time codecs are renegotiated by + a new offer/answer exchange, the list of codecs MUST be restored to the full + negotiated set, in the priority order indicated by the remote description, + in effect discarding the effects of setParameters. + + codecs + - When using the setParameters method, the codecs sequence from the corresponding + call to getParameters can be reordered and entries can be removed, but entries + cannot be added, and the RTCRtpCodecParameters dictionary members cannot be modified. + */ + + // Get the first codec from param.codecs. + // Assert that param.codecs has at least one element + function getFirstCodec(param) { + const { codecs } = param; + assert_greater_than(codecs.length, 0); + return codecs[0]; + } + + /* + 5.2. setParameters + 7. If parameters.encodings.length is different from N, or if any parameter + in the parameters argument, marked as a Read-only parameter, has a value + that is different from the corresponding parameter value returned from + sender.getParameters(), abort these steps and return a promise rejected + with a newly created InvalidModificationError. Note that this also applies + to transactionId. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const { sender } = pc.addTransceiver('audio'); + await doOfferAnswerExchange(t, pc); + + const param = sender.getParameters(); + validateSenderRtpParameters(param); + + const codec = getFirstCodec(param); + + if(codec.payloadType === undefined) { + codec.payloadType = 8; + } else { + codec.payloadType += 1; + } + + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, 'setParameters() with codec.payloadType modified should reject with InvalidModificationError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + await doOfferAnswerExchange(t, pc); + const param = sender.getParameters(); + validateSenderRtpParameters(param); + + const codec = getFirstCodec(param); + + if(codec.mimeType === undefined) { + codec.mimeType = 'audio/piedpiper'; + } else { + codec.mimeType = `${codec.mimeType}-modified`; + } + + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, 'setParameters() with codec.mimeType modified should reject with InvalidModificationError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + await doOfferAnswerExchange(t, pc); + const param = sender.getParameters(); + validateSenderRtpParameters(param); + + const codec = getFirstCodec(param); + + if(codec.clockRate === undefined) { + codec.clockRate = 8000; + } else { + codec.clockRate += 1; + } + + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, 'setParameters() with codec.clockRate modified should reject with InvalidModificationError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + await doOfferAnswerExchange(t, pc); + const param = sender.getParameters(); + validateSenderRtpParameters(param); + + const codec = getFirstCodec(param); + + if(codec.channels === undefined) { + codec.channels = 6; + } else { + codec.channels += 1; + } + + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, 'setParameters() with codec.channels modified should reject with InvalidModificationError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + await doOfferAnswerExchange(t, pc); + const param = sender.getParameters(); + validateSenderRtpParameters(param); + + const codec = getFirstCodec(param); + + if(codec.sdpFmtpLine === undefined) { + codec.sdpFmtpLine = 'a=fmtp:98 0-15'; + } else { + codec.sdpFmtpLine = `${codec.sdpFmtpLine};foo=1`; + } + + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, 'setParameters() with codec.sdpFmtpLine modified should reject with InvalidModificationError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + await doOfferAnswerExchange(t, pc); + const param = sender.getParameters(); + validateSenderRtpParameters(param); + + const { codecs } = param; + + codecs.push({ + payloadType: 2, + mimeType: 'audio/piedpiper', + clockRate: 1000, + channels: 2 + }); + + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, 'setParameters() with new codecs inserted should reject with InvalidModificationError'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpParameters-encodings.html b/testing/web-platform/tests/webrtc/RTCRtpParameters-encodings.html new file mode 100644 index 0000000000..22abbb3718 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpParameters-encodings.html @@ -0,0 +1,543 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpParameters encodings</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="dictionary-helper.js"></script> +<script src="RTCRtpParameters-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCRtpParameters-helper.js: + // validateSenderRtpParameters + + /* + 5.1. RTCPeerConnection Interface Extensions + partial interface RTCPeerConnection { + RTCRtpTransceiver addTransceiver((MediaStreamTrack or DOMString) trackOrKind, + optional RTCRtpTransceiverInit init); + ... + }; + + dictionary RTCRtpTransceiverInit { + RTCRtpTransceiverDirection direction = "sendrecv"; + sequence<MediaStream> streams; + sequence<RTCRtpEncodingParameters> sendEncodings; + }; + + 5.2. RTCRtpSender Interface + interface RTCRtpSender { + Promise<void> setParameters(optional RTCRtpParameters parameters); + RTCRtpParameters getParameters(); + }; + + dictionary RTCRtpParameters { + DOMString transactionId; + sequence<RTCRtpEncodingParameters> encodings; + sequence<RTCRtpHeaderExtensionParameters> headerExtensions; + RTCRtcpParameters rtcp; + sequence<RTCRtpCodecParameters> codecs; + }; + + dictionary RTCRtpEncodingParameters { + boolean active; + unsigned long maxBitrate; + + [readonly] + DOMString rid; + + double scaleResolutionDownBy; + }; + + getParameters + - encodings is set to the value of the [[send encodings]] internal slot. + */ + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('video'); + + const param = transceiver.sender.getParameters(); + assert_equals(param.encodings.length, 1); + // Do not call this in every test; it does not make sense to disable all of + // the tests below for an implementation that is missing support for + // fields that are not related to the test. + validateSenderRtpParameters(param); + }, `getParameters should return RTCRtpEncodingParameters with all required fields`); + + /* + 5.1. addTransceiver + 7. Create an RTCRtpSender with track, streams and sendEncodings and let sender + be the result. + + 5.2. create an RTCRtpSender + 5. Let sender have a [[send encodings]] internal slot, representing a list + of RTCRtpEncodingParameters dictionaries. + 6. If sendEncodings is given as input to this algorithm, and is non-empty, + set the [[send encodings]] slot to sendEncodings. + + Otherwise, set it to a list containing a single RTCRtpEncodingParameters + with active set to true. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + + const param = transceiver.sender.getParameters(); + const { encodings } = param; + assert_equals(encodings.length, 1); + const encoding = param.encodings[0]; + + assert_equals(encoding.active, true); + assert_not_own_property(encoding, "maxBitrate"); + assert_not_own_property(encoding, "rid"); + assert_not_own_property(encoding, "scaleResolutionDownBy"); + // We do not check props from extension specifications here; those checks + // need to go in a test-case for that extension specification. + }, 'addTransceiver(audio) with undefined sendEncodings should have default encoding parameter with active set to true'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('video'); + + const param = transceiver.sender.getParameters(); + const { encodings } = param; + assert_equals(encodings.length, 1); + const encoding = param.encodings[0]; + + assert_equals(encoding.active, true); + // spec says to return an encoding without a scaleResolutionDownBy value + // when addTransceiver does not pass any encodings, however spec also says + // to throw if setParameters is missing a scaleResolutionDownBy. One of + // these two requirements needs to be removed, but it is unclear right now + // which will be removed. For now, allow scaleResolutionDownBy, but don't + // require it. + // https://github.com/w3c/webrtc-pc/issues/2730 + assert_not_own_property(encoding, "maxBitrate"); + assert_not_own_property(encoding, "rid"); + assert_equals(encoding.scaleResolutionDownBy, 1.0); + // We do not check props from extension specifications here; those checks + // need to go in a test-case for that extension specification. + }, 'addTransceiver(video) with undefined sendEncodings should have default encoding parameter with active set to true and scaleResolutionDownBy set to 1'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio', { sendEncodings: [] }); + + const param = transceiver.sender.getParameters(); + const { encodings } = param; + assert_equals(encodings.length, 1); + const encoding = param.encodings[0]; + + assert_equals(encoding.active, true); + assert_not_own_property(encoding, "maxBitrate"); + assert_not_own_property(encoding, "rid"); + assert_not_own_property(encoding, "scaleResolutionDownBy"); + // We do not check props from extension specifications here; those checks + // need to go in a test-case for that extension specification. + }, 'addTransceiver(audio) with empty list sendEncodings should have default encoding parameter with active set to true'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('video', { sendEncodings: [] }); + + const param = transceiver.sender.getParameters(); + const { encodings } = param; + assert_equals(encodings.length, 1); + const encoding = param.encodings[0]; + + assert_equals(encoding.active, true); + assert_not_own_property(encoding, "maxBitrate"); + assert_not_own_property(encoding, "rid"); + assert_equals(encoding.scaleResolutionDownBy, 1.0); + // We do not check props from extension specifications here; those checks + // need to go in a test-case for that extension specification. + }, 'addTransceiver(video) with empty list sendEncodings should have default encoding parameter with active set to true and scaleResolutionDownBy set to 1'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('video', {sendEncodings: [{rid: "foo"}, {rid: "bar", scaleResolutionDownBy: 3.0}]}); + + const param = transceiver.sender.getParameters(); + const { encodings } = param; + assert_equals(encodings.length, 2); + assert_equals(encodings[0].scaleResolutionDownBy, 1.0); + assert_equals(encodings[1].scaleResolutionDownBy, 3.0); + }, `addTransceiver(video) should auto-set scaleResolutionDownBy to 1 when some encodings have it, but not all`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('video', {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + + const param = transceiver.sender.getParameters(); + const { encodings } = param; + assert_equals(encodings.length, 2); + assert_equals(encodings[0].scaleResolutionDownBy, 2.0); + assert_equals(encodings[1].scaleResolutionDownBy, 1.0); + }, `addTransceiver should auto-set scaleResolutionDownBy to powers of 2 (descending) when absent`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const sendEncodings = []; + for (let i = 0; i < 1000; i++) { + sendEncodings.push({rid: i}); + } + const transceiver = pc.addTransceiver('video', {sendEncodings}); + + const param = transceiver.sender.getParameters(); + const { encodings } = param; + assert_less_than(encodings.length, 1000, `1000 encodings is clearly too many`); + }, `addTransceiver with a ridiculous number of encodings should truncate the list`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio', {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + + const param = transceiver.sender.getParameters(); + const { encodings } = param; + assert_equals(encodings.length, 1); + assert_not_own_property(encodings[0], "maxBitrate"); + assert_not_own_property(encodings[0], "rid"); + assert_not_own_property(encodings[0], "scaleResolutionDownBy"); + // We do not check props from extension specifications here; those checks + // need to go in a test-case for that extension specification. + }, `addTransceiver(audio) with multiple encodings should result in one encoding with no properties other than active`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {sender} = pc.addTransceiver('audio', {sendEncodings: [{rid: "foo", scaleResolutionDownBy: 2.0}]}); + const {encodings} = sender.getParameters(); + assert_equals(encodings.length, 1); + assert_not_own_property(encodings[0], "scaleResolutionDownBy"); + }, `addTransceiver(audio) should remove valid scaleResolutionDownBy`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {sender} = pc.addTransceiver('audio', {sendEncodings: [{rid: "foo", scaleResolutionDownBy: -1.0}]}); + const {encodings} = sender.getParameters(); + assert_equals(encodings.length, 1); + assert_not_own_property(encodings[0], "scaleResolutionDownBy"); + }, `addTransceiver(audio) should remove invalid scaleResolutionDownBy`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {sender} = pc.addTransceiver('audio'); + let params = sender.getParameters(); + assert_equals(params.encodings.length, 1); + params.encodings[0].scaleResolutionDownBy = 2; + await sender.setParameters(params); + const {encodings} = sender.getParameters(); + assert_equals(encodings.length, 1); + assert_not_own_property(encodings[0], "scaleResolutionDownBy"); + }, `setParameters with scaleResolutionDownBy on an audio sender should succeed, but remove the scaleResolutionDownBy`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const {sender} = pc.addTransceiver('audio'); + let params = sender.getParameters(); + assert_equals(params.encodings.length, 1); + params.encodings[0].scaleResolutionDownBy = -1; + await sender.setParameters(params); + const {encodings} = sender.getParameters(); + assert_equals(encodings.length, 1); + assert_not_own_property(encodings[0], "scaleResolutionDownBy"); + }, `setParameters with an invalid scaleResolutionDownBy on an audio sender should succeed, but remove the scaleResolutionDownBy`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_throws_js(TypeError, () => pc.addTransceiver('video', { sendEncodings: [{rid: "foo"}, {rid: "foo"}] })); + }, 'addTransceiver with duplicate rid and multiple encodings throws TypeError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_throws_js(TypeError, () => pc.addTransceiver('video', { sendEncodings: [{rid: "foo"}, {}] })); + }, 'addTransceiver with missing rid and multiple encodings throws TypeError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_throws_js(TypeError, () => pc.addTransceiver('video', { sendEncodings: [{rid: ""}] })); + }, 'addTransceiver with empty rid throws TypeError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_throws_js(TypeError, () => pc.addTransceiver('video', { sendEncodings: [{rid: "!?"}] })); + assert_throws_js(TypeError, () => pc.addTransceiver('video', { sendEncodings: [{rid: "(âŊ°âĄÂ°)âŊïļĩ âŧââŧ"}] })); + // RFC 8851 says '-' and '_' are allowed, but RFC 8852 says they are not. + // RFC 8852 needs to be adhered to, otherwise we can't put the rid in RTP + // https://github.com/w3c/webrtc-pc/issues/2732 + // https://www.rfc-editor.org/errata/eid7132 + assert_throws_js(TypeError, () => pc.addTransceiver('video', { sendEncodings: [{rid: "foo-bar"}] })); + assert_throws_js(TypeError, () => pc.addTransceiver('video', { sendEncodings: [{rid: "foo_bar"}] })); + }, 'addTransceiver with invalid rid characters throws TypeError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + // https://github.com/w3c/webrtc-pc/issues/2732 + assert_throws_js(TypeError, () => pc.addTransceiver('video', { sendEncodings: [{rid: 'a'.repeat(256)}] })); + }, 'addTransceiver with rid longer than 255 characters throws TypeError'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_throws_js(RangeError, () => pc.addTransceiver('video', { sendEncodings: [{scaleResolutionDownBy: -1}] })); + assert_throws_js(RangeError, () => pc.addTransceiver('video', { sendEncodings: [{scaleResolutionDownBy: 0}] })); + assert_throws_js(RangeError, () => pc.addTransceiver('video', { sendEncodings: [{scaleResolutionDownBy: 0.5}] })); + }, `addTransceiver with scaleResolutionDownBy < 1 throws RangeError`); + + /* + 5.2. create an RTCRtpSender + To create an RTCRtpSender with a MediaStreamTrack , track, a list of MediaStream + objects, streams, and optionally a list of RTCRtpEncodingParameters objects, + sendEncodings, run the following steps: + 5. Let sender have a [[send encodings]] internal slot, representing a list + of RTCRtpEncodingParameters dictionaries. + + 6. If sendEncodings is given as input to this algorithm, and is non-empty, + set the [[send encodings]] slot to sendEncodings. + + 5.2. getParameters + - encodings is set to the value of the [[send encodings]] internal slot. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video', { + sendEncodings: [{ + active: false, + maxBitrate: 8, + rid: 'foo' + }] + }); + + const param = sender.getParameters(); + const encoding = param.encodings[0]; + + assert_equals(encoding.active, false); + assert_equals(encoding.maxBitrate, 8); + assert_not_own_property(encoding, "rid", "rid should be removed with a single encoding"); + + }, `sender.getParameters() should return sendEncodings set by addTransceiver()`); + + /* + 5.2. setParameters + 3. Let N be the number of RTCRtpEncodingParameters stored in sender's internal + [[send encodings]] slot. + 7. If parameters.encodings.length is different from N, or if any parameter + in the parameters argument, marked as a Read-only parameter, has a value + that is different from the corresponding parameter value returned from + sender.getParameters(), abort these steps and return a promise rejected + with a newly created InvalidModificationError. Note that this also applies + to transactionId. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video'); + + const param = sender.getParameters(); + + const { encodings } = param; + assert_equals(encodings.length, 1); + + // While {} is valid RTCRtpEncodingParameters because all fields are + // optional, it is still invalid to be missing a rid when there are multiple + // encodings. Only trigger one kind of error here. + encodings.push({ rid: "foo" }); + assert_equals(param.encodings.length, 2); + + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, `sender.setParameters() with added encodings should reject with InvalidModificationError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video', {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + + const param = sender.getParameters(); + + const { encodings } = param; + assert_equals(encodings.length, 2); + + encodings.pop(); + assert_equals(param.encodings.length, 1); + + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, `sender.setParameters() with removed encodings should reject with InvalidModificationError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video', {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + + const param = sender.getParameters(); + + const { encodings } = param; + assert_equals(encodings.length, 2); + encodings.push(encodings.shift()); + assert_equals(param.encodings.length, 2); + + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, `sender.setParameters() with reordered encodings should reject with InvalidModificationError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video'); + + const param = sender.getParameters(); + + delete param.encodings; + + return promise_rejects_js(t, TypeError, + sender.setParameters(param)); + }, `sender.setParameters() with encodings unset should reject with TypeError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video'); + + const param = sender.getParameters(); + + param.encodings = []; + + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, `sender.setParameters() with empty encodings should reject with InvalidModificationError (video)`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + + const param = sender.getParameters(); + + param.encodings = []; + + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, `sender.setParameters() with empty encodings should reject with InvalidModificationError (audio)`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video', { + sendEncodings: [{ rid: 'foo' }, { rid: 'baz' }], + }); + + const param = sender.getParameters(); + const encoding = param.encodings[0]; + + assert_equals(encoding.rid, 'foo'); + + encoding.rid = 'bar'; + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, `setParameters() with modified encoding.rid field should reject with InvalidModificationError`); + + /* + 5.2. setParameters + 8. If the scaleResolutionDownBy parameter in the parameters argument has a + value less than 1.0, abort these steps and return a promise rejected with + a newly created RangeError. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video'); + + const param = sender.getParameters(); + const encoding = param.encodings[0]; + + encoding.scaleResolutionDownBy = 0.5; + await promise_rejects_js(t, RangeError, sender.setParameters(param)); + encoding.scaleResolutionDownBy = 0; + await promise_rejects_js(t, RangeError, sender.setParameters(param)); + encoding.scaleResolutionDownBy = -1; + await promise_rejects_js(t, RangeError, sender.setParameters(param)); + }, `setParameters() with encoding.scaleResolutionDownBy field set to less than 1.0 should reject with RangeError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video'); + + let param = sender.getParameters(); + const encoding = param.encodings[0]; + + delete encoding.scaleResolutionDownBy; + await sender.setParameters(param); + param = sender.getParameters(); + assert_equals(param.encodings[0].scaleResolutionDownBy, 1.0); + }, `setParameters() with missing encoding.scaleResolutionDownBy field should succeed, and set the value back to 1`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('video'); + + const param = sender.getParameters(); + const encoding = param.encodings[0]; + + encoding.scaleResolutionDownBy = 1.5; + return sender.setParameters(param) + .then(() => { + const param = sender.getParameters(); + const encoding = param.encodings[0]; + + assert_approx_equals(encoding.scaleResolutionDownBy, 1.5, 0.01); + }); + }, `setParameters() with encoding.scaleResolutionDownBy field set to greater than 1.0 should succeed`); + + test_modified_encoding('video', 'active', false, true, + 'setParameters() with encoding.active false->true should succeed (video)'); + + test_modified_encoding('video', 'active', true, false, + 'setParameters() with encoding.active true->false should succeed (video)'); + + test_modified_encoding('video', 'maxBitrate', 10000, 20000, + 'setParameters() with modified encoding.maxBitrate should succeed (video)'); + + test_modified_encoding('audio', 'active', false, true, + 'setParameters() with encoding.active false->true should succeed (audio)'); + + test_modified_encoding('audio', 'active', true, false, + 'setParameters() with encoding.active true->false should succeed (audio)'); + + test_modified_encoding('audio', 'maxBitrate', 10000, 20000, + 'setParameters() with modified encoding.maxBitrate should succeed (audio)'); + + test_modified_encoding('video', 'scaleResolutionDownBy', 2, 4, + 'setParameters() with modified encoding.scaleResolutionDownBy should succeed'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpParameters-headerExtensions.html b/testing/web-platform/tests/webrtc/RTCRtpParameters-headerExtensions.html new file mode 100644 index 0000000000..7de2b75f4e --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpParameters-headerExtensions.html @@ -0,0 +1,74 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpParameters headerExtensions</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="dictionary-helper.js"></script> +<script src="RTCRtpParameters-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCRtpParameters-helper.js: + // validateSenderRtpParameters + + /* + 5.2. RTCRtpSender Interface + interface RTCRtpSender { + Promise<void> setParameters(optional RTCRtpParameters parameters); + RTCRtpParameters getParameters(); + }; + + dictionary RTCRtpParameters { + DOMString transactionId; + sequence<RTCRtpEncodingParameters> encodings; + sequence<RTCRtpHeaderExtensionParameters> headerExtensions; + RTCRtcpParameters rtcp; + sequence<RTCRtpCodecParameters> codecs; + }; + + dictionary RTCRtpHeaderExtensionParameters { + [readonly] + DOMString uri; + + [readonly] + unsigned short id; + + [readonly] + boolean encrypted; + }; + + getParameters + - The headerExtensions sequence is populated based on the header extensions + that have been negotiated for sending. + */ + + /* + 5.2. setParameters + 7. If parameters.encodings.length is different from N, or if any parameter + in the parameters argument, marked as a Read-only parameter, has a value + that is different from the corresponding parameter value returned from + sender.getParameters(), abort these steps and return a promise rejected + with a newly created InvalidModificationError. Note that this also applies + to transactionId. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + const param = sender.getParameters(); + validateSenderRtpParameters(param); + + param.headerExtensions = [{ + uri: 'non-existent.example.org', + id: 404, + encrypted: false + }]; + + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, `setParameters() with modified headerExtensions should reject with InvalidModificationError`); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpParameters-helper.js b/testing/web-platform/tests/webrtc/RTCRtpParameters-helper.js new file mode 100644 index 0000000000..dd8ae0cc06 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpParameters-helper.js @@ -0,0 +1,259 @@ +'use strict'; + +// Test is based on the following editor draft: +// https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + +// Helper function for testing RTCRtpParameters dictionary fields + +// This file depends on dictionary-helper.js which should +// be loaded from the main HTML file. + +// An offer/answer exchange is necessary for getParameters() to have any +// negotiated parameters to return. +async function doOfferAnswerExchange(t, caller) { + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const offer = await caller.createOffer(); + await caller.setLocalDescription(offer); + await callee.setRemoteDescription(offer); + const answer = await callee.createAnswer(); + await callee.setLocalDescription(answer); + await caller.setRemoteDescription(answer); + + return callee; +} + +/* + Validates the RTCRtpParameters returned from RTCRtpSender.prototype.getParameters + + 5.2. RTCRtpSender Interface + getParameters + - transactionId is set to a new unique identifier, used to match this getParameters + call to a setParameters call that may occur later. + + - encodings is set to the value of the [[SendEncodings]] internal slot. + + - The headerExtensions sequence is populated based on the header extensions that + have been negotiated for sending. + + - The codecs sequence is populated based on the codecs that have been negotiated + for sending, and which the user agent is currently capable of sending. If + setParameters has removed or reordered codecs, getParameters MUST return the + shortened/reordered list. However, every time codecs are renegotiated by a + new offer/answer exchange, the list of codecs MUST be restored to the full + negotiated set, in the priority order indicated by the remote description, + in effect discarding the effects of setParameters. + + - rtcp.cname is set to the CNAME of the associated RTCPeerConnection. rtcp.reducedSize + is set to true if reduced-size RTCP has been negotiated for sending, and false otherwise. + */ +function validateSenderRtpParameters(param) { + validateRtpParameters(param); + + assert_array_field(param, 'encodings'); + for(const encoding of param.encodings) { + validateEncodingParameters(encoding); + } + + assert_not_equals(param.transactionId, undefined, + 'Expect sender param.transactionId to be set'); + + assert_not_equals(param.rtcp.cname, undefined, + 'Expect sender param.rtcp.cname to be set'); + + assert_not_equals(param.rtcp.reducedSize, undefined, + 'Expect sender param.rtcp.reducedSize to be set to either true or false'); +} + +/* + Validates the RTCRtpParameters returned from RTCRtpReceiver.prototype.getParameters + + 5.3. RTCRtpReceiver Interface + getParameters + When getParameters is called, the RTCRtpParameters dictionary is constructed + as follows: + + - The headerExtensions sequence is populated based on the header extensions that + the receiver is currently prepared to receive. + + - The codecs sequence is populated based on the codecs that the receiver is currently + prepared to receive. + + - rtcp.reducedSize is set to true if the receiver is currently prepared to receive + reduced-size RTCP packets, and false otherwise. rtcp.cname is left undefined. + + - transactionId is left undefined. + */ +function validateReceiverRtpParameters(param) { + validateRtpParameters(param); + + assert_equals(param.transactionId, undefined, + 'Expect receiver param.transactionId to be unset'); + + assert_not_equals(param.rtcp.reducedSize, undefined, + 'Expect receiver param.rtcp.reducedSize to be set'); + + assert_equals(param.rtcp.cname, undefined, + 'Expect receiver param.rtcp.cname to be unset'); +} + +/* + dictionary RTCRtpParameters { + DOMString transactionId; + sequence<RTCRtpEncodingParameters> encodings; + sequence<RTCRtpHeaderExtensionParameters> headerExtensions; + RTCRtcpParameters rtcp; + sequence<RTCRtpCodecParameters> codecs; + }; + + */ +function validateRtpParameters(param) { + assert_optional_string_field(param, 'transactionId'); + + assert_array_field(param, 'headerExtensions'); + for(const headerExt of param.headerExtensions) { + validateHeaderExtensionParameters(headerExt); + } + + assert_dict_field(param, 'rtcp'); + validateRtcpParameters(param.rtcp); + + assert_array_field(param, 'codecs'); + for(const codec of param.codecs) { + validateCodecParameters(codec); + } +} + +/* + dictionary RTCRtpEncodingParameters { + boolean active; + unsigned long maxBitrate; + + [readonly] + DOMString rid; + + double scaleResolutionDownBy; + }; + + */ +function validateEncodingParameters(encoding) { + assert_optional_boolean_field(encoding, 'active'); + assert_optional_unsigned_int_field(encoding, 'maxBitrate'); + + assert_optional_string_field(encoding, 'rid'); + assert_optional_number_field(encoding, 'scaleResolutionDownBy'); +} + +/* + dictionary RTCRtcpParameters { + [readonly] + DOMString cname; + + [readonly] + boolean reducedSize; + }; + */ +function validateRtcpParameters(rtcp) { + assert_optional_string_field(rtcp, 'cname'); + assert_optional_boolean_field(rtcp, 'reducedSize'); +} + +/* + dictionary RTCRtpHeaderExtensionParameters { + [readonly] + DOMString uri; + + [readonly] + unsigned short id; + + [readonly] + boolean encrypted; + }; + */ +function validateHeaderExtensionParameters(headerExt) { + assert_optional_string_field(headerExt, 'uri'); + assert_optional_unsigned_int_field(headerExt, 'id'); + assert_optional_boolean_field(headerExt, 'encrypted'); +} + +/* + dictionary RTCRtpCodecParameters { + [readonly] + unsigned short payloadType; + + [readonly] + DOMString mimeType; + + [readonly] + unsigned long clockRate; + + [readonly] + unsigned short channels; + + [readonly] + DOMString sdpFmtpLine; + }; + */ +function validateCodecParameters(codec) { + assert_optional_unsigned_int_field(codec, 'payloadType'); + assert_optional_string_field(codec, 'mimeType'); + assert_optional_unsigned_int_field(codec, 'clockRate'); + assert_optional_unsigned_int_field(codec, 'channels'); + assert_optional_string_field(codec, 'sdpFmtpLine'); +} + +// Helper function to test that modifying an encoding field should succeed +function test_modified_encoding(kind, field, value1, value2, desc) { + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { + sender + } = pc.addTransceiver(kind, { + sendEncodings: [{ + [field]: value1 + }] + }); + await doOfferAnswerExchange(t, pc); + + const param1 = sender.getParameters(); + validateSenderRtpParameters(param1); + const encoding1 = param1.encodings[0]; + + assert_equals(encoding1[field], value1); + encoding1[field] = value2; + + await sender.setParameters(param1); + const param2 = sender.getParameters(); + validateSenderRtpParameters(param2); + const encoding2 = param2.encodings[0]; + assert_equals(encoding2[field], value2); + }, desc + ' with RTCRtpTransceiverInit'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { + sender + } = pc.addTransceiver(kind); + await doOfferAnswerExchange(t, pc); + + const initParam = sender.getParameters(); + validateSenderRtpParameters(initParam); + initParam.encodings[0][field] = value1; + await sender.setParameters(initParam); + + const param1 = sender.getParameters(); + validateSenderRtpParameters(param1); + const encoding1 = param1.encodings[0]; + + assert_equals(encoding1[field], value1); + encoding1[field] = value2; + + await sender.setParameters(param1); + const param2 = sender.getParameters(); + validateSenderRtpParameters(param2); + const encoding2 = param2.encodings[0]; + assert_equals(encoding2[field], value2); + }, desc + ' without RTCRtpTransceiverInit'); +} diff --git a/testing/web-platform/tests/webrtc/RTCRtpParameters-rtcp.html b/testing/web-platform/tests/webrtc/RTCRtpParameters-rtcp.html new file mode 100644 index 0000000000..7965304520 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpParameters-rtcp.html @@ -0,0 +1,104 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpParameters rtcp</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="dictionary-helper.js"></script> +<script src="RTCRtpParameters-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCRtpParameters-helper.js: + // validateSenderRtpParameters + + /* + 5.2. RTCRtpSender Interface + interface RTCRtpSender { + Promise<void> setParameters(optional RTCRtpParameters parameters); + RTCRtpParameters getParameters(); + }; + + dictionary RTCRtpParameters { + DOMString transactionId; + sequence<RTCRtpEncodingParameters> encodings; + sequence<RTCRtpHeaderExtensionParameters> headerExtensions; + RTCRtcpParameters rtcp; + sequence<RTCRtpCodecParameters> codecs; + }; + + dictionary RTCRtcpParameters { + [readonly] + DOMString cname; + + [readonly] + boolean reducedSize; + }; + + getParameters + - rtcp.cname is set to the CNAME of the associated RTCPeerConnection. + + rtcp.reducedSize is set to true if reduced-size RTCP has been negotiated for + sending, and false otherwise. + */ + + /* + 5.2. setParameters + 7. If parameters.encodings.length is different from N, or if any parameter + in the parameters argument, marked as a Read-only parameter, has a value + that is different from the corresponding parameter value returned from + sender.getParameters(), abort these steps and return a promise rejected + with a newly created InvalidModificationError. Note that this also applies + to transactionId. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + + const param = sender.getParameters(); + validateSenderRtpParameters(param); + + const { rtcp } = param; + + if(rtcp === undefined) { + param.rtcp = { cname: 'foo' }; + + } else if(rtcp.cname === undefined) { + rtcp.cname = 'foo'; + + } else { + rtcp.cname = `${rtcp.cname}-modified`; + } + + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, `setParameters() with modified rtcp.cname should reject with InvalidModificationError`); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + + const param = sender.getParameters(); + validateSenderRtpParameters(param); + + const { rtcp } = param; + + if(rtcp === undefined) { + param.rtcp = { reducedSize: true }; + + } else if(rtcp.reducedSize === undefined) { + rtcp.reducedSize = true; + + } else { + rtcp.reducedSize = !rtcp.reducedSize; + } + + return promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, `setParameters() with modified rtcp.reducedSize should reject with InvalidModificationError`); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpParameters-transactionId.html b/testing/web-platform/tests/webrtc/RTCRtpParameters-transactionId.html new file mode 100644 index 0000000000..a0fa0fab25 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpParameters-transactionId.html @@ -0,0 +1,190 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpParameters transactionId</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="dictionary-helper.js"></script> +<script src="RTCRtpParameters-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + /* + 5.1. RTCPeerConnection Interface Extensions + partial interface RTCPeerConnection { + RTCRtpTransceiver addTransceiver((MediaStreamTrack or DOMString) trackOrKind, + optional RTCRtpTransceiverInit init); + ... + }; + + dictionary RTCRtpTransceiverInit { + RTCRtpTransceiverDirection direction = "sendrecv"; + sequence<MediaStream> streams; + sequence<RTCRtpEncodingParameters> sendEncodings; + }; + + addTransceiver + 2. If the dictionary argument is present, and it has a sendEncodings member, + let sendEncodings be that list of RTCRtpEncodingParameters objects, or an + empty list otherwise. + 7. Create an RTCRtpSender with track, streams and sendEncodings and let + sender be the result. + + 5.2. RTCRtpSender Interface + interface RTCRtpSender { + Promise<void> setParameters(optional RTCRtpParameters parameters); + RTCRtpParameters getParameters(); + }; + + dictionary RTCRtpParameters { + DOMString transactionId; + sequence<RTCRtpEncodingParameters> encodings; + sequence<RTCRtpHeaderExtensionParameters> headerExtensions; + RTCRtcpParameters rtcp; + sequence<RTCRtpCodecParameters> codecs; + }; + + getParameters + - transactionId is set to a new unique identifier, used to match this + getParameters call to a setParameters call that may occur later. + */ + + /* + 5.2. getParameters + - transactionId is set to a new unique identifier, used to match this + getParameters call to a setParameters call that may occur later. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + + const param1 = sender.getParameters(); + const param2 = sender.getParameters(); + assert_equals(typeof param1.transactionId, "string"); + assert_greater_than(param1.transactionId.length, 0); + assert_equals(typeof param2.transactionId, "string"); + assert_greater_than(param2.transactionId.length, 0); + // Don't assert_equals() because the transcation ID is different on each run + // which makes the -expected.txt baseline different each failed run. + assert_true(param1.transactionId == param2.transactionId); + + await undefined; + const param3 = sender.getParameters(); + assert_equals(typeof param3.transactionId, "string"); + assert_greater_than(param3.transactionId.length, 0); + assert_equals(param1.transactionId, param3.transactionId); + }, `sender.getParameters() should return the same transaction ID if called back-to-back without relinquishing the event loop, even if the microtask queue runs`); + + test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + + const param1 = sender.getParameters(); + sender.setParameters(param1); + const param2 = sender.getParameters(); + assert_equals(typeof param1.transactionId, "string"); + assert_greater_than(param1.transactionId.length, 0); + assert_equals(typeof param2.transactionId, "string"); + assert_greater_than(param2.transactionId.length, 0); + + // Don't assert_equals() because the transcation ID is different on each run + // which makes the -expected.txt baseline different each failed run. + assert_true(param1.transactionId == param2.transactionId); + }, `sender.getParameters() should return the same transaction ID if called back-to-back without relinquishing the event loop, even if there is an intervening call to setParameters`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + + const param1 = sender.getParameters(); + await pc.createOffer(); + const param2 = sender.getParameters(); + assert_equals(typeof param1.transactionId, "string"); + assert_greater_than(param1.transactionId.length, 0); + assert_equals(typeof param2.transactionId, "string"); + assert_greater_than(param2.transactionId.length, 0); + + assert_not_equals(param1.transactionId, param2.transactionId); + }, `sender.getParameters() should return a different transaction ID if the event loop is relinquished between multiple calls`); + + /* + 5.2. setParameters + 7. If parameters.encodings.length is different from N, or if any parameter + in the parameters argument, marked as a Read-only parameter, has a value + that is different from the corresponding parameter value returned from + sender.getParameters(), abort these steps and return a promise rejected + with a newly created InvalidModificationError. Note that this also applies + to transactionId. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + + const param = sender.getParameters(); + + const { transactionId } = param; + param.transactionId = `${transactionId}-modified`; + + await promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param)); + }, `sender.setParameters() with transaction ID different from last getParameters() should reject with InvalidModificationError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + + const param = sender.getParameters(); + + delete param.transactionId; + + await promise_rejects_js(t, TypeError, + sender.setParameters(param)); + }, `sender.setParameters() with transaction ID unset should reject with TypeError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + + const param = sender.getParameters(); + + await sender.setParameters(param); + await promise_rejects_dom(t, 'InvalidStateError', sender.setParameters(param)) + }, `setParameters() twice with the same parameters should reject with InvalidStateError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + + const param1 = sender.getParameters(); + // Queue a task, does not really matter what kind + await pc.createOffer(); + const param2 = sender.getParameters(); + + assert_not_equals(param1.transactionId, param2.transactionId); + + await promise_rejects_dom(t, 'InvalidModificationError', + sender.setParameters(param1)); + }, `setParameters() with parameters older than last getParameters() should reject with InvalidModificationError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const { sender } = pc.addTransceiver('audio'); + + const param1 = sender.getParameters(); + await pc.createOffer(); + + await promise_rejects_dom(t, 'InvalidStateError', + sender.setParameters(param1)); + }, `setParameters() when the event loop has been relinquished since the last getParameters() should reject with InvalidStateError`); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpReceiver-getCapabilities.html b/testing/web-platform/tests/webrtc/RTCRtpReceiver-getCapabilities.html new file mode 100644 index 0000000000..21dcae208a --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpReceiver-getCapabilities.html @@ -0,0 +1,39 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpReceiver.getCapabilities</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="dictionary-helper.js"></script> +<script src="RTCRtpCapabilities-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCRtpCapabilities-helper.js: + // validateRtpCapabilities + + /* + 5.3. RTCRtpReceiver Interface + interface RTCRtpReceiver { + ... + static RTCRtpCapabilities getCapabilities(DOMString kind); + }; + */ + test(() => { + const capabilities = RTCRtpReceiver.getCapabilities('audio'); + validateRtpCapabilities(capabilities); + }, `RTCRtpSender.getCapabilities('audio') should return RTCRtpCapabilities dictionary`); + + test(() => { + const capabilities = RTCRtpReceiver.getCapabilities('video'); + validateRtpCapabilities(capabilities); + }, `RTCRtpSender.getCapabilities('video') should return RTCRtpCapabilities dictionary`); + + test(() => { + const capabilities = RTCRtpReceiver.getCapabilities('dummy'); + assert_equals(capabilities, null); + }, `RTCRtpSender.getCapabilities('dummy') should return null`); + + </script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpReceiver-getContributingSources.https.html b/testing/web-platform/tests/webrtc/RTCRtpReceiver-getContributingSources.https.html new file mode 100644 index 0000000000..7245d477cc --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpReceiver-getContributingSources.https.html @@ -0,0 +1,35 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpReceiver.prototype.getContributingSources</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +async function connectAndExpectNoCsrcs(t, kind) { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({[kind]:true}); + const [track] = stream.getTracks(); + t.add_cleanup(() => track.stop()); + pc1.addTrack(track, stream); + + exchangeIceCandidates(pc1, pc2); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + await exchangeAnswer(pc1, pc2); + + assert_array_equals(trackEvent.receiver.getContributingSources(), []); +} + +promise_test(async t => { + await connectAndExpectNoCsrcs(t, 'audio'); +}, '[audio] getContributingSources() returns an empty list in loopback call'); + +promise_test(async t => { + await connectAndExpectNoCsrcs(t, 'video'); +}, '[video] getContributingSources() returns an empty list in loopback call'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpReceiver-getParameters.html b/testing/web-platform/tests/webrtc/RTCRtpReceiver-getParameters.html new file mode 100644 index 0000000000..7047ce7d1f --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpReceiver-getParameters.html @@ -0,0 +1,73 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpReceiver.prototype.getParameters</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="dictionary-helper.js"></script> +<script src="RTCRtpParameters-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCRtpParameters-helper.js: + // validateReceiverRtpParameters + + /* + Validates the RTCRtpParameters returned from RTCRtpReceiver.prototype.getParameters + + 5.3. RTCRtpReceiver Interface + getParameters + When getParameters is called, the RTCRtpParameters dictionary is constructed + as follows: + + - The headerExtensions sequence is populated based on the header extensions that + the receiver is currently prepared to receive. + + - The codecs sequence is populated based on the codecs that the receiver is currently + prepared to receive. + + - rtcp.reducedSize is set to true if the receiver is currently prepared to receive + reduced-size RTCP packets, and false otherwise. rtcp.cname is left undefined. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + pc.addTransceiver('audio'); + const callee = await doOfferAnswerExchange(t, pc); + const param = callee.getTransceivers()[0].receiver.getParameters(); + validateReceiverRtpParameters(param); + + assert_greater_than(param.headerExtensions.length, 0); + assert_greater_than(param.codecs.length, 0); + }, 'getParameters() with audio receiver'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + pc.addTransceiver('video'); + const callee = await doOfferAnswerExchange(t, pc); + const param = callee.getTransceivers()[0].receiver.getParameters(); + validateReceiverRtpParameters(param); + + assert_greater_than(param.headerExtensions.length, 0); + assert_greater_than(param.codecs.length, 0); + }, 'getParameters() with video receiver'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + pc.addTransceiver('video', { + sendEncodings: [ + { rid: "rid1" }, + { rid: "rid2" } + ] + }); + const callee = await doOfferAnswerExchange(t, pc); + const param = callee.getTransceivers()[0].receiver.getParameters(); + validateReceiverRtpParameters(param); + assert_greater_than(param.headerExtensions.length, 0); + assert_greater_than(param.codecs.length, 0); + }, 'getParameters() with simulcast video receiver'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpReceiver-getStats.https.html b/testing/web-platform/tests/webrtc/RTCRtpReceiver-getStats.https.html new file mode 100644 index 0000000000..39948ed6f7 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpReceiver-getStats.https.html @@ -0,0 +1,145 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCRtpReceiver.prototype.getStats</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script src="dictionary-helper.js"></script> +<script src="RTCStats-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + // https://w3c.github.io/webrtc-stats/archives/20170614/webrtc-stats.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // exchangeOfferAnswer + + // The following helper function is called from RTCStats-helper.js + // validateStatsReport + // assert_stats_report_has_stats + + /* + 5.3. RTCRtpReceiver Interface + interface RTCRtpReceiver { + Promise<RTCStatsReport> getStats(); + ... + }; + + getStats + 1. Let selector be the RTCRtpReceiver object on which the method was invoked. + 2. Let p be a new promise, and run the following steps in parallel: + 1. Gather the stats indicated by selector according to the stats selection + algorithm. + 2. Resolve p with the resulting RTCStatsReport object, containing the + gathered stats. + 3. Return p. + + 8.5. The stats selection algorithm + 4. If selector is an RTCRtpReceiver, gather stats for and add the following objects + to result: + - All RTCInboundRtpStreamStats objects corresponding to selector. + - All stats objects referenced directly or indirectly by the RTCInboundRtpStreamStats + added. + */ + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream = await getNoiseStream({audio:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + callee.addTrack(track, stream); + + const { receiver } = caller.addTransceiver('audio'); + + await exchangeOfferAnswer(caller, callee); + const statsReport = await receiver.getStats(); + validateStatsReport(statsReport); + assert_stats_report_has_stats(statsReport, ['inbound-rtp']); + }, 'receiver.getStats() via addTransceiver should return stats report containing inbound-rtp stats'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await getNoiseStream({audio:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + caller.addTrack(track, stream); + + await exchangeOfferAnswer(caller, callee); + const receiver = callee.getReceivers()[0]; + const statsReport = await receiver.getStats(); + validateStatsReport(statsReport); + assert_stats_report_has_stats(statsReport, ['inbound-rtp']); + }, 'receiver.getStats() via addTrack should return stats report containing inbound-rtp stats'); + + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await getNoiseStream({audio:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + caller.addTrack(track, stream); + + await exchangeOfferAnswer(caller, callee); + const [receiver] = callee.getReceivers(); + const [transceiver] = callee.getTransceivers(); + const statsPromiseFirst = receiver.getStats(); + transceiver.stop(); + const statsReportFirst = await statsPromiseFirst; + const statsReportSecond = await receiver.getStats(); + validateStatsReport(statsReportFirst); + validateStatsReport(statsReportSecond); + }, 'receiver.getStats() should work on a stopped transceiver'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await getNoiseStream({audio:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + caller.addTrack(track, stream); + + await exchangeOfferAnswer(caller, callee); + const [receiver] = callee.getReceivers(); + const statsPromiseFirst = receiver.getStats(); + callee.close(); + const statsReportFirst = await statsPromiseFirst; + const statsReportSecond = await receiver.getStats(); + validateStatsReport(statsReportFirst); + validateStatsReport(statsReportSecond); + }, 'receiver.getStats() should work with a closed PeerConnection'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await getNoiseStream({audio:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + caller.addTrack(track, stream); + + exchangeIceCandidates(caller, callee); + exchangeIceCandidates(callee, caller); + await exchangeOfferAnswer(caller, callee); + await waitForIceStateChange(callee, ['connected', 'completed']); + const receiver = callee.getReceivers()[0]; + const statsReport = await receiver.getStats(); + assert_stats_report_has_stats(statsReport, ['candidate-pair', 'local-candidate', 'remote-candidate']); + }, 'receiver.getStats() should return stats report containing ICE candidate stats'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpReceiver-getSynchronizationSources.https.html b/testing/web-platform/tests/webrtc/RTCRtpReceiver-getSynchronizationSources.https.html new file mode 100644 index 0000000000..8436a44ebc --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpReceiver-getSynchronizationSources.https.html @@ -0,0 +1,105 @@ +<!doctype html> +<meta charset=utf-8> +<!-- This file contains two tests that wait for 10 seconds each. --> +<meta name="timeout" content="long"> +<title>RTCRtpReceiver.prototype.getSynchronizationSources</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +async function initiateSingleTrackCallAndReturnReceiver(t, kind) { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({[kind]:true}); + const [track] = stream.getTracks(); + t.add_cleanup(() => track.stop()); + pc1.addTrack(track, stream); + + exchangeIceCandidates(pc1, pc2); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + await exchangeAnswer(pc1, pc2); + return trackEvent.receiver; +} + +for (const kind of ['audio', 'video']) { + promise_test(async t => { + const receiver = await initiateSingleTrackCallAndReturnReceiver(t, kind); + await listenForSSRCs(t, receiver); + }, '[' + kind + '] getSynchronizationSources() eventually returns a ' + + 'non-empty list'); + + promise_test(async t => { + const startTime = performance.now(); + const receiver = await initiateSingleTrackCallAndReturnReceiver(t, kind); + const [ssrc] = await listenForSSRCs(t, receiver); + assert_equals(typeof ssrc.timestamp, 'number'); + assert_true(ssrc.timestamp >= startTime); + }, '[' + kind + '] RTCRtpSynchronizationSource.timestamp is a number'); + + promise_test(async t => { + const receiver = await initiateSingleTrackCallAndReturnReceiver(t, kind); + const [ssrc] = await listenForSSRCs(t, receiver); + assert_equals(typeof ssrc.rtpTimestamp, 'number'); + assert_greater_than_equal(ssrc.rtpTimestamp, 0); + assert_less_than_equal(ssrc.rtpTimestamp, 0xffffffff); + }, '[' + kind + '] RTCRtpSynchronizationSource.rtpTimestamp is a number ' + + '[0, 2^32-1]'); + + promise_test(async t => { + const receiver = await initiateSingleTrackCallAndReturnReceiver(t, kind); + // Wait for packets to start flowing. + await listenForSSRCs(t, receiver); + // Wait for 10 seconds. + await new Promise(resolve => t.step_timeout(resolve, 10000)); + let earliestTimestamp = undefined; + let latestTimestamp = undefined; + for (const ssrc of await listenForSSRCs(t, receiver)) { + if (earliestTimestamp == undefined || earliestTimestamp > ssrc.timestamp) + earliestTimestamp = ssrc.timestamp; + if (latestTimestamp == undefined || latestTimestamp < ssrc.timestamp) + latestTimestamp = ssrc.timestamp; + } + assert_true(latestTimestamp - earliestTimestamp <= 10000); + }, '[' + kind + '] getSynchronizationSources() does not contain SSRCs ' + + 'older than 10 seconds'); + + promise_test(async t => { + const startTime = performance.timeOrigin + performance.now(); + const receiver = await initiateSingleTrackCallAndReturnReceiver(t, kind); + const [ssrc] = await listenForSSRCs(t, receiver); + const endTime = performance.timeOrigin + performance.now(); + assert_true(startTime <= ssrc.timestamp && ssrc.timestamp <= endTime); + }, '[' + kind + '] RTCRtpSynchronizationSource.timestamp is comparable to ' + + 'performance.timeOrigin + performance.now()'); + + promise_test(async t => { + const receiver = await initiateSingleTrackCallAndReturnReceiver(t, kind); + const [ssrc] = await listenForSSRCs(t, receiver); + assert_equals(typeof ssrc.source, 'number'); + }, '[' + kind + '] RTCRtpSynchronizationSource.source is a number'); +} + +promise_test(async t => { + const receiver = await initiateSingleTrackCallAndReturnReceiver(t, 'audio'); + const [ssrc] = await listenForSSRCs(t, receiver); + assert_equals(typeof ssrc.audioLevel, 'number'); + assert_greater_than_equal(ssrc.audioLevel, 0); + assert_less_than_equal(ssrc.audioLevel, 1); +}, '[audio-only] RTCRtpSynchronizationSource.audioLevel is a number [0, 1]'); + +// This test only passes if the implementation is sending the RFC 6464 extension +// header and the "vad" extension attribute is not "off", otherwise +// voiceActivityFlag is absent. TODO: Consider moving this test to an +// optional-to-implement subfolder? +promise_test(async t => { + const receiver = await initiateSingleTrackCallAndReturnReceiver(t, 'audio'); + const [ssrc] = await listenForSSRCs(t, receiver); + assert_equals(typeof ssrc.voiceActivityFlag, 'boolean'); +}, '[audio-only] RTCRtpSynchronizationSource.voiceActivityFlag is a boolean'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpSender-encode-same-track-twice.https.html b/testing/web-platform/tests/webrtc/RTCRtpSender-encode-same-track-twice.https.html new file mode 100644 index 0000000000..568543da70 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpSender-encode-same-track-twice.https.html @@ -0,0 +1,66 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title></title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // A generous testing duration that will not time out on bots. + const kEncodeDurationMs = 10000; + + // The crash this test aims to repro was easy to reproduce using a normal + // getUserMedia() track when running the browser normally, e.g. by navigating + // to https://jsfiddle.net/henbos/fc7gk3ve/11/. But for some reason, the fake + // tracks returned by getUserMedia() when inside this testing environment had + // a much harder time with reproducibility. + // + // By creating a high FPS canvas capture track we are able to repro reliably + // in this WPT environment as well. + function whiteNoise(width, height) { + const canvas = + Object.assign(document.createElement('canvas'), {width, height}); + const ctx = canvas.getContext('2d'); + ctx.fillRect(0, 0, width, height); + const p = ctx.getImageData(0, 0, width, height); + requestAnimationFrame(function draw () { + for (let i = 0; i < p.data.length; i++) { + const color = Math.random() * 255; + p.data[i++] = color; + p.data[i++] = color; + p.data[i++] = color; + } + ctx.putImageData(p, 0, 0); + requestAnimationFrame(draw); + }); + return canvas.captureStream(); + } + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = whiteNoise(640, 480); + const [track] = stream.getTracks(); + const t1 = pc1.addTransceiver("video", {direction:"sendonly"}); + const t2 = pc1.addTransceiver("video", {direction:"sendonly"}); + await t1.sender.replaceTrack(track); + await t2.sender.replaceTrack(track); + + exchangeIceCandidates(pc1, pc2); + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + + // In Chromium, each sender instantiates a VideoStreamEncoder during + // negotiation. This test reproduces https://crbug.com/webrtc/11485 where a + // race causes a crash when multiple VideoStreamEncoders are encoding the + // same MediaStreamTrack. + await new Promise(resolve => t.step_timeout(resolve, kEncodeDurationMs)); + }, "Two RTCRtpSenders encoding the same track"); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpSender-getCapabilities.html b/testing/web-platform/tests/webrtc/RTCRtpSender-getCapabilities.html new file mode 100644 index 0000000000..3d41c62016 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpSender-getCapabilities.html @@ -0,0 +1,45 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpSender.getCapabilities</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="dictionary-helper.js"></script> +<script src="RTCRtpCapabilities-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + // The following helper functions are called from RTCRtpCapabilities-helper.js: + // validateRtpCapabilities + + /* + 5.2. RTCRtpSender Interface + interface RTCRtpSender { + ... + static RTCRtpCapabilities getCapabilities(DOMString kind); + }; + + getCapabilities + The getCapabilities() method returns the most optimist view on the capabilities + of the system for sending media of the given kind. It does not reserve any + resources, ports, or other state but is meant to provide a way to discover + the types of capabilities of the browser including which codecs may be supported. + */ + test(() => { + const capabilities = RTCRtpSender.getCapabilities('audio'); + validateRtpCapabilities(capabilities); + }, `RTCRtpSender.getCapabilities('audio') should return RTCRtpCapabilities dictionary`); + + test(() => { + const capabilities = RTCRtpSender.getCapabilities('video'); + validateRtpCapabilities(capabilities); + }, `RTCRtpSender.getCapabilities('video') should return RTCRtpCapabilities dictionary`); + + test(() => { + const capabilities = RTCRtpSender.getCapabilities('dummy'); + assert_equals(capabilities, null); + }, `RTCRtpSender.getCapabilities('dummy') should return null`); + + </script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpSender-getStats.https.html b/testing/web-platform/tests/webrtc/RTCRtpSender-getStats.https.html new file mode 100644 index 0000000000..62c01aafa6 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpSender-getStats.https.html @@ -0,0 +1,97 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCRtpSender.prototype.getStats</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script src="dictionary-helper.js"></script> +<script src="RTCStats-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // webrtc-pc 20171130 + // webrtc-stats 20171122 + + // The following helper functions are called from RTCPeerConnection-helper.js: + // exchangeOfferAnswer + + // The following helper function is called from RTCStats-helper.js + // validateStatsReport + // assert_stats_report_has_stats + + /* + 5.2. RTCRtpSender Interface + getStats + 1. Let selector be the RTCRtpSender object on which the method was invoked. + 2. Let p be a new promise, and run the following steps in parallel: + 1. Gather the stats indicated by selector according to the stats selection + algorithm. + 2. Resolve p with the resulting RTCStatsReport object, containing the + gathered stats. + 3. Return p. + + 8.5. The stats selection algorithm + 3. If selector is an RTCRtpSender, gather stats for and add the following objects + to result: + - All RTCOutboundRtpStreamStats objects corresponding to selector. + - All stats objects referenced directly or indirectly by the RTCOutboundRtpStreamStats + objects added. + */ + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream = await getNoiseStream({audio:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const { sender } = caller.addTransceiver(track); + + await exchangeOfferAnswer(caller, callee); + const statsReport = await sender.getStats(); + validateStatsReport(statsReport); + assert_stats_report_has_stats(statsReport, ['outbound-rtp']); + }, 'sender.getStats() via addTransceiver should return stats report containing outbound-rtp stats'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await getNoiseStream({audio:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const sender = caller.addTrack(track, stream); + + await exchangeOfferAnswer(caller, callee); + const statsReport = await sender.getStats(); + validateStatsReport(statsReport); + assert_stats_report_has_stats(statsReport, ['outbound-rtp']); + }, 'sender.getStats() via addTrack should return stats report containing outbound-rtp stats'); + + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await getNoiseStream({audio:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const { sender } = caller.addTransceiver(track); + + exchangeIceCandidates(caller, callee); + exchangeIceCandidates(callee, caller); + await exchangeOfferAnswer(caller, callee); + // Pairing should be possible as soon as we are 'checking', but to allow the + // pairing to happen asynchronously, we wait until 'connected' or + // 'completed' instead as it is not possible to reach these without a pair. + await waitForIceStateChange(caller, ['connected', 'completed']); + const statsReport = await sender.getStats(); + assert_stats_report_has_stats(statsReport, ['candidate-pair', 'local-candidate', 'remote-candidate']); + }, 'sender.getStats() should return stats report containing ICE candidate stats'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpSender-replaceTrack.https.html b/testing/web-platform/tests/webrtc/RTCRtpSender-replaceTrack.https.html new file mode 100644 index 0000000000..bec44c53e4 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpSender-replaceTrack.https.html @@ -0,0 +1,338 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCRtpSender.prototype.replaceTrack</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + /* + 5.2. RTCRtpSender Interface + interface RTCRtpSender { + readonly attribute MediaStreamTrack? track; + Promise<void> replaceTrack(MediaStreamTrack? withTrack); + ... + }; + + replaceTrack + Attempts to replace the track being sent with another track provided + (or with a null track), without renegotiation. + */ + + /* + 5.2. replaceTrack + 4. If connection's [[isClosed]] slot is true, return a promise rejected + with a newly created InvalidStateError and abort these steps. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + const transceiver = pc.addTransceiver('audio'); + const { sender } = transceiver; + pc.close(); + + return promise_rejects_dom(t, 'InvalidStateError', + sender.replaceTrack(track)); + }, 'Calling replaceTrack on closed connection should reject with InvalidStateError'); + + /* + 5.2. replaceTrack + 7. If withTrack is non-null and withTrack.kind differs from the + transceiver kind of transceiver, return a promise rejected with a + newly created TypeError. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + const transceiver = pc.addTransceiver('audio'); + const { sender } = transceiver; + + return promise_rejects_js(t, TypeError, + sender.replaceTrack(track)); + }, 'Calling replaceTrack with track of different kind should reject with TypeError'); + + /* + 5.2. replaceTrack + 5. If transceiver.stopped is true, return a promise rejected with a newly + created InvalidStateError. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + const transceiver = pc.addTransceiver('audio'); + const { sender } = transceiver; + transceiver.stop(); + + return promise_rejects_dom(t, 'InvalidStateError', + sender.replaceTrack(track)); + }, 'Calling replaceTrack on stopped sender should reject with InvalidStateError'); + + /* + 5.2. replaceTrack + 8. If transceiver is not yet associated with a media description [JSEP] + (section 3.4.1.), then set sender's track attribute to withTrack, and + return a promise resolved with undefined. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + const transceiver = pc.addTransceiver('audio'); + const { sender } = transceiver; + assert_equals(sender.track, null); + + return sender.replaceTrack(track) + .then(() => { + assert_equals(sender.track, track); + }); + }, 'Calling replaceTrack on sender with null track and not set to session description should resolve with sender.track set to given track'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream1 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream1.getTracks().forEach(track => track.stop())); + const [track1] = stream1.getTracks(); + const stream2 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + const [track2] = stream2.getTracks(); + + const transceiver = pc.addTransceiver(track1); + const { sender } = transceiver; + + assert_equals(sender.track, track1); + + return sender.replaceTrack(track2) + .then(() => { + assert_equals(sender.track, track2); + }); + }, 'Calling replaceTrack on sender not set to session description should resolve with sender.track set to given track'); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + const transceiver = pc.addTransceiver(track); + const { sender } = transceiver; + + assert_equals(sender.track, track); + + return sender.replaceTrack(null) + .then(() => { + assert_equals(sender.track, null); + }); + }, 'Calling replaceTrack(null) on sender not set to session description should resolve with sender.track set to null'); + + /* + 5.2. replaceTrack + 10. Run the following steps in parallel: + 1. Determine if negotiation is needed to transmit withTrack in place + of the sender's existing track. + + Negotiation is not needed if withTrack is null. + + 3. Queue a task that runs the following steps: + 2. Set sender's track attribute to withTrack. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + const transceiver = pc.addTransceiver(track); + const { sender } = transceiver; + + assert_equals(sender.track, track); + + return pc.createOffer() + .then(offer => pc.setLocalDescription(offer)) + .then(() => sender.replaceTrack(null)) + .then(() => { + assert_equals(sender.track, null); + }); + }, 'Calling replaceTrack(null) on sender set to session description should resolve with sender.track set to null'); + + /* + 5.2. replaceTrack + 10. Run the following steps in parallel: + 1. Determine if negotiation is needed to transmit withTrack in place + of the sender's existing track. + + Negotiation is not needed if the sender's existing track is + ended (which appears as though the track was muted). + + 3. Queue a task that runs the following steps: + 2. Set sender's track attribute to withTrack. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream1 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream1.getTracks().forEach(track => track.stop())); + const [track1] = stream1.getTracks(); + const stream2 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + const [track2] = stream1.getTracks(); + + const transceiver = pc.addTransceiver(track1); + const { sender } = transceiver; + assert_equals(sender.track, track1); + + track1.stop(); + + return pc.createOffer() + .then(offer => pc.setLocalDescription(offer)) + .then(() => sender.replaceTrack(track2)) + .then(() => { + assert_equals(sender.track, track2); + }); + }, 'Calling replaceTrack on sender with stopped track and and set to session description should resolve with sender.track set to given track'); + + /* + 5.2. replaceTrack + 10. Run the following steps in parallel: + 1. Determine if negotiation is needed to transmit withTrack in place + of the sender's existing track. + + (tracks generated with default parameters *should* be similar + enough to not require re-negotiation) + + 3. Queue a task that runs the following steps: + 2. Set sender's track attribute to withTrack. + */ + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream1 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream1.getTracks().forEach(track => track.stop())); + const [track1] = stream1.getTracks(); + const stream2 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + const [track2] = stream1.getTracks(); + + const transceiver = pc.addTransceiver(track1); + const { sender } = transceiver; + assert_equals(sender.track, track1); + + return pc.createOffer() + .then(offer => pc.setLocalDescription(offer)) + .then(() => sender.replaceTrack(track2)) + .then(() => { + assert_equals(sender.track, track2); + }); + }, 'Calling replaceTrack on sender with similar track and and set to session description should resolve with sender.track set to new track'); + + /* + TODO + 5.2. replaceTrack + To avoid track identifiers changing on the remote receiving end when + a track is replaced, the sender must retain the original track + identifier and stream associations and use these in subsequent + negotiations. + + Non-Testable + 5.2. replaceTrack + 10. Run the following steps in parallel: + 1. Determine if negotiation is needed to transmit withTrack in place + of the sender's existing track. + + Ignore which MediaStream the track resides in and the id attribute + of the track in this determination. + + If negotiation is needed, then reject p with a newly created + InvalidModificationError and abort these steps. + + 2. If withTrack is null, have the sender stop sending, without + negotiating. Otherwise, have the sender switch seamlessly to + transmitting withTrack instead of the sender's existing track, + without negotiating. + 3. Queue a task that runs the following steps: + 1. If connection's [[isClosed]] slot is true, abort these steps. + */ + + promise_test(async t => { + const v = document.createElement('video'); + v.autoplay = true; + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + const stream1 = await getNoiseStream({video: {signal: 20}}); + t.add_cleanup(() => stream1.getTracks().forEach(track => track.stop())); + const [track1] = stream1.getTracks(); + const stream2 = await getNoiseStream({video: {signal: 250}}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + const [track2] = stream2.getTracks(); + const sender = pc1.addTrack(track1); + pc2.ontrack = (e) => { + v.srcObject = new MediaStream([e.track]); + }; + const metadataToBeLoaded = new Promise((resolve) => { + v.addEventListener('loadedmetadata', () => { + resolve(); + }); + }); + exchangeIceCandidates(pc1, pc2); + exchangeOfferAnswer(pc1, pc2); + await metadataToBeLoaded; + await detectSignal(t, v, 20); + await sender.replaceTrack(track2); + await detectSignal(t, v, 250); + }, 'ReplaceTrack transmits the new track not the old track'); + + promise_test(async t => { + const v = document.createElement('video'); + v.autoplay = true; + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + const stream1 = await getNoiseStream({video: {signal: 20}}); + t.add_cleanup(() => stream1.getTracks().forEach(track => track.stop())); + const [track1] = stream1.getTracks(); + const stream2 = await getNoiseStream({video: {signal: 250}}); + t.add_cleanup(() => stream2.getTracks().forEach(track => track.stop())); + const [track2] = stream2.getTracks(); + const sender = pc1.addTrack(track1); + pc2.ontrack = (e) => { + v.srcObject = new MediaStream([e.track]); + }; + const metadataToBeLoaded = new Promise((resolve) => { + v.addEventListener('loadedmetadata', () => { + resolve(); + }); + }); + exchangeIceCandidates(pc1, pc2); + exchangeOfferAnswer(pc1, pc2); + await metadataToBeLoaded; + await detectSignal(t, v, 20); + await sender.replaceTrack(null); + await sender.replaceTrack(track2); + await detectSignal(t, v, 250); + }, 'ReplaceTrack null -> new track transmits the new track'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpSender-setParameters.html b/testing/web-platform/tests/webrtc/RTCRtpSender-setParameters.html new file mode 100644 index 0000000000..94c572343d --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpSender-setParameters.html @@ -0,0 +1,52 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpSender.prototype.setParameters</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + /* + 5.2. setParameters + 6. If transceiver.stopped is true, abort these steps and return a promise + rejected with a newly created InvalidStateError. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const { sender } = transceiver; + + const param = sender.getParameters(); + transceiver.stop(); + + return promise_rejects_dom(t, 'InvalidStateError', + sender.setParameters(param)); + }, `setParameters() when transceiver is stopped should reject with InvalidStateError`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const sender = pc.addTransceiver('audio').sender; + const param = sender.getParameters(); + sender.setParameters(param); + await sender.setParameters(param); + }, `setParameters() with already used parameters should work if the event loop has not been relinquished`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const sender = pc.addTransceiver('audio').sender; + const param = sender.getParameters(); + sender.setParameters(param); + await queueAWebrtcTask(); + + await promise_rejects_dom(t, 'InvalidStateError', + sender.setParameters(param)); + }, `setParameters() with already used parameters should reject with InvalidStateError if the event loop has been relinquished`); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpSender-setStreams.https.html b/testing/web-platform/tests/webrtc/RTCRtpSender-setStreams.https.html new file mode 100644 index 0000000000..03ae863d0f --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpSender-setStreams.https.html @@ -0,0 +1,127 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpSender.prototype.setStreams</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + const sender = caller.addTrack(track); + const stream1 = new MediaStream(); + const stream2 = new MediaStream(); + sender.setStreams(stream1, stream2); + + const offer = await caller.createOffer(); + callee.setRemoteDescription(offer); + return new Promise(resolve => callee.ontrack = t.step_func(event =>{ + assert_equals(event.streams.length, 2); + const calleeStreamIds = event.streams.map(s => s.id); + assert_in_array(stream1.id, calleeStreamIds); + assert_in_array(stream2.id, calleeStreamIds); + resolve(); + })); +}, 'setStreams causes streams to be reported via ontrack on callee'); + +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + + const sender = caller.addTrack(track); + sender.setStreams(stream); + + const offer = await caller.createOffer(); + callee.setRemoteDescription(offer); + return new Promise(resolve => callee.ontrack = t.step_func(event =>{ + assert_equals(event.streams.length, 1); + assert_equals(stream.id, event.streams[0].id); + assert_equals(event.streams[0].getTracks()[0], event.track); + resolve(); + })); +}, 'setStreams can be used to reconstruct a stream with a track on the remote side'); + + +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + callee.ontrack = t.unreached_func(); + const transceiver = caller.addTransceiver('audio', {direction: 'inactive'}); + await exchangeOfferAnswer(caller, callee); + + const stream1 = new MediaStream(); + const stream2 = new MediaStream(); + transceiver.direction = 'sendrecv'; + transceiver.sender.setStreams(stream1, stream2); + + const offer = await caller.createOffer(); + callee.setRemoteDescription(offer); + return new Promise(resolve => callee.ontrack = t.step_func(event =>{ + assert_equals(event.streams.length, 2); + const calleeStreamIds = event.streams.map(s => s.id); + assert_in_array(stream1.id, calleeStreamIds); + assert_in_array(stream2.id, calleeStreamIds); + assert_in_array(event.track, event.streams[0].getTracks()); + assert_in_array(event.track, event.streams[1].getTracks()); + resolve(); + })); +}, 'Adding streams and changing direction causes new streams to be reported via ontrack on callee'); + +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + + const stream1 = new MediaStream(); + const stream2 = new MediaStream(); + let calleeTrack = null; + callee.ontrack = t.step_func(event => { + assert_equals(event.streams.length, 0); + calleeTrack = event.track; + }); + const transceiver = caller.addTransceiver('audio', {direction: 'sendrecv'}); + await exchangeOfferAnswer(caller, callee); + assert_true(calleeTrack instanceof MediaStreamTrack); + + transceiver.sender.setStreams(stream1, stream2); + const offer = await caller.createOffer(); + callee.setRemoteDescription(offer); + return new Promise(resolve => callee.ontrack = t.step_func(event =>{ + assert_equals(event.streams.length, 2); + const calleeStreamIds = event.streams.map(s => s.id); + assert_in_array(stream1.id, calleeStreamIds); + assert_in_array(stream2.id, calleeStreamIds); + assert_in_array(event.track, event.streams[0].getTracks()); + assert_in_array(event.track, event.streams[1].getTracks()); + assert_equals(event.track, calleeTrack); + resolve(); + })); +}, 'Adding streams to an active transceiver causes new streams to be reported via ontrack on callee'); + +test(t => { + const pc = new RTCPeerConnection(); + const stream1 = new MediaStream(); + const stream2 = new MediaStream(); + const transceiver = pc.addTransceiver('audio'); + + pc.close(); + assert_throws_dom('InvalidStateError', () => transceiver.sender.setStreams(stream1, stream2)); +}, 'setStreams() fires InvalidStateError on a closed peer connection.'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpSender-transport.https.html b/testing/web-platform/tests/webrtc/RTCRtpSender-transport.https.html new file mode 100644 index 0000000000..cd419ebc18 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpSender-transport.https.html @@ -0,0 +1,152 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCRtpSender.transport</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="dictionary-helper.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Spec link: http://w3c.github.io/webrtc-pc/#dom-rtcrtpsender-transport + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const sender = caller.addTrack(track); + assert_equals(sender.transport, null); + }, 'RTCRtpSender.transport is null when unconnected'); + + // Test for the simple/happy path of connecting a single track + promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track] = stream.getTracks(); + const sender = caller.addTrack(track); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + exchangeIceCandidates(caller, callee); + await exchangeOfferAndListenToOntrack(t, caller, callee); + assert_not_equals(sender.transport, null); + const [transceiver] = caller.getTransceivers(); + assert_equals(transceiver.sender.transport, + transceiver.receiver.transport); + assert_not_equals(sender.transport.iceTransport, null); + }, 'RTCRtpSender/receiver.transport has a value when connected'); + + // Test with multiple tracks, and checking details of when things show up + // for different bundle policies. + for (let bundle_policy of ['balanced', 'max-bundle', 'max-compat']) { + promise_test(async t => { + const caller = new RTCPeerConnection({bundlePolicy: bundle_policy}); + t.add_cleanup(() => caller.close()); + const stream = await getNoiseStream( + {audio: true, video:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track1, track2] = stream.getTracks(); + const sender1 = caller.addTrack(track1); + const sender2 = caller.addTrack(track2); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + exchangeIceCandidates(caller, callee); + const offer = await caller.createOffer(); + assert_equals(sender1.transport, null); + assert_equals(sender2.transport, null); + await caller.setLocalDescription(offer); + assert_not_equals(sender1.transport, null); + assert_not_equals(sender2.transport, null); + const [caller_transceiver1, caller_transceiver2] = caller.getTransceivers(); + assert_equals(sender1.transport, caller_transceiver1.sender.transport); + if (bundle_policy == 'max-bundle') { + assert_equals(caller_transceiver1.sender.transport, + caller_transceiver2.sender.transport); + } else { + assert_not_equals(caller_transceiver1.sender.transport, + caller_transceiver2.sender.transport); + } + await callee.setRemoteDescription(offer); + const [callee_transceiver1, callee_transceiver2] = callee.getTransceivers(); + // According to spec, setRemoteDescription only updates the transports + // if the remote description is an answer. + assert_equals(callee_transceiver1.receiver.transport, null); + assert_equals(callee_transceiver2.receiver.transport, null); + const answer = await callee.createAnswer(); + await callee.setLocalDescription(answer); + assert_not_equals(callee_transceiver1.receiver.transport, null); + assert_not_equals(callee_transceiver2.receiver.transport, null); + // At this point, bundle should have kicked in. + assert_equals(callee_transceiver1.receiver.transport, + callee_transceiver2.receiver.transport); + await caller.setRemoteDescription(answer); + assert_equals(caller_transceiver1.receiver.transport, + caller_transceiver2.receiver.transport); + }, 'RTCRtpSender/receiver.transport at the right time, with bundle policy ' + bundle_policy); + + // Do the same test again, with DataChannel in the mix. + promise_test(async t => { + const caller = new RTCPeerConnection({bundlePolicy: bundle_policy}); + t.add_cleanup(() => caller.close()); + const stream = await getNoiseStream( + {audio: true, video:true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const [track1, track2] = stream.getTracks(); + const sender1 = caller.addTrack(track1); + const sender2 = caller.addTrack(track2); + caller.createDataChannel('datachannel'); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + exchangeIceCandidates(caller, callee); + const offer = await caller.createOffer(); + assert_equals(sender1.transport, null); + assert_equals(sender2.transport, null); + if (caller.sctp) { + assert_equals(caller.sctp.transport, null); + } + await caller.setLocalDescription(offer); + assert_not_equals(sender1.transport, null); + assert_not_equals(sender2.transport, null); + assert_not_equals(caller.sctp.transport, null); + const [caller_transceiver1, caller_transceiver2] = caller.getTransceivers(); + assert_equals(sender1.transport, caller_transceiver1.sender.transport); + if (bundle_policy == 'max-bundle') { + assert_equals(caller_transceiver1.sender.transport, + caller_transceiver2.sender.transport); + assert_equals(caller_transceiver1.sender.transport, + caller.sctp.transport); + } else { + assert_not_equals(caller_transceiver1.sender.transport, + caller_transceiver2.sender.transport); + assert_not_equals(caller_transceiver1.sender.transport, + caller.sctp.transport); + } + await callee.setRemoteDescription(offer); + const [callee_transceiver1, callee_transceiver2] = callee.getTransceivers(); + // According to spec, setRemoteDescription only updates the transports + // if the remote description is an answer. + assert_equals(callee_transceiver1.receiver.transport, null); + assert_equals(callee_transceiver2.receiver.transport, null); + const answer = await callee.createAnswer(); + await callee.setLocalDescription(answer); + assert_not_equals(callee_transceiver1.receiver.transport, null); + assert_not_equals(callee_transceiver2.receiver.transport, null); + assert_not_equals(callee.sctp.transport, null); + // At this point, bundle should have kicked in. + assert_equals(callee_transceiver1.receiver.transport, + callee_transceiver2.receiver.transport); + assert_equals(callee_transceiver1.receiver.transport, + callee.sctp.transport, + 'Callee SCTP transport does not match:'); + await caller.setRemoteDescription(answer); + assert_equals(caller_transceiver1.receiver.transport, + caller_transceiver2.receiver.transport); + assert_equals(caller_transceiver1.receiver.transport, + caller.sctp.transport, + 'Caller SCTP transport does not match:'); + }, 'RTCRtpSender/receiver/SCTP transport at the right time, with bundle policy ' + bundle_policy); + } + </script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpSender.https.html b/testing/web-platform/tests/webrtc/RTCRtpSender.https.html new file mode 100644 index 0000000000..d17115c46a --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpSender.https.html @@ -0,0 +1,20 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpSender</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> + 'use strict'; + +test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const t1 = pc.addTransceiver("audio"); + const t2 = pc.addTransceiver("video"); + + assert_not_equals(t1.sender.dtmf, null); + assert_equals(t2.sender.dtmf, null); +}, "Video sender @dtmf is null"); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpTransceiver-direction.html b/testing/web-platform/tests/webrtc/RTCRtpTransceiver-direction.html new file mode 100644 index 0000000000..e76bc1fbb7 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpTransceiver-direction.html @@ -0,0 +1,94 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpTransceiver.prototype.direction</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://rawgit.com/w3c/webrtc-pc/8495678808d126d8bc764bf944996f32981fa6fd/webrtc.html + + // The following helper functions are called from RTCPeerConnection-helper.js: + // generateAnswer + + /* + 5.4. RTCRtpTransceiver Interface + interface RTCRtpTransceiver { + attribute RTCRtpTransceiverDirection direction; + readonly attribute RTCRtpTransceiverDirection? currentDirection; + ... + }; + */ + + /* + 5.4. direction + 7. Set transceiver's [[Direction]] slot to newDirection. + */ + test(t => { + const pc = new RTCPeerConnection(); + const transceiver = pc.addTransceiver('audio'); + assert_equals(transceiver.direction, 'sendrecv'); + assert_equals(transceiver.currentDirection, null); + + transceiver.direction = 'recvonly'; + assert_equals(transceiver.direction, 'recvonly'); + assert_equals(transceiver.currentDirection, null, + 'Expect transceiver.currentDirection to not change'); + + }, 'setting direction should change transceiver.direction'); + + /* + 5.4. direction + 3. If newDirection is equal to transceiver's [[Direction]] slot, abort + these steps. + */ + test(t => { + const pc = new RTCPeerConnection(); + const transceiver = pc.addTransceiver('audio', { direction: 'sendonly' }); + assert_equals(transceiver.direction, 'sendonly'); + transceiver.direction = 'sendonly'; + assert_equals(transceiver.direction, 'sendonly'); + + }, 'setting direction with same direction should have no effect'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio', { direction: 'recvonly' }); + assert_equals(transceiver.direction, 'recvonly'); + assert_equals(transceiver.currentDirection, null); + + return pc.createOffer() + .then(offer => + pc.setLocalDescription(offer) + .then(() => generateAnswer(offer))) + .then(answer => pc.setRemoteDescription(answer)) + .then(() => { + assert_equals(transceiver.currentDirection, 'inactive'); + transceiver.direction = 'sendrecv'; + assert_equals(transceiver.direction, 'sendrecv'); + assert_equals(transceiver.currentDirection, 'inactive'); + }); + }, 'setting direction should change transceiver.direction independent of transceiver.currentDirection'); + + /* + TODO + An update of directionality does not take effect immediately. Instead, future calls + to createOffer and createAnswer mark the corresponding media description as + sendrecv, sendonly, recvonly or inactive as defined in [JSEP] (section 5.2.2. + and section 5.3.2.). + + Tested in RTCPeerConnection-onnegotiationneeded.html + 5.4. direction + 6. Update the negotiation-needed flag for connection. + + Coverage Report + Tested 6 + Not Tested 1 + Untestable 0 + Total 7 + */ + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpTransceiver-setCodecPreferences.html b/testing/web-platform/tests/webrtc/RTCRtpTransceiver-setCodecPreferences.html new file mode 100644 index 0000000000..f779f5a94c --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpTransceiver-setCodecPreferences.html @@ -0,0 +1,322 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpTransceiver.prototype.setCodecPreferences</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="./third_party/sdp/sdp.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + /* + 5.4. RTCRtpTransceiver Interface + interface RTCRtpTransceiver { + ... + void setCodecPreferences(sequence<RTCRtpCodecCapability> codecs); + }; + + setCodecPreferences + - Setting codecs to an empty sequence resets codec preferences to any + default value. + + - The codecs sequence passed into setCodecPreferences can only contain + codecs that are returned by RTCRtpSender.getCapabilities(kind) or + RTCRtpReceiver.getCapabilities(kind), where kind is the kind of the + RTCRtpTransceiver on which the method is called. Additionally, the + RTCRtpCodecParameters dictionary members cannot be modified. If + codecs does not fulfill these requirements, the user agent MUST throw + an InvalidModificationError. + */ + /* + * Chromium note: this requires build bots with H264 support. See + * https://bugs.chromium.org/p/chromium/issues/detail?id=840659 + * for details on how to enable support. + */ + + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const capabilities = RTCRtpSender.getCapabilities('audio'); + transceiver.setCodecPreferences(capabilities.codecs); + }, `setCodecPreferences() on audio transceiver with codecs returned from RTCRtpSender.getCapabilities('audio') should succeed`); + + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('video'); + const capabilities = RTCRtpReceiver.getCapabilities('video'); + transceiver.setCodecPreferences(capabilities.codecs); + }, `setCodecPreferences() on video transceiver with codecs returned from RTCRtpReceiver.getCapabilities('video') should succeed`); + + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const capabilities1 = RTCRtpSender.getCapabilities('audio'); + const capabilities2 = RTCRtpReceiver.getCapabilities('audio'); + transceiver.setCodecPreferences([...capabilities1.codecs, ... capabilities2.codecs]); + }, `setCodecPreferences() with both sender receiver codecs combined should succeed`); + + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + transceiver.setCodecPreferences([]); + }, `setCodecPreferences([]) should succeed`); + + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const capabilities = RTCRtpSender.getCapabilities('audio'); + const { codecs } = capabilities; + + if(codecs.length >= 2) { + const tmp = codecs[0]; + codecs[0] = codecs[1]; + codecs[1] = tmp; + } + + transceiver.setCodecPreferences(codecs); + }, `setCodecPreferences() with reordered codecs should succeed`); + + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('video'); + const capabilities = RTCRtpSender.getCapabilities('video'); + const { codecs } = capabilities; + // This test verifies that the mandatory VP8 codec is present + // and can be set. + let tried = false; + codecs.forEach(codec => { + if (codec.mimeType.toLowerCase() === 'video/vp8') { + transceiver.setCodecPreferences([codecs[0]]); + tried = true; + } + }); + assert_true(tried, 'VP8 video codec was found and tried'); + }, `setCodecPreferences() with only VP8 should succeed`); + + test(() => { + const pc = new RTCPeerConnection(); + const transceiver = pc.addTransceiver('video'); + const capabilities = RTCRtpSender.getCapabilities('video'); + const { codecs } = capabilities; + // This test verifies that the mandatory H264 codec is present + // and can be set. + let tried = false; + codecs.forEach(codec => { + if (codec.mimeType.toLowerCase() === 'video/h264') { + transceiver.setCodecPreferences([codecs[0]]); + tried = true; + } + }); + assert_true(tried, 'H264 video codec was found and tried'); + }, `setCodecPreferences() with only H264 should succeed`); + + async function getRTPMapLinesWithCodecAsFirst(firstCodec) + { + const capabilities = RTCRtpSender.getCapabilities('video').codecs; + capabilities.forEach((codec, idx) => { + if (codec.mimeType === firstCodec) { + capabilities.splice(idx, 1); + capabilities.unshift(codec); + } + }); + + const pc = new RTCPeerConnection(); + const transceiver = pc.addTransceiver('video'); + transceiver.setCodecPreferences(capabilities); + const offer = await pc.createOffer(); + + return offer.sdp.split('\r\n').filter(line => line.indexOf("a=rtpmap") === 0); + } + + promise_test(async () => { + const lines = await getRTPMapLinesWithCodecAsFirst('video/H264'); + + assert_greater_than(lines.length, 1); + assert_true(lines[0].indexOf("H264") !== -1, "H264 should be the first codec"); + }, `setCodecPreferences() should allow setting H264 as first codec`); + + promise_test(async () => { + const lines = await getRTPMapLinesWithCodecAsFirst('video/VP8'); + + assert_greater_than(lines.length, 1); + assert_true(lines[0].indexOf("VP8") !== -1, "VP8 should be the first codec"); + }, `setCodecPreferences() should allow setting VP8 as first codec`); + + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const capabilities = RTCRtpSender.getCapabilities('video'); + assert_throws_dom('InvalidModificationError', () => transceiver.setCodecPreferences(capabilities.codecs)); + }, `setCodecPreferences() on audio transceiver with codecs returned from getCapabilities('video') should throw InvalidModificationError`); + + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const codecs = [{ + mimeType: 'data', + clockRate: 2000, + channels: 2, + sdpFmtpLine: '0-15' + }]; + + assert_throws_dom('InvalidModificationError', () => transceiver.setCodecPreferences(codecs)); + }, `setCodecPreferences() with user defined codec with invalid mimeType should throw InvalidModificationError`); + + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const codecs = [{ + mimeType: 'audio/piepiper', + clockRate: 2000, + channels: 2, + sdpFmtpLine: '0-15' + }]; + + assert_throws_dom('InvalidModificationError', () => transceiver.setCodecPreferences(codecs)); + }, `setCodecPreferences() with user defined codec should throw InvalidModificationError`); + + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const capabilities = RTCRtpSender.getCapabilities('audio'); + const codecs = [ + ...capabilities.codecs, + { + mimeType: 'audio/piepiper', + clockRate: 2000, + channels: 2, + sdpFmtpLine: '0-15' + }]; + + assert_throws_dom('InvalidModificationError', () => transceiver.setCodecPreferences(codecs)); + }, `setCodecPreferences() with user defined codec together with codecs returned from getCapabilities() should throw InvalidModificationError`); + + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const capabilities = RTCRtpSender.getCapabilities('audio'); + const codecs = [capabilities.codecs[0]]; + codecs[0].clockRate = codecs[0].clockRate / 2; + + assert_throws_dom('InvalidModificationError', () => transceiver.setCodecPreferences(codecs)); + }, `setCodecPreferences() with modified codec clock rate should throw InvalidModificationError`); + + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const capabilities = RTCRtpSender.getCapabilities('audio'); + const codecs = [capabilities.codecs[0]]; + codecs[0].channels = codecs[0].channels + 11; + + assert_throws_dom('InvalidModificationError', () => transceiver.setCodecPreferences(codecs)); + }, `setCodecPreferences() with modified codec channel count should throw InvalidModificationError`); + + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const capabilities = RTCRtpSender.getCapabilities('audio'); + const codecs = [capabilities.codecs[0]]; + codecs[0].sdpFmtpLine = "modifiedparameter=1"; + + assert_throws_dom('InvalidModificationError', () => transceiver.setCodecPreferences(codecs)); + }, `setCodecPreferences() with modified codec parameters should throw InvalidModificationError`); + + test((t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const capabilities = RTCRtpSender.getCapabilities('audio'); + + const { codecs } = capabilities; + assert_greater_than(codecs.length, 0, + 'Expect at least one codec available'); + + const [ codec ] = codecs; + const { channels=2 } = codec; + codec.channels = channels+1; + + assert_throws_dom('InvalidModificationError', () => transceiver.setCodecPreferences(codecs)); + }, `setCodecPreferences() with modified codecs returned from getCapabilities() should throw InvalidModificationError`); + + promise_test(async (t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('audio'); + const {codecs} = RTCRtpSender.getCapabilities('audio'); + // Reorder codecs, put PCMU/PCMA first. + let firstCodec; + let i; + for (i = 0; i < codecs.length; i++) { + const codec = codecs[i]; + if (codec.mimeType === 'audio/PCMU' || codec.mimeType === 'audio/PCMA') { + codecs.splice(i, 1); + codecs.unshift(codec); + firstCodec = codec.mimeType.substr(6); + break; + } + } + assert_not_equals(firstCodec, undefined, 'PCMU or PCMA codec not found'); + transceiver.setCodecPreferences(codecs); + + const offer = await pc.createOffer(); + const mediaSection = SDPUtils.getMediaSections(offer.sdp)[0]; + const rtpParameters = SDPUtils.parseRtpParameters(mediaSection); + assert_equals(rtpParameters.codecs[0].name, firstCodec); + }, `setCodecPreferences() modifies the order of audio codecs in createOffer`); + + promise_test(async (t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const transceiver = pc.addTransceiver('video'); + const {codecs} = RTCRtpSender.getCapabilities('video'); + // Reorder codecs, swap H264 and VP8. + let vp8 = -1; + let h264 = -1; + let firstCodec; + let i; + for (i = 0; i < codecs.length; i++) { + const codec = codecs[i]; + if (codec.mimeType === 'video/VP8' && vp8 === -1) { + vp8 = i; + if (h264 !== -1) { + codecs[vp8] = codecs[h264]; + codecs[h264] = codec; + firstCodec = 'VP8'; + break; + } + } + if (codec.mimeType === 'video/H264' && h264 === -1) { + h264 = i; + if (vp8 !== -1) { + codecs[h264] = codecs[vp8]; + codecs[vp8] = codec; + firstCodec = 'H264'; + break; + } + } + } + assert_not_equals(firstCodec, undefined, 'VP8 and H264 codecs not found'); + transceiver.setCodecPreferences(codecs); + + const offer = await pc.createOffer(); + const mediaSection = SDPUtils.getMediaSections(offer.sdp)[0]; + const rtpParameters = SDPUtils.parseRtpParameters(mediaSection); + assert_equals(rtpParameters.codecs[0].name, firstCodec); + }, `setCodecPreferences() modifies the order of video codecs in createOffer`); + + </script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpTransceiver-stop.html b/testing/web-platform/tests/webrtc/RTCRtpTransceiver-stop.html new file mode 100644 index 0000000000..766b34d7b1 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpTransceiver-stop.html @@ -0,0 +1,155 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpTransceiver.prototype.stop</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +// FIXME: Add a test adding a transceiver, stopping it and trying to create an empty offer. + +promise_test(async (t)=> { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + + pc1.addTransceiver("audio", { direction: "sendonly" }); + pc1.addTransceiver("video"); + pc1.getTransceivers()[0].stop(); + + const offer = await pc1.createOffer(); + + assert_false(offer.sdp.includes("m=audio"), "offer should not contain an audio m-section"); + assert_true(offer.sdp.includes("m=video"), "offer should contain a video m-section"); +}, "A transceiver added and stopped before the initial offer generation should not trigger an offer m-section generation"); + +promise_test(async (t)=> { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + + pc1.addTransceiver("audio", { direction: "sendonly" }); + pc1.addTransceiver("video"); + assert_equals(null, pc1.getTransceivers()[1].receiver.transport); + + pc1.getTransceivers()[1].stop(); + assert_equals(pc1.getTransceivers()[1].receiver.transport, null); +}, "A transceiver added and stopped should not crash when getting receiver's transport"); + +promise_test(async (t)=> { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + + await exchangeOfferAnswer(pc1, pc2); + + pc1.addTransceiver("video"); + + pc1.getTransceivers()[0].stop(); + pc1.getTransceivers()[1].stop(); + + const offer = await pc1.createOffer(); + + assert_true(offer.sdp.includes("m=audio"), "offer should contain an audio m-section"); + assert_true(offer.sdp.includes("m=audio 0"), "The audio m-section should be rejected"); + + assert_false(offer.sdp.includes("m=video"), "offer should not contain a video m-section"); +}, "During renegotiation, adding and stopping a transceiver should not trigger a renegotiated offer m-section generation"); + +promise_test(async (t)=> { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + + await exchangeOfferAnswer(pc1, pc2); + + pc1.getTransceivers()[0].direction = "sendonly"; + pc1.getTransceivers()[0].stop(); + + const offer = await pc1.createOffer(); + + assert_true(offer.sdp.includes("a=inactive"), "The audio m-section should be inactive"); +}, "A stopped sendonly transceiver should generate an inactive m-section in the offer"); + +promise_test(async (t)=> { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + + await exchangeOfferAnswer(pc1, pc2); + + pc1.getTransceivers()[0].direction = "inactive"; + pc1.getTransceivers()[0].stop(); + + const offer = await pc1.createOffer(); + + assert_true(offer.sdp.includes("a=inactive"), "The audio m-section should be inactive"); +}, "A stopped inactive transceiver should generate an inactive m-section in the offer"); + +promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver("audio"); + await exchangeOfferAnswer(pc1, pc2); + pc1.getTransceivers()[0].stop(); + await exchangeOfferAnswer(pc1, pc2); + await pc1.setLocalDescription(await pc1.createOffer()); +}, 'If a transceiver is stopped locally, setting a locally generated answer should still work'); + +promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver("audio"); + await exchangeOfferAnswer(pc1, pc2); + pc2.getTransceivers()[0].stop(); + await exchangeOfferAnswer(pc2, pc1); + await pc1.setLocalDescription(await pc1.createOffer()); +}, 'If a transceiver is stopped remotely, setting a locally generated answer should still work'); + +promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver("audio"); + await exchangeOfferAnswer(pc1, pc2); + assert_equals(pc1.getTransceivers().length, 1); + assert_equals(pc2.getTransceivers().length, 1); + pc1.getTransceivers()[0].stop(); + await exchangeOfferAnswer(pc1, pc2); + assert_equals(pc1.getTransceivers().length, 0); + assert_equals(pc2.getTransceivers().length, 0); + assert_equals(pc1.getSenders().length, 0, 'caller senders'); + assert_equals(pc1.getReceivers().length, 0, 'caller receivers'); + assert_equals(pc2.getSenders().length, 0, 'callee senders'); + assert_equals(pc2.getReceivers().length, 0, 'callee receivers'); +}, 'If a transceiver is stopped, transceivers, senders and receivers should disappear after offer/answer'); + +promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver("audio"); + await exchangeOfferAnswer(pc1, pc2); + assert_equals(pc1.getTransceivers().length, 1); + assert_equals(pc2.getTransceivers().length, 1); + pc1Transceiver = pc1.getTransceivers()[0]; + pc2Transceiver = pc2.getTransceivers()[0]; + pc1.getTransceivers()[0].stop(); + await exchangeOfferAnswer(pc1, pc2); + assert_equals(pc1Transceiver.direction, 'stopped'); + assert_equals(pc2Transceiver.direction, 'stopped'); +}, 'If a transceiver is stopped, transceivers should end up in state stopped'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpTransceiver-stopping.https.html b/testing/web-platform/tests/webrtc/RTCRtpTransceiver-stopping.https.html new file mode 100644 index 0000000000..16be25fe13 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpTransceiver-stopping.https.html @@ -0,0 +1,217 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +'use strict'; + +['audio', 'video'].forEach((kind) => { + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const transceiver = pc1.addTransceiver(kind); + + // Complete O/A exchange such that the transceiver gets associated. + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + assert_not_equals(transceiver.mid, null, 'mid before stop()'); + assert_not_equals(transceiver.direction, 'stopped', + 'direction before stop()'); + assert_not_equals(transceiver.currentDirection, 'stopped', + 'currentDirection before stop()'); + + // Stop makes it stopping, but not stopped. + transceiver.stop(); + assert_not_equals(transceiver.mid, null, 'mid after stop()'); + assert_equals(transceiver.direction, 'stopped', 'direction after stop()'); + assert_not_equals(transceiver.currentDirection, 'stopped', + 'currentDirection after stop()'); + + // Negotiating makes it stopped. + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + assert_equals(transceiver.mid, null, 'mid after negotiation'); + assert_equals(transceiver.direction, 'stopped', + 'direction after negotiation'); + assert_equals(transceiver.currentDirection, 'stopped', + 'currentDirection after negotiation'); + }, `[${kind}] Locally stopped transceiver goes from stopping to stopped`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver(kind); + const trackEnded = new Promise( + r => { transceiver.receiver.track.onended = () => { r(); } }); + assert_equals(transceiver.receiver.track.readyState, 'live'); + transceiver.stop(); + // Stopping triggers ending the track, but this happens asynchronously. + assert_equals(transceiver.receiver.track.readyState, 'live'); + await trackEnded; + assert_equals(transceiver.receiver.track.readyState, 'ended'); + }, `[${kind}] Locally stopping a transceiver ends the track`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const pc1Transceiver = pc1.addTransceiver(kind); + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + const [pc2Transceiver] = pc2.getTransceivers(); + + pc1Transceiver.stop(); + + await pc1.setLocalDescription(); + assert_equals(pc2Transceiver.receiver.track.readyState, 'live'); + // Applying the remote offer immediately ends the track, we don't need to + // create or apply an answer. + await pc2.setRemoteDescription(pc1.localDescription); + assert_equals(pc2Transceiver.receiver.track.readyState, 'ended'); + }, `[${kind}] Remotely stopping a transceiver ends the track`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const pc1Transceiver = pc1.addTransceiver(kind); + + // Complete O/A exchange such that the transceiver gets associated. + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + const [pc2Transceiver] = pc2.getTransceivers(); + assert_not_equals(pc2Transceiver.mid, null, 'mid before stop()'); + assert_not_equals(pc2Transceiver.direction, 'stopped', + 'direction before stop()'); + assert_not_equals(pc2Transceiver.currentDirection, 'stopped', + 'currentDirection before stop()'); + + // Make the remote transceiver stopped. + pc1Transceiver.stop(); + + // Negotiating makes it stopped. + assert_equals(pc2.getTransceivers().length, 1); + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + // As soon as the remote offer is set, the transceiver is stopped but it is + // not disassociated or removed until setting the local answer. + assert_equals(pc2.getTransceivers().length, 1); + assert_not_equals(pc2Transceiver.mid, null, 'mid during negotiation'); + assert_equals(pc2Transceiver.direction, 'stopped', + 'direction during negotiation'); + assert_equals(pc2Transceiver.currentDirection, 'stopped', + 'currentDirection during negotiation'); + await pc2.setLocalDescription(); + assert_equals(pc2.getTransceivers().length, 0); + assert_equals(pc2Transceiver.mid, null, 'mid after negotiation'); + assert_equals(pc2Transceiver.direction, 'stopped', + 'direction after negotiation'); + assert_equals(pc2Transceiver.currentDirection, 'stopped', + 'currentDirection after negotiation'); + }, `[${kind}] Remotely stopped transceiver goes directly to stopped`); + + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const transceiver = pc.addTransceiver(kind); + + // Rollback does not end the track, because the transceiver is not removed. + await pc.setLocalDescription(); + await pc.setLocalDescription({type:'rollback'}); + assert_equals(transceiver.receiver.track.readyState, 'live'); + }, `[${kind}] Rollback when transceiver is not removed does not end track`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const pc1Transceiver = pc1.addTransceiver(kind); + + // Start negotiation, causing a transceiver to be created. + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + const [pc2Transceiver] = pc2.getTransceivers(); + + // Rollback such that the transceiver is removed. + await pc2.setLocalDescription({type:'rollback'}); + assert_equals(pc2.getTransceivers().length, 0); + assert_equals(pc2Transceiver.receiver.track.readyState, 'ended'); + }, `[${kind}] Rollback when removing transceiver does end the track`); + + // Same test as above but looking at direction and currentDirection. + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const pc1Transceiver = pc1.addTransceiver(kind); + + // Start negotiation, causing a transceiver to be created. + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + const [pc2Transceiver] = pc2.getTransceivers(); + + // Rollback such that the transceiver is removed. + await pc2.setLocalDescription({type:'rollback'}); + assert_equals(pc2.getTransceivers().length, 0); + // The removed transceiver is stopped. + assert_equals(pc2Transceiver.currentDirection, 'stopped', + 'currentDirection indicate stopped'); + // A stopped transceiver is necessarily also stopping. + assert_equals(pc2Transceiver.direction, 'stopped', + 'direction indicate stopping'); + // A stopped transceiver has no mid. + assert_equals(pc2Transceiver.mid, null, 'not associated'); + }, `[${kind}] Rollback when removing transceiver makes it stopped`); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const constraints = {}; + constraints[kind] = true; + const stream = await navigator.mediaDevices.getUserMedia(constraints); + const [track] = stream.getTracks(); + + pc1.addTrack(track); + pc2.addTrack(track); + const transceiver = pc2.getTransceivers()[0]; + + const ontrackEvent = new Promise(r => { + pc2.ontrack = e => r(e.track); + }); + + // Simulate glare: both peer connections set local offers. + await pc1.setLocalDescription(); + await pc2.setLocalDescription(); + // Set remote offer, which implicitly rolls back the local offer. Because + // `transceiver` is an addTrack-transceiver, it should get repurposed. + await pc2.setRemoteDescription(pc1.localDescription); + assert_equals(transceiver.receiver.track.readyState, 'live'); + // Sanity check: the track should still be live when ontrack fires. + assert_equals((await ontrackEvent).readyState, 'live'); + }, `[${kind}] Glare when transceiver is not removed does not end track`); +}); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCRtpTransceiver.https.html b/testing/web-platform/tests/webrtc/RTCRtpTransceiver.https.html new file mode 100644 index 0000000000..943550d4b7 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCRtpTransceiver.https.html @@ -0,0 +1,2297 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCRtpTransceiver</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + const checkThrows = async (func, exceptionName, description) => { + try { + await func(); + assert_true(false, description + " throws " + exceptionName); + } catch (e) { + assert_equals(e.name, exceptionName, description + " throws " + exceptionName); + } + }; + + const stopTracks = (...streams) => { + streams.forEach(stream => stream.getTracks().forEach(track => track.stop())); + }; + + const collectEvents = (target, name, check) => { + const events = []; + const handler = e => { + check(e); + events.push(e); + }; + + target.addEventListener(name, handler); + + const finishCollecting = () => { + target.removeEventListener(name, handler); + return events; + }; + + return {finish: finishCollecting}; + }; + + const collectAddTrackEvents = stream => { + const checkEvent = e => { + assert_true(e.track instanceof MediaStreamTrack, "Track is set on event"); + assert_true(stream.getTracks().includes(e.track), + "track in addtrack event is in the stream"); + }; + return collectEvents(stream, "addtrack", checkEvent); + }; + + const collectRemoveTrackEvents = stream => { + const checkEvent = e => { + assert_true(e.track instanceof MediaStreamTrack, "Track is set on event"); + assert_true(!stream.getTracks().includes(e.track), + "track in removetrack event is not in the stream"); + }; + return collectEvents(stream, "removetrack", checkEvent); + }; + + const collectTrackEvents = pc => { + const checkEvent = e => { + assert_true(e.track instanceof MediaStreamTrack, "Track is set on event"); + assert_true(e.receiver instanceof RTCRtpReceiver, "Receiver is set on event"); + assert_true(e.transceiver instanceof RTCRtpTransceiver, "Transceiver is set on event"); + assert_true(Array.isArray(e.streams), "Streams is set on event"); + e.streams.forEach(stream => { + assert_true(stream.getTracks().includes(e.track), + "Each stream in event contains the track"); + }); + assert_equals(e.receiver, e.transceiver.receiver, + "Receiver belongs to transceiver"); + assert_equals(e.track, e.receiver.track, + "Track belongs to receiver"); + }; + + return collectEvents(pc, "track", checkEvent); + }; + + const setRemoteDescriptionReturnTrackEvents = async (pc, desc) => { + const trackEventCollector = collectTrackEvents(pc); + await pc.setRemoteDescription(desc); + return trackEventCollector.finish(); + }; + + const offerAnswer = async (offerer, answerer) => { + const offer = await offerer.createOffer(); + await answerer.setRemoteDescription(offer); + await offerer.setLocalDescription(offer); + const answer = await answerer.createAnswer(); + await offerer.setRemoteDescription(answer); + await answerer.setLocalDescription(answer); + }; + + const trickle = (t, pc1, pc2) => { + pc1.onicecandidate = t.step_func(async e => { + try { + await pc2.addIceCandidate(e.candidate); + } catch (e) { + assert_true(false, "addIceCandidate threw error: " + e.name); + } + }); + }; + + const iceConnected = pc => { + return new Promise((resolve, reject) => { + const iceCheck = () => { + if (pc.iceConnectionState == "connected") { + assert_true(true, "ICE connected"); + resolve(); + } + + if (pc.iceConnectionState == "failed") { + assert_true(false, "ICE failed"); + reject(); + } + }; + + iceCheck(); + pc.oniceconnectionstatechange = iceCheck; + }); + }; + + const negotiationNeeded = pc => { + return new Promise(resolve => pc.onnegotiationneeded = resolve); + }; + + const countEvents = (target, name) => { + const result = {count: 0}; + target.addEventListener(name, e => result.count++); + return result; + }; + + const gotMuteEvent = async track => { + await new Promise(r => track.addEventListener("mute", r, {once: true})); + + assert_true(track.muted, "track should be muted after onmute"); + }; + + const gotUnmuteEvent = async track => { + await new Promise(r => track.addEventListener("unmute", r, {once: true})); + + assert_true(!track.muted, "track should not be muted after onunmute"); + }; + + // comparable() - produces copy of object that is JSON comparable. + // o = original object (required) + // t = template of what to examine. Useful if o is non-enumerable (optional) + + const comparable = (o, t = o) => { + if (typeof o != 'object' || !o) { + return o; + } + if (Array.isArray(t) && Array.isArray(o)) { + return o.map((n, i) => comparable(n, t[i])); + } + return Object.keys(t).sort() + .reduce((r, key) => (r[key] = comparable(o[key], t[key]), r), {}); + }; + + const stripKeyQuotes = s => s.replace(/"(\w+)":/g, "$1:"); + + const hasProps = (observed, expected) => { + const observable = comparable(observed, expected); + assert_equals(stripKeyQuotes(JSON.stringify(observable)), + stripKeyQuotes(JSON.stringify(comparable(expected)))); + }; + + const hasPropsAndUniqueMids = (observed, expected) => { + hasProps(observed, expected); + + const mids = []; + observed.forEach((transceiver, i) => { + if (!("mid" in expected[i])) { + assert_not_equals(transceiver.mid, null); + assert_equals(typeof transceiver.mid, "string"); + } + if (transceiver.mid) { + assert_false(mids.includes(transceiver.mid), "mid must be unique"); + mids.push(transceiver.mid); + } + }); + }; + + const checkAddTransceiverNoTrack = async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + hasProps(pc.getTransceivers(), []); + + pc.addTransceiver("audio"); + pc.addTransceiver("video"); + + hasProps(pc.getTransceivers(), + [ + { + receiver: {track: {kind: "audio", readyState: "live", muted: true}}, + sender: {track: null}, + direction: "sendrecv", + mid: null, + currentDirection: null, + }, + { + receiver: {track: {kind: "video", readyState: "live", muted: true}}, + sender: {track: null}, + direction: "sendrecv", + mid: null, + currentDirection: null, + } + ]); + }; + + const checkAddTransceiverWithTrack = async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({audio: true, video: true}); + t.add_cleanup(() => stopTracks(stream)); + const audio = stream.getAudioTracks()[0]; + const video = stream.getVideoTracks()[0]; + + pc.addTransceiver(audio); + pc.addTransceiver(video); + + hasProps(pc.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: audio}, + direction: "sendrecv", + mid: null, + currentDirection: null, + }, + { + receiver: {track: {kind: "video"}}, + sender: {track: video}, + direction: "sendrecv", + mid: null, + currentDirection: null, + } + ]); + }; + + const checkAddTransceiverWithAddTrack = async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({audio: true, video: true}); + t.add_cleanup(() => stopTracks(stream)); + const audio = stream.getAudioTracks()[0]; + const video = stream.getVideoTracks()[0]; + + pc.addTrack(audio, stream); + pc.addTrack(video, stream); + + hasProps(pc.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: audio}, + direction: "sendrecv", + mid: null, + currentDirection: null, + }, + { + receiver: {track: {kind: "video"}}, + sender: {track: video}, + direction: "sendrecv", + mid: null, + currentDirection: null, + } + ]); + }; + + const checkAddTransceiverWithDirection = async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + pc.addTransceiver("audio", {direction: "recvonly"}); + pc.addTransceiver("video", {direction: "recvonly"}); + + hasProps(pc.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: null}, + direction: "recvonly", + mid: null, + currentDirection: null, + }, + { + receiver: {track: {kind: "video"}}, + sender: {track: null}, + direction: "recvonly", + mid: null, + currentDirection: null, + } + ]); + }; + + const checkAddTransceiverWithSetRemoteOfferSending = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTransceiver(track, {streams: [stream]}); + + const offer = await pc1.createOffer(); + + const trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, offer); + hasProps(trackEvents, + [ + { + track: pc2.getTransceivers()[0].receiver.track, + streams: [{id: stream.id}] + } + ]); + + + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: null}, + direction: "recvonly", + currentDirection: null, + } + ]); + }; + + const checkAddTransceiverWithSetRemoteOfferNoSend = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTransceiver(track); + pc1.getTransceivers()[0].direction = "recvonly"; + + const offer = await pc1.createOffer(); + const trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, offer); + hasProps(trackEvents, []); + + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: null}, + // rtcweb-jsep says this is recvonly, w3c-webrtc does not... + direction: "recvonly", + currentDirection: null, + } + ]); + }; + + const checkAddTransceiverBadKind = async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + try { + pc.addTransceiver("foo"); + assert_true(false, 'addTransceiver("foo") throws'); + } + catch (e) { + if (e instanceof TypeError) { + assert_true(true, 'addTransceiver("foo") throws a TypeError'); + } else { + assert_true(false, 'addTransceiver("foo") throws a TypeError'); + } + } + + hasProps(pc.getTransceivers(), []); + }; + + const checkNoMidOffer = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTrack(track, stream); + + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + + // Remove mid attr + offer.sdp = offer.sdp.replace("a=mid:", "a=unknownattr:"); + offer.sdp = offer.sdp.replace("a=group:", "a=unknownattr:"); + await pc2.setRemoteDescription(offer); + + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: null}, + direction: "recvonly", + currentDirection: null, + } + ]); + + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + }; + + const checkNoMidAnswer = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTrack(track, stream); + + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + await pc2.setRemoteDescription(offer); + + hasPropsAndUniqueMids(pc1.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: {kind: "audio"}}, + direction: "sendrecv", + currentDirection: null, + } + ]); + + const lastMid = pc1.getTransceivers()[0].mid; + + let answer = await pc2.createAnswer(); + // Remove mid attr + answer.sdp = answer.sdp.replace("a=mid:", "a=unknownattr:"); + // Remove group attr also + answer.sdp = answer.sdp.replace("a=group:", "a=unknownattr:"); + await pc1.setRemoteDescription(answer); + + hasProps(pc1.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: {kind: "audio"}}, + direction: "sendrecv", + currentDirection: "sendonly", + mid: lastMid + } + ]); + + const reoffer = await pc1.createOffer(); + await pc1.setLocalDescription(reoffer); + hasProps(pc1.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: {kind: "audio"}}, + direction: "sendrecv", + currentDirection: "sendonly", + mid: lastMid + } + ]); + }; + + const checkAddTransceiverNoTrackDoesntPair = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver("audio"); + pc2.addTransceiver("audio"); + + const offer = await pc1.createOffer(); + const trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, offer); + hasProps(trackEvents, + [ + { + track: pc2.getTransceivers()[1].receiver.track, + streams: [] + } + ]); + + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + {mid: null}, // no addTrack magic, doesn't auto-pair + {} // Created by SRD + ]); + }; + + const checkAddTransceiverWithTrackDoesntPair = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver("audio"); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc2.addTransceiver(track); + + const offer = await pc1.createOffer(); + const trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, offer); + hasProps(trackEvents, + [ + { + track: pc2.getTransceivers()[1].receiver.track, + streams: [] + } + ]); + + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + {mid: null, sender: {track}}, + {sender: {track: null}} // Created by SRD + ]); + }; + + const checkAddTransceiverThenReplaceTrackDoesntPair = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver("audio"); + pc2.addTransceiver("audio"); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + await pc2.getTransceivers()[0].sender.replaceTrack(track); + + const offer = await pc1.createOffer(); + const trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, offer); + hasProps(trackEvents, + [ + { + track: pc2.getTransceivers()[1].receiver.track, + streams: [] + } + ]); + + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + {mid: null, sender: {track}}, + {sender: {track: null}} // Created by SRD + ]); + }; + + const checkAddTransceiverThenAddTrackPairs = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver("audio"); + pc2.addTransceiver("audio"); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc2.addTrack(track, stream); + + const offer = await pc1.createOffer(); + const trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, offer); + hasProps(trackEvents, + [ + { + track: pc2.getTransceivers()[0].receiver.track, + streams: [] + } + ]); + + // addTransceiver-transceivers cannot attach to a remote offers, so a second + // transceiver is created and associated whilst the first transceiver + // remains unassociated. + assert_equals(pc2.getTransceivers()[0].mid, null); + assert_not_equals(pc2.getTransceivers()[1].mid, null); + }; + + const checkAddTrackPairs = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver("audio"); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc2.addTrack(track, stream); + + const offer = await pc1.createOffer(); + const trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, offer); + hasProps(trackEvents, + [ + { + track: pc2.getTransceivers()[0].receiver.track, + streams: [] + } + ]); + + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + {sender: {track}} + ]); + }; + + const checkReplaceTrackNullDoesntPreventPairing = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1.addTransceiver("audio"); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc2.addTrack(track, stream); + await pc2.getTransceivers()[0].sender.replaceTrack(null); + + const offer = await pc1.createOffer(); + const trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, offer); + hasProps(trackEvents, + [ + { + track: pc2.getTransceivers()[0].receiver.track, + streams: [] + } + ]); + + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + {sender: {track: null}} + ]); + }; + + const checkRemoveAndReadd = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTrack(track, stream); + + await offerAnswer(pc1, pc2); + + pc1.removeTrack(pc1.getSenders()[0]); + pc1.addTrack(track, stream); + + hasProps(pc1.getTransceivers(), + [ + { + sender: {track: null}, + direction: "recvonly" + }, + { + sender: {track}, + direction: "sendrecv" + } + ]); + + // pc1 is offerer + await offerAnswer(pc1, pc2); + + hasProps(pc2.getTransceivers(), + [ + {currentDirection: "inactive"}, + {currentDirection: "recvonly"} + ]); + + pc1.removeTrack(pc1.getSenders()[1]); + pc1.addTrack(track, stream); + + hasProps(pc1.getTransceivers(), + [ + { + sender: {track: null}, + direction: "recvonly" + }, + { + sender: {track: null}, + direction: "recvonly" + }, + { + sender: {track}, + direction: "sendrecv" + } + ]); + + // pc1 is answerer. We need to create a new transceiver so pc1 will have + // something to attach the re-added track to + pc2.addTransceiver("audio"); + + await offerAnswer(pc2, pc1); + + hasProps(pc2.getTransceivers(), + [ + {currentDirection: "inactive"}, + {currentDirection: "inactive"}, + {currentDirection: "sendrecv"} + ]); + }; + + const checkAddTrackExistingTransceiverThenRemove = async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + pc.addTransceiver("audio"); + const stream = await getNoiseStream({audio: true}); + const audio = stream.getAudioTracks()[0]; + let sender = pc.addTrack(audio, stream); + pc.removeTrack(sender); + + // Cause transceiver to be associated + await pc.setLocalDescription(await pc.createOffer()); + + // Make sure add/remove works still + sender = pc.addTrack(audio, stream); + pc.removeTrack(sender); + + stopTracks(stream); + }; + + const checkRemoveTrackNegotiation = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const stream = await getNoiseStream({audio: true, video: true}); + t.add_cleanup(() => stopTracks(stream)); + const audio = stream.getAudioTracks()[0]; + pc1.addTrack(audio, stream); + const video = stream.getVideoTracks()[0]; + pc1.addTrack(video, stream); + // We want both a sendrecv and sendonly transceiver to test that the + // appropriate direction changes happen. + pc1.getTransceivers()[1].direction = "sendonly"; + + let offer = await pc1.createOffer(); + + // Get a reference to the stream + let trackEventCollector = collectTrackEvents(pc2); + await pc2.setRemoteDescription(offer); + let pc2TrackEvents = trackEventCollector.finish(); + hasProps(pc2TrackEvents, + [ + {streams: [{id: stream.id}]}, + {streams: [{id: stream.id}]} + ]); + const receiveStream = pc2TrackEvents[0].streams[0]; + + // Verify that rollback causes onremovetrack to fire for the added tracks + let removetrackEventCollector = collectRemoveTrackEvents(receiveStream); + await pc2.setRemoteDescription({type: "rollback"}); + let removedtracks = removetrackEventCollector.finish().map(e => e.track); + assert_equals(removedtracks.length, 2, + "Rollback should have removed two tracks"); + assert_true(removedtracks.includes(pc2TrackEvents[0].track), + "First track should be removed"); + assert_true(removedtracks.includes(pc2TrackEvents[1].track), + "Second track should be removed"); + + offer = await pc1.createOffer(); + + let addtrackEventCollector = collectAddTrackEvents(receiveStream); + trackEventCollector = collectTrackEvents(pc2); + await pc2.setRemoteDescription(offer); + pc2TrackEvents = trackEventCollector.finish(); + let addedtracks = addtrackEventCollector.finish().map(e => e.track); + assert_equals(addedtracks.length, 2, + "pc2.setRemoteDescription(offer) should've added 2 tracks to receive stream"); + assert_true(addedtracks.includes(pc2TrackEvents[0].track), + "First track should be added"); + assert_true(addedtracks.includes(pc2TrackEvents[1].track), + "Second track should be added"); + + await pc1.setLocalDescription(offer); + let answer = await pc2.createAnswer(); + await pc1.setRemoteDescription(answer); + await pc2.setLocalDescription(answer); + pc1.removeTrack(pc1.getSenders()[0]); + + hasProps(pc1.getSenders(), + [ + {track: null}, + {track: video} + ]); + + hasProps(pc1.getTransceivers(), + [ + { + sender: {track: null}, + direction: "recvonly" + }, + { + sender: {track: video}, + direction: "sendonly" + } + ]); + + await negotiationNeeded(pc1); + + pc1.removeTrack(pc1.getSenders()[1]); + + hasProps(pc1.getSenders(), + [ + {track: null}, + {track: null} + ]); + + hasProps(pc1.getTransceivers(), + [ + { + sender: {track: null}, + direction: "recvonly" + }, + { + sender: {track: null}, + direction: "inactive" + } + ]); + + // pc1 as offerer + offer = await pc1.createOffer(); + + removetrackEventCollector = collectRemoveTrackEvents(receiveStream); + await pc2.setRemoteDescription(offer); + removedtracks = removetrackEventCollector.finish().map(e => e.track); + assert_equals(removedtracks.length, 2, "Should have two removed tracks"); + assert_true(removedtracks.includes(pc2TrackEvents[0].track), + "First track should be removed"); + assert_true(removedtracks.includes(pc2TrackEvents[1].track), + "Second track should be removed"); + + addtrackEventCollector = collectAddTrackEvents(receiveStream); + await pc2.setRemoteDescription({type: "rollback"}); + addedtracks = addtrackEventCollector.finish().map(e => e.track); + assert_equals(addedtracks.length, 2, "Rollback should have added two tracks"); + + // pc2 as offerer + offer = await pc2.createOffer(); + await pc2.setLocalDescription(offer); + await pc1.setRemoteDescription(offer); + answer = await pc1.createAnswer(); + await pc1.setLocalDescription(answer); + + removetrackEventCollector = collectRemoveTrackEvents(receiveStream); + await pc2.setRemoteDescription(answer); + removedtracks = removetrackEventCollector.finish().map(e => e.track); + assert_equals(removedtracks.length, 2, "Should have two removed tracks"); + + hasProps(pc2.getTransceivers(), + [ + { + currentDirection: "inactive" + }, + { + currentDirection: "inactive" + } + ]); + }; + + const checkSetDirection = async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + pc.addTransceiver("audio"); + + pc.getTransceivers()[0].direction = "sendonly"; + hasProps(pc.getTransceivers(),[{direction: "sendonly"}]); + pc.getTransceivers()[0].direction = "recvonly"; + hasProps(pc.getTransceivers(),[{direction: "recvonly"}]); + pc.getTransceivers()[0].direction = "inactive"; + hasProps(pc.getTransceivers(),[{direction: "inactive"}]); + pc.getTransceivers()[0].direction = "sendrecv"; + hasProps(pc.getTransceivers(),[{direction: "sendrecv"}]); + }; + + const checkCurrentDirection = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTrack(track, stream); + pc2.addTrack(track, stream); + hasProps(pc1.getTransceivers(), [{currentDirection: null}]); + + let offer = await pc1.createOffer(); + hasProps(pc1.getTransceivers(), [{currentDirection: null}]); + + await pc1.setLocalDescription(offer); + hasProps(pc1.getTransceivers(), [{currentDirection: null}]); + + let trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, offer); + hasProps(trackEvents, + [ + { + track: pc2.getTransceivers()[0].receiver.track, + streams: [{id: stream.id}] + } + ]); + + hasProps(pc2.getTransceivers(), [{currentDirection: null}]); + + let answer = await pc2.createAnswer(); + hasProps(pc2.getTransceivers(), [{currentDirection: null}]); + + await pc2.setLocalDescription(answer); + hasProps(pc2.getTransceivers(), [{currentDirection: "sendrecv"}]); + + trackEvents = await setRemoteDescriptionReturnTrackEvents(pc1, answer); + hasProps(trackEvents, + [ + { + track: pc1.getTransceivers()[0].receiver.track, + streams: [{id: stream.id}] + } + ]); + + hasProps(pc1.getTransceivers(), [{currentDirection: "sendrecv"}]); + + pc2.getTransceivers()[0].direction = "sendonly"; + + offer = await pc2.createOffer(); + hasProps(pc2.getTransceivers(), [{currentDirection: "sendrecv"}]); + + await pc2.setLocalDescription(offer); + hasProps(pc2.getTransceivers(), [{currentDirection: "sendrecv"}]); + + trackEvents = await setRemoteDescriptionReturnTrackEvents(pc1, offer); + hasProps(trackEvents, []); + + hasProps(pc1.getTransceivers(), [{currentDirection: "sendrecv"}]); + + answer = await pc1.createAnswer(); + hasProps(pc1.getTransceivers(), [{currentDirection: "sendrecv"}]); + + await pc1.setLocalDescription(answer); + hasProps(pc1.getTransceivers(), [{currentDirection: "recvonly"}]); + + trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, answer); + hasProps(trackEvents, []); + + hasProps(pc2.getTransceivers(), [{currentDirection: "sendonly"}]); + + pc2.getTransceivers()[0].direction = "sendrecv"; + + offer = await pc2.createOffer(); + hasProps(pc2.getTransceivers(), [{currentDirection: "sendonly"}]); + + await pc2.setLocalDescription(offer); + hasProps(pc2.getTransceivers(), [{currentDirection: "sendonly"}]); + + trackEvents = await setRemoteDescriptionReturnTrackEvents(pc1, offer); + hasProps(trackEvents, []); + + hasProps(pc1.getTransceivers(), [{currentDirection: "recvonly"}]); + + answer = await pc1.createAnswer(); + hasProps(pc1.getTransceivers(), [{currentDirection: "recvonly"}]); + + await pc1.setLocalDescription(answer); + hasProps(pc1.getTransceivers(), [{currentDirection: "sendrecv"}]); + + trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, answer); + hasProps(trackEvents, + [ + { + track: pc2.getTransceivers()[0].receiver.track, + streams: [{id: stream.id}] + } + ]); + + hasProps(pc2.getTransceivers(), [{currentDirection: "sendrecv"}]); + + pc2.close(); + hasProps(pc2.getTransceivers(), [{currentDirection: "stopped"}]); + }; + + const checkSendrecvWithNoSendTrack = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTransceiver("audio"); + pc1.getTransceivers()[0].direction = "sendrecv"; + pc2.addTrack(track, stream); + + const offer = await pc1.createOffer(); + + let trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, offer); + hasProps(trackEvents, + [ + { + track: pc2.getTransceivers()[0].receiver.track, + streams: [] + } + ]); + + trickle(t, pc1, pc2); + await pc1.setLocalDescription(offer); + + const answer = await pc2.createAnswer(); + trackEvents = await setRemoteDescriptionReturnTrackEvents(pc1, answer); + // Spec language doesn't say anything about checking whether the transceiver + // is stopped here. + hasProps(trackEvents, + [ + { + track: pc1.getTransceivers()[0].receiver.track, + streams: [{id: stream.id}] + } + ]); + + trickle(t, pc2, pc1); + await pc2.setLocalDescription(answer); + + await iceConnected(pc1); + await iceConnected(pc2); + }; + + const checkSendrecvWithTracklessStream = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = new MediaStream(); + pc1.addTransceiver("audio", {streams: [stream]}); + + const offer = await pc1.createOffer(); + + const trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, offer); + hasProps(trackEvents, + [ + { + track: pc2.getTransceivers()[0].receiver.track, + streams: [{id: stream.id}] + } + ]); + }; + + const checkMute = async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const stream1 = await getNoiseStream({audio: true, video: true}); + t.add_cleanup(() => stopTracks(stream1)); + const audio1 = stream1.getAudioTracks()[0]; + pc1.addTrack(audio1, stream1); + const countMuteAudio1 = countEvents(pc1.getTransceivers()[0].receiver.track, "mute"); + const countUnmuteAudio1 = countEvents(pc1.getTransceivers()[0].receiver.track, "unmute"); + + const video1 = stream1.getVideoTracks()[0]; + pc1.addTrack(video1, stream1); + const countMuteVideo1 = countEvents(pc1.getTransceivers()[1].receiver.track, "mute"); + const countUnmuteVideo1 = countEvents(pc1.getTransceivers()[1].receiver.track, "unmute"); + + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + const stream2 = await getNoiseStream({audio: true, video: true}); + t.add_cleanup(() => stopTracks(stream2)); + const audio2 = stream2.getAudioTracks()[0]; + pc2.addTrack(audio2, stream2); + const countMuteAudio2 = countEvents(pc2.getTransceivers()[0].receiver.track, "mute"); + const countUnmuteAudio2 = countEvents(pc2.getTransceivers()[0].receiver.track, "unmute"); + + const video2 = stream2.getVideoTracks()[0]; + pc2.addTrack(video2, stream2); + const countMuteVideo2 = countEvents(pc2.getTransceivers()[1].receiver.track, "mute"); + const countUnmuteVideo2 = countEvents(pc2.getTransceivers()[1].receiver.track, "unmute"); + + + // Check that receive tracks start muted + hasProps(pc1.getTransceivers(), + [ + {receiver: {track: {kind: "audio", muted: true}}}, + {receiver: {track: {kind: "video", muted: true}}} + ]); + + hasProps(pc1.getTransceivers(), + [ + {receiver: {track: {kind: "audio", muted: true}}}, + {receiver: {track: {kind: "video", muted: true}}} + ]); + + let offer = await pc1.createOffer(); + await pc2.setRemoteDescription(offer); + trickle(t, pc1, pc2); + await pc1.setLocalDescription(offer); + let answer = await pc2.createAnswer(); + await pc1.setRemoteDescription(answer); + trickle(t, pc2, pc1); + await pc2.setLocalDescription(answer); + + let gotUnmuteAudio1 = gotUnmuteEvent(pc1.getTransceivers()[0].receiver.track); + let gotUnmuteVideo1 = gotUnmuteEvent(pc1.getTransceivers()[1].receiver.track); + + let gotUnmuteAudio2 = gotUnmuteEvent(pc2.getTransceivers()[0].receiver.track); + let gotUnmuteVideo2 = gotUnmuteEvent(pc2.getTransceivers()[1].receiver.track); + // Jump out before waiting if a track is unmuted before RTP starts flowing. + assert_true(pc1.getTransceivers()[0].receiver.track.muted); + assert_true(pc1.getTransceivers()[1].receiver.track.muted); + assert_true(pc2.getTransceivers()[0].receiver.track.muted); + assert_true(pc2.getTransceivers()[1].receiver.track.muted); + + await iceConnected(pc1); + await iceConnected(pc2); + + + // Check that receive tracks are unmuted when RTP starts flowing + await gotUnmuteAudio1; + await gotUnmuteVideo1; + await gotUnmuteAudio2; + await gotUnmuteVideo2; + + // Check whether disabling recv locally causes onmute + pc1.getTransceivers()[0].direction = "sendonly"; + pc1.getTransceivers()[1].direction = "sendonly"; + offer = await pc1.createOffer(); + await pc2.setRemoteDescription(offer); + await pc1.setLocalDescription(offer); + answer = await pc2.createAnswer(); + const gotMuteAudio1 = gotMuteEvent(pc1.getTransceivers()[0].receiver.track); + const gotMuteVideo1 = gotMuteEvent(pc1.getTransceivers()[1].receiver.track); + await pc1.setRemoteDescription(answer); + await pc2.setLocalDescription(answer); + await gotMuteAudio1; + await gotMuteVideo1; + + // Check whether disabling on remote causes onmute + pc1.getTransceivers()[0].direction = "inactive"; + pc1.getTransceivers()[1].direction = "inactive"; + offer = await pc1.createOffer(); + const gotMuteAudio2 = gotMuteEvent(pc2.getTransceivers()[0].receiver.track); + const gotMuteVideo2 = gotMuteEvent(pc2.getTransceivers()[1].receiver.track); + await pc2.setRemoteDescription(offer); + await gotMuteAudio2; + await gotMuteVideo2; + await pc1.setLocalDescription(offer); + answer = await pc2.createAnswer(); + await pc1.setRemoteDescription(answer); + await pc2.setLocalDescription(answer); + + // Check whether onunmute fires when we turn everything on again + pc1.getTransceivers()[0].direction = "sendrecv"; + pc1.getTransceivers()[1].direction = "sendrecv"; + offer = await pc1.createOffer(); + await pc2.setRemoteDescription(offer); + // Set these up before sLD, since that sets [[Receptive]] to true, which + // could allow an unmute to occur from a packet that was sent before we + // negotiated inactive! + gotUnmuteAudio1 = gotUnmuteEvent(pc1.getTransceivers()[0].receiver.track); + gotUnmuteVideo1 = gotUnmuteEvent(pc1.getTransceivers()[1].receiver.track); + await pc1.setLocalDescription(offer); + answer = await pc2.createAnswer(); + gotUnmuteAudio2 = gotUnmuteEvent(pc2.getTransceivers()[0].receiver.track); + gotUnmuteVideo2 = gotUnmuteEvent(pc2.getTransceivers()[1].receiver.track); + await pc1.setRemoteDescription(answer); + await pc2.setLocalDescription(answer); + await gotUnmuteAudio1; + await gotUnmuteVideo1; + await gotUnmuteAudio2; + await gotUnmuteVideo2; + + // Wait a little, just in case some stray events fire + await new Promise(r => t.step_timeout(r, 100)); + + assert_equals(1, countMuteAudio1.count, "Got 1 mute event for pc1's audio track"); + assert_equals(1, countMuteVideo1.count, "Got 1 mute event for pc1's video track"); + assert_equals(1, countMuteAudio2.count, "Got 1 mute event for pc2's audio track"); + assert_equals(1, countMuteVideo2.count, "Got 1 mute event for pc2's video track"); + assert_equals(2, countUnmuteAudio1.count, "Got 2 unmute events for pc1's audio track"); + assert_equals(2, countUnmuteVideo1.count, "Got 2 unmute events for pc1's video track"); + assert_equals(2, countUnmuteAudio2.count, "Got 2 unmute events for pc2's audio track"); + assert_equals(2, countUnmuteVideo2.count, "Got 2 unmute events for pc2's video track"); + }; + + const checkStop = async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTrack(track, stream); + + let offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + await pc2.setRemoteDescription(offer); + + pc2.addTrack(track, stream); + + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + + let stoppedTransceiver = pc1.getTransceivers()[0]; + let onended = new Promise(resolve => { + stoppedTransceiver.receiver.track.onended = resolve; + }); + stoppedTransceiver.stop(); + assert_equals(pc1.getReceivers().length, 1, 'getReceivers exposes a receiver of a stopped transceiver before negotiation'); + assert_equals(pc1.getSenders().length, 1, 'getSenders exposes a sender of a stopped transceiver before negotiation'); + await onended; + // The transceiver has [[stopping]] = true, [[stopped]] = false + hasPropsAndUniqueMids(pc1.getTransceivers(), + [ + { + sender: {track: {kind: "audio"}}, + receiver: {track: {kind: "audio", readyState: "ended"}}, + currentDirection: "sendrecv", + direction: "stopped" + } + ]); + + const transceiver = pc1.getTransceivers()[0]; + + checkThrows(() => transceiver.sender.setParameters( + transceiver.sender.getParameters()), + "InvalidStateError", "setParameters on stopped transceiver"); + + const stream2 = await getNoiseStream({audio: true}); + const track2 = stream.getAudioTracks()[0]; + checkThrows(() => transceiver.sender.replaceTrack(track2), + "InvalidStateError", "replaceTrack on stopped transceiver"); + + checkThrows(() => transceiver.direction = "sendrecv", + "InvalidStateError", "set direction on stopped transceiver"); + + checkThrows(() => transceiver.sender.dtmf.insertDTMF("111"), + "InvalidStateError", "insertDTMF on stopped transceiver"); + + // Shouldn't throw + stoppedTransceiver.stop(); + + offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + + const stoppedCalleeTransceiver = pc2.getTransceivers()[0]; + onended = new Promise(resolve => { + stoppedCalleeTransceiver.receiver.track.onended = resolve; + }); + + await pc2.setRemoteDescription(offer); + + await onended; + // pc2's transceiver was stopped remotely. + // The track ends when setRemeoteDescription(offer) is set. + hasProps(pc2.getTransceivers(), + [ + { + sender: {track: {kind: "audio"}}, + receiver: {track: {kind: "audio", readyState: "ended"}}, + currentDirection: "stopped", + direction: "stopped" + } + ]); + // After setLocalDescription(answer), the transceiver has + // [[stopping]] = true, [[stopped]] = true, and is removed from pc2. + const stoppingAnswer = await pc2.createAnswer(); + await pc2.setLocalDescription(stoppingAnswer); + assert_equals(pc2.getTransceivers().length, 0); + assert_equals(pc2.getReceivers().length, 0, 'getReceivers does not expose a receiver of a stopped transceiver after negotiation'); + assert_equals(pc2.getSenders().length, 0, 'getSenders does not expose a sender of a stopped transceiver after negotiation'); + + // Shouldn't throw either + stoppedTransceiver.stop(); + await pc1.setRemoteDescription(stoppingAnswer); + assert_equals(pc1.getReceivers().length, 0, 'getReceivers does not expose a receiver of a stopped transceiver after negotiation'); + assert_equals(pc1.getSenders().length, 0, 'getSenders does not expose a sender of a stopped transceiver after negotiation'); + + pc1.close(); + pc2.close(); + + // Spec says the closed check comes before the stopped check, so this + // should throw now. + checkThrows(() => stoppedTransceiver.stop(), + "InvalidStateError", "RTCRtpTransceiver.stop() with closed PC"); + }; + + const checkStopAfterCreateOffer = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTrack(track, stream); + pc2.addTrack(track, stream); + + let offer = await pc1.createOffer(); + + const transceiverThatWasStopped = pc1.getTransceivers()[0]; + transceiverThatWasStopped.stop(); + await pc2.setRemoteDescription(offer) + trickle(t, pc1, pc2); + await pc1.setLocalDescription(offer); + + let answer = await pc2.createAnswer(); + const negotiationNeededAwaiter = negotiationNeeded(pc1); + const trackEvents = await setRemoteDescriptionReturnTrackEvents(pc1, answer); + // Spec language doesn't say anything about checking whether the transceiver + // is stopped here. + hasProps(trackEvents, + [ + { + track: pc1.getTransceivers()[0].receiver.track, + streams: [{id: stream.id}] + } + ]); + + assert_equals(transceiverThatWasStopped, pc1.getTransceivers()[0]); + // The transceiver should still be [[stopping]]=true, [[stopped]]=false. + hasPropsAndUniqueMids(pc1.getTransceivers(), + [ + { + currentDirection: "sendrecv", + direction: "stopped" + } + ]); + + await negotiationNeededAwaiter; + + trickle(t, pc2, pc1); + + await pc2.setLocalDescription(answer); + + await iceConnected(pc1); + await iceConnected(pc2); + + offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + await pc2.setRemoteDescription(offer); + answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + assert_equals(pc1.getTransceivers().length, 0); + assert_equals(pc2.getTransceivers().length, 0); + }; + + const checkStopAfterSetLocalOffer = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTrack(track, stream); + pc2.addTrack(track, stream); + + let offer = await pc1.createOffer(); + + await pc2.setRemoteDescription(offer) + trickle(t, pc1, pc2); + await pc1.setLocalDescription(offer); + + pc1.getTransceivers()[0].stop(); + + let answer = await pc2.createAnswer(); + const negotiationNeededAwaiter = negotiationNeeded(pc1); + const trackEvents = await setRemoteDescriptionReturnTrackEvents(pc1, answer); + // Spec language doesn't say anything about checking whether the transceiver + // is stopped here. + hasProps(trackEvents, + [ + { + track: pc1.getTransceivers()[0].receiver.track, + streams: [{id: stream.id}] + } + ]); + + hasPropsAndUniqueMids(pc1.getTransceivers(), + [ + { + direction: "stopped", + currentDirection: "sendrecv" + } + ]); + await negotiationNeededAwaiter; + + trickle(t, pc2, pc1); + await pc2.setLocalDescription(answer); + + await iceConnected(pc1); + await iceConnected(pc2); + + offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + await pc2.setRemoteDescription(offer); + answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + + assert_equals(pc1.getTransceivers().length, 0); + assert_equals(pc2.getTransceivers().length, 0); + }; + + const checkStopAfterSetRemoteOffer = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTrack(track, stream); + pc2.addTrack(track, stream); + + const offer = await pc1.createOffer(); + + await pc2.setRemoteDescription(offer) + await pc1.setLocalDescription(offer); + + // Stop on _answerer_ side now. Should not stop transceiver in answer, + // but cause firing of negotiationNeeded at pc2, and disabling + // of the transceiver with direction = inactive in answer. + pc2.getTransceivers()[0].stop(); + assert_equals(pc2.getTransceivers()[0].direction, 'stopped'); + + const answer = await pc2.createAnswer(); + const trackEvents = await setRemoteDescriptionReturnTrackEvents(pc1, answer); + hasProps(trackEvents, []); + + hasProps(pc2.getTransceivers(), + [ + { + direction: "stopped", + currentDirection: null, + } + ]); + + const negotiationNeededAwaiter = negotiationNeeded(pc2); + await pc2.setLocalDescription(answer); + hasProps(pc2.getTransceivers(), + [ + { + direction: "stopped", + currentDirection: "inactive", + } + ]); + + await negotiationNeededAwaiter; + }; + + const checkStopAfterCreateAnswer = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTrack(track, stream); + pc2.addTrack(track, stream); + + let offer = await pc1.createOffer(); + + await pc2.setRemoteDescription(offer) + trickle(t, pc1, pc2); + await pc1.setLocalDescription(offer); + + let answer = await pc2.createAnswer(); + + // Too late for this to go in the answer. ICE should succeed. + pc2.getTransceivers()[0].stop(); + + const trackEvents = await setRemoteDescriptionReturnTrackEvents(pc1, answer); + hasProps(trackEvents, + [ + { + track: pc1.getTransceivers()[0].receiver.track, + streams: [{id: stream.id}] + } + ]); + + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + { + direction: "stopped", + currentDirection: null, + } + ]); + + trickle(t, pc2, pc1); + // The negotiationneeded event is fired during processing of + // setLocalDescription() + const negotiationNeededAwaiter = negotiationNeeded(pc2); + await pc2.setLocalDescription(answer); + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + { + direction: "stopped", + currentDirection: "sendrecv", + } + ]); + + await negotiationNeededAwaiter; + await iceConnected(pc1); + await iceConnected(pc2); + + offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + await pc2.setRemoteDescription(offer); + answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + + // Since this offer/answer exchange was initiated from pc1, + // pc2 still doesn't get to say that it has a stopped transceiver, + // but does get to set it to inactive. + hasProps(pc1.getTransceivers(), + [ + { + direction: "sendrecv", + currentDirection: "inactive", + } + ]); + + hasProps(pc2.getTransceivers(), + [ + { + direction: "stopped", + currentDirection: "inactive", + } + ]); + }; + + const checkStopAfterSetLocalAnswer = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTrack(track, stream); + pc2.addTrack(track, stream); + + let offer = await pc1.createOffer(); + + await pc2.setRemoteDescription(offer) + trickle(t, pc1, pc2); + await pc1.setLocalDescription(offer); + + let answer = await pc2.createAnswer(); + + const trackEvents = await setRemoteDescriptionReturnTrackEvents(pc1, answer); + hasProps(trackEvents, + [ + { + track: pc1.getTransceivers()[0].receiver.track, + streams: [{id: stream.id}] + } + ]); + + trickle(t, pc2, pc1); + await pc2.setLocalDescription(answer); + + // ICE should succeed. + pc2.getTransceivers()[0].stop(); + + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + { + direction: "stopped", + currentDirection: "sendrecv", + } + ]); + + await negotiationNeeded(pc2); + await iceConnected(pc1); + await iceConnected(pc2); + + // Initiate an offer/answer exchange from pc2 in order + // to negotiate the stopped transceiver. + offer = await pc2.createOffer(); + await pc2.setLocalDescription(offer); + await pc1.setRemoteDescription(offer); + answer = await pc1.createAnswer(); + await pc1.setLocalDescription(answer); + await pc2.setRemoteDescription(answer); + + assert_equals(pc1.getTransceivers().length, 0); + assert_equals(pc2.getTransceivers().length, 0); + }; + + const checkStopAfterClose = async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTrack(track, stream); + pc2.addTrack(track, stream); + + const offer = await pc1.createOffer(); + await pc2.setRemoteDescription(offer) + await pc1.setLocalDescription(offer); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + + pc1.close(); + await checkThrows(() => pc1.getTransceivers()[0].stop(), + "InvalidStateError", + "Stopping a transceiver on a closed PC should throw."); + }; + + const checkLocalRollback = async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc.addTrack(track, stream); + + let offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + hasPropsAndUniqueMids(pc.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track}, + direction: "sendrecv", + currentDirection: null, + } + ]); + + // Verify that rollback doesn't stomp things it should not + pc.getTransceivers()[0].direction = "sendonly"; + const stream2 = await getNoiseStream({audio: true}); + const track2 = stream2.getAudioTracks()[0]; + await pc.getTransceivers()[0].sender.replaceTrack(track2); + + await pc.setLocalDescription({type: "rollback"}); + + hasProps(pc.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: track2}, + direction: "sendonly", + mid: null, + currentDirection: null, + } + ]); + + // Make sure stop() isn't rolled back either. + offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + pc.getTransceivers()[0].stop(); + await pc.setLocalDescription({type: "rollback"}); + + hasProps(pc.getTransceivers(), [ + { + direction: "stopped", + } + ]); + }; + + const checkRollbackAndSetRemoteOfferWithDifferentType = async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + + const audioStream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(audioStream)); + const audioTrack = audioStream.getAudioTracks()[0]; + pc1.addTrack(audioTrack, audioStream); + + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const videoStream = await getNoiseStream({video: true}); + t.add_cleanup(() => stopTracks(videoStream)); + const videoTrack = videoStream.getVideoTracks()[0]; + pc2.addTrack(videoTrack, videoStream); + + await pc1.setLocalDescription(await pc1.createOffer()); + await pc1.setLocalDescription({type: "rollback"}); + + hasProps(pc1.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: audioTrack}, + direction: "sendrecv", + mid: null, + currentDirection: null, + } + ]); + + hasProps(pc2.getTransceivers(), + [ + { + receiver: {track: {kind: "video"}}, + sender: {track: videoTrack}, + direction: "sendrecv", + mid: null, + currentDirection: null, + } + ]); + + await offerAnswer(pc2, pc1); + + hasPropsAndUniqueMids(pc1.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: audioTrack}, + direction: "sendrecv", + mid: null, + currentDirection: null, + }, + { + receiver: {track: {kind: "video"}}, + sender: {track: null}, + direction: "recvonly", + currentDirection: "recvonly", + } + ]); + + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + { + receiver: {track: {kind: "video"}}, + sender: {track: videoTrack}, + direction: "sendrecv", + currentDirection: "sendonly", + } + ]); + + await offerAnswer(pc1, pc2); + }; + + const checkRemoteRollback = async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTrack(track, stream); + + let offer = await pc1.createOffer(); + + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + await pc2.setRemoteDescription(offer); + + const removedTransceiver = pc2.getTransceivers()[0]; + + const onended = new Promise(resolve => { + removedTransceiver.receiver.track.onended = resolve; + }); + + await pc2.setRemoteDescription({type: "rollback"}); + + // Transceiver should be _gone_ + hasProps(pc2.getTransceivers(), []); + + hasProps(removedTransceiver, + { + mid: null, + currentDirection: "stopped" + } + ); + + await onended; + + hasProps(removedTransceiver, + { + receiver: {track: {readyState: "ended"}}, + mid: null, + currentDirection: "stopped" + } + ); + + // Setting the same offer again should do the same thing as before + await pc2.setRemoteDescription(offer); + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: null}, + direction: "recvonly", + currentDirection: null, + } + ]); + + const mid0 = pc2.getTransceivers()[0].mid; + + // Give pc2 a track with replaceTrack + const stream2 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream2)); + const track2 = stream2.getAudioTracks()[0]; + await pc2.getTransceivers()[0].sender.replaceTrack(track2); + pc2.getTransceivers()[0].direction = "sendrecv"; + hasProps(pc2.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: track2}, + direction: "sendrecv", + mid: mid0, + currentDirection: null, + } + ]); + + await pc2.setRemoteDescription({type: "rollback"}); + + // Transceiver should be _gone_, again. replaceTrack doesn't prevent this, + // nor does setting direction. + hasProps(pc2.getTransceivers(), []); + + // Setting the same offer for a _third_ time should do the same thing + await pc2.setRemoteDescription(offer); + hasProps(pc2.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: null}, + direction: "recvonly", + mid: mid0, + currentDirection: null, + } + ]); + + // We should be able to add the same track again + pc2.addTrack(track2, stream2); + hasProps(pc2.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: track2}, + direction: "sendrecv", + mid: mid0, + currentDirection: null, + } + ]); + + await pc2.setRemoteDescription({type: "rollback"}); + // Transceiver should _not_ be gone this time, because addTrack touched it. + hasProps(pc2.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: track2}, + direction: "sendrecv", + mid: null, + currentDirection: null, + } + ]); + + // Complete negotiation so we can test interactions with transceiver.stop() + await pc1.setLocalDescription(offer); + + // After all this SRD/rollback, we should still get the track event + let trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, offer); + + assert_equals(trackEvents.length, 1); + hasProps(trackEvents, + [ + { + track: pc2.getTransceivers()[0].receiver.track, + streams: [{id: stream.id}] + } + ]); + + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + + // Make sure all this rollback hasn't messed up the signaling + trackEvents = await setRemoteDescriptionReturnTrackEvents(pc1, answer); + assert_equals(trackEvents.length, 1); + hasProps(trackEvents, + [ + { + track: pc1.getTransceivers()[0].receiver.track, + streams: [{id: stream2.id}] + } + ]); + hasProps(pc1.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track}, + direction: "sendrecv", + mid: mid0, + currentDirection: "sendrecv", + } + ]); + + // Don't bother waiting for ICE and such + + // Check to see whether rolling back a remote track removal works + pc1.getTransceivers()[0].direction = "recvonly"; + offer = await pc1.createOffer(); + + trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, offer); + hasProps(trackEvents, []); + + trackEvents = + await setRemoteDescriptionReturnTrackEvents(pc2, {type: "rollback"}); + + assert_equals(trackEvents.length, 1, 'track event from remote rollback'); + hasProps(trackEvents, + [ + { + track: pc2.getTransceivers()[0].receiver.track, + streams: [{id: stream.id}] + } + ]); + + // Check to see that stop() cannot be rolled back + pc1.getTransceivers()[0].stop(); + offer = await pc1.createOffer(); + + await pc2.setRemoteDescription(offer); + hasProps(pc2.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: track2}, + direction: "stopped", + mid: mid0, + currentDirection: "stopped", + } + ]); + + // stop() cannot be rolled back! + // Transceiver should have [[stopping]]=true, [[stopped]]=false. + await pc2.setRemoteDescription({type: "rollback"}); + hasProps(pc2.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: {kind: "audio"}}, + direction: "stopped", + mid: mid0, + currentDirection: "stopped", + } + ]); + }; + + const checkBundleTagRejected = async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream1 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream1)); + const track1 = stream1.getAudioTracks()[0]; + const stream2 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream2)); + const track2 = stream2.getAudioTracks()[0]; + + pc1.addTrack(track1, stream1); + pc1.addTrack(track2, stream2); + + await offerAnswer(pc1, pc2); + + pc2.getTransceivers()[0].stop(); + + await offerAnswer(pc1, pc2); + await offerAnswer(pc2, pc1); + }; + + const checkMsectionReuse = async t => { + // Use max-compat to make it easier to check for disabled m-sections + const pc1 = new RTCPeerConnection({ bundlePolicy: "max-compat" }); + const pc2 = new RTCPeerConnection({ bundlePolicy: "max-compat" }); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream)); + const track = stream.getAudioTracks()[0]; + pc1.addTrack(track, stream); + const [pc1Transceiver] = pc1.getTransceivers(); + + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + + // Answerer stops transceiver. The m-section is not immediately rejected + // (a follow-up O/A exchange is needed) but it should become inactive in + // the meantime. + const stoppedMid0 = pc2.getTransceivers()[0].mid; + const [pc2Transceiver] = pc2.getTransceivers(); + pc2Transceiver.stop(); + assert_equals(pc2.getTransceivers()[0].direction, "stopped"); + assert_not_equals(pc2.getTransceivers()[0].currentDirection, "stopped"); + + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + + // Still not stopped - but inactive is reflected! + assert_equals(pc1Transceiver.mid, stoppedMid0); + assert_equals(pc1Transceiver.direction, "sendrecv"); + assert_equals(pc1Transceiver.currentDirection, "inactive"); + assert_equals(pc2Transceiver.mid, stoppedMid0); + assert_equals(pc2Transceiver.direction, "stopped"); + assert_equals(pc2Transceiver.currentDirection, "inactive"); + + // Now do the follow-up O/A exchange pc2 -> pc1. + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + + // Now they're stopped, and have been removed from the PCs. + assert_equals(pc1.getTransceivers().length, 0); + assert_equals(pc2.getTransceivers().length, 0); + assert_equals(pc1Transceiver.mid, null); + assert_equals(pc1Transceiver.direction, "stopped"); + assert_equals(pc1Transceiver.currentDirection, "stopped"); + assert_equals(pc2Transceiver.mid, null); + assert_equals(pc2Transceiver.direction, "stopped"); + assert_equals(pc2Transceiver.currentDirection, "stopped"); + + // Check that m-section is reused on both ends + const stream2 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream2)); + const track2 = stream2.getAudioTracks()[0]; + + pc1.addTrack(track2, stream2); + let offer = await pc1.createOffer(); + assert_equals(offer.sdp.match(/m=/g).length, 1, + "Exactly one m-line in offer, because it was reused"); + hasProps(pc1.getTransceivers(), + [ + { + sender: {track: track2} + } + ]); + + assert_not_equals(pc1.getTransceivers()[0].mid, stoppedMid0); + + pc2.addTrack(track, stream); + offer = await pc2.createOffer(); + assert_equals(offer.sdp.match(/m=/g).length, 1, + "Exactly one m-line in offer, because it was reused"); + hasProps(pc2.getTransceivers(), + [ + { + sender: {track} + } + ]); + + assert_not_equals(pc2.getTransceivers()[0].mid, stoppedMid0); + + await pc2.setLocalDescription(offer); + await pc1.setRemoteDescription(offer); + let answer = await pc1.createAnswer(); + await pc1.setLocalDescription(answer); + await pc2.setRemoteDescription(answer); + hasPropsAndUniqueMids(pc1.getTransceivers(), + [ + { + sender: {track: track2}, + currentDirection: "sendrecv" + } + ]); + + const mid0 = pc1.getTransceivers()[0].mid; + + hasProps(pc2.getTransceivers(), + [ + { + sender: {track}, + currentDirection: "sendrecv", + mid: mid0 + } + ]); + + // stop the transceiver, and add a track. Verify that we don't reuse + // prematurely in our offer. (There should be one rejected m-section, and a + // new one for the new track) + const stoppedMid1 = pc1.getTransceivers()[0].mid; + pc1.getTransceivers()[0].stop(); + const stream3 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream3)); + const track3 = stream3.getAudioTracks()[0]; + pc1.addTrack(track3, stream3); + offer = await pc1.createOffer(); + assert_equals(offer.sdp.match(/m=/g).length, 2, + "Exactly 2 m-lines in offer, because it is too early to reuse"); + assert_equals(offer.sdp.match(/m=audio 0 /g).length, 1, + "One m-line is rejected"); + + await pc1.setLocalDescription(offer); + + let trackEvents = await setRemoteDescriptionReturnTrackEvents(pc2, offer); + hasProps(trackEvents, + [ + { + track: pc2.getTransceivers()[1].receiver.track, + streams: [{id: stream3.id}] + } + ]); + + answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + + trackEvents = await setRemoteDescriptionReturnTrackEvents(pc1, answer); + hasProps(trackEvents, []); + + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + { + sender: {track: null}, + currentDirection: "recvonly" + } + ]); + + // Verify that we don't reuse the mid from the stopped transceiver + const mid1 = pc2.getTransceivers()[0].mid; + assert_not_equals(mid1, stoppedMid1); + + pc2.addTrack(track3, stream3); + // There are two ways to handle this new track; reuse the recvonly + // transceiver created above, or create a new transceiver and reuse the + // disabled m-section. We're supposed to do the former. + offer = await pc2.createOffer(); + assert_equals(offer.sdp.match(/m=/g).length, 2, "Exactly 2 m-lines in offer"); + assert_equals(offer.sdp.match(/m=audio 0 /g).length, 1, + "One m-line is rejected, because the other was used"); + + hasProps(pc2.getTransceivers(), + [ + { + mid: mid1, + sender: {track: track3}, + currentDirection: "recvonly", + direction: "sendrecv" + } + ]); + + // Add _another_ track; this should reuse the disabled m-section + const stream4 = await getNoiseStream({audio: true}); + t.add_cleanup(() => stopTracks(stream4)); + const track4 = stream4.getAudioTracks()[0]; + pc2.addTrack(track4, stream4); + offer = await pc2.createOffer(); + await pc2.setLocalDescription(offer); + hasPropsAndUniqueMids(pc2.getTransceivers(), + [ + { + mid: mid1 + }, + { + sender: {track: track4}, + } + ]); + + // Fourth transceiver should have a new mid + assert_not_equals(pc2.getTransceivers()[1].mid, stoppedMid0); + assert_not_equals(pc2.getTransceivers()[1].mid, stoppedMid1); + + assert_equals(offer.sdp.match(/m=/g).length, 2, + "Exactly 2 m-lines in offer, because m-section was reused"); + assert_equals(offer.sdp.match(/m=audio 0 /g), null, + "No rejected m-line, because it was reused"); + }; + + const checkStopAfterCreateOfferWithReusedMsection = async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true, video: true}); + t.add_cleanup(() => stopTracks(stream)); + const audio = stream.getAudioTracks()[0]; + const video = stream.getVideoTracks()[0]; + + pc1.addTrack(audio, stream); + pc1.addTrack(video, stream); + + await offerAnswer(pc1, pc2); + pc1.getTransceivers()[1].stop(); + await offerAnswer(pc1, pc2); + + // Second (video) m-section has been negotiated disabled. + const transceiver = pc1.addTransceiver("video"); + const offer = await pc1.createOffer(); + transceiver.stop(); + await pc1.setLocalDescription(offer); + await pc2.setRemoteDescription(offer); + + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + }; + + const checkAddIceCandidateToStoppedTransceiver = async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true, video: true}); + t.add_cleanup(() => stopTracks(stream)); + const audio = stream.getAudioTracks()[0]; + const video = stream.getVideoTracks()[0]; + + pc1.addTrack(audio, stream); + pc1.addTrack(video, stream); + + pc2.addTrack(audio, stream); + pc2.addTrack(video, stream); + + await pc1.setLocalDescription(await pc1.createOffer()); + pc1.getTransceivers()[1].stop(); + pc1.setLocalDescription({type: "rollback"}); + + const offer = await pc2.createOffer(); + await pc2.setLocalDescription(offer); + await pc1.setRemoteDescription(offer); + + await pc1.addIceCandidate( + { + candidate: "candidate:0 1 UDP 2122252543 192.168.1.112 64261 typ host", + sdpMid: pc2.getTransceivers()[1].mid + }); + }; + +const tests = [ + checkAddTransceiverNoTrack, + checkAddTransceiverWithTrack, + checkAddTransceiverWithAddTrack, + checkAddTransceiverWithDirection, + checkAddTransceiverWithSetRemoteOfferSending, + checkAddTransceiverWithSetRemoteOfferNoSend, + checkAddTransceiverBadKind, + checkNoMidOffer, + checkNoMidAnswer, + checkSetDirection, + checkCurrentDirection, + checkSendrecvWithNoSendTrack, + checkSendrecvWithTracklessStream, + checkAddTransceiverNoTrackDoesntPair, + checkAddTransceiverWithTrackDoesntPair, + checkAddTransceiverThenReplaceTrackDoesntPair, + checkAddTransceiverThenAddTrackPairs, + checkAddTrackPairs, + checkReplaceTrackNullDoesntPreventPairing, + checkRemoveAndReadd, + checkAddTrackExistingTransceiverThenRemove, + checkRemoveTrackNegotiation, + checkMute, + checkStop, + checkStopAfterCreateOffer, + checkStopAfterSetLocalOffer, + checkStopAfterSetRemoteOffer, + checkStopAfterCreateAnswer, + checkStopAfterSetLocalAnswer, + checkStopAfterClose, + checkLocalRollback, + checkRollbackAndSetRemoteOfferWithDifferentType, + checkRemoteRollback, + checkMsectionReuse, + checkStopAfterCreateOfferWithReusedMsection, + checkAddIceCandidateToStoppedTransceiver, + checkBundleTagRejected +].forEach(test => promise_test(test, test.name)); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCSctpTransport-constructor.html b/testing/web-platform/tests/webrtc/RTCSctpTransport-constructor.html new file mode 100644 index 0000000000..484967f76b --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCSctpTransport-constructor.html @@ -0,0 +1,125 @@ +<!doctype html> +<meta charset="utf-8"> +<title>RTCSctpTransport constructor</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +// Test is based on the following revision: +// https://rawgit.com/w3c/webrtc-pc/1cc5bfc3ff18741033d804c4a71f7891242fb5b3/webrtc.html + +// The following helper functions are called from RTCPeerConnection-helper.js: +// generateDataChannelOffer() +// generateAnswer() + +/* + 6.1. + + partial interface RTCPeerConnection { + readonly attribute RTCSctpTransport? sctp; + ... + }; + + 6.1.1. + + interface RTCSctpTransport { + readonly attribute RTCDtlsTransport transport; + readonly attribute RTCSctpTransportState state; + readonly attribute unrestricted double maxMessageSize; + attribute EventHandler onstatechange; + }; + + 4.4.1.1. Constructor + 9. Let connection have an [[SctpTransport]] internal slot, initialized to null. + + 4.4.1.6. Set the RTCSessionSessionDescription + 2.2.6. If description is of type "answer" or "pranswer", then run the + following steps: + 1. If description initiates the establishment of a new SCTP association, as defined in + [SCTP-SDP], Sections 10.3 and 10.4, create an RTCSctpTransport with an initial state + of "connecting" and assign the result to the [[SctpTransport]] slot. + */ + +promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + assert_equals(pc1.sctp, null, 'RTCSctpTransport must be null'); + + const offer = await generateAudioReceiveOnlyOffer(pc1); + await Promise.all([pc1.setLocalDescription(offer), pc2.setRemoteDescription(offer)]); + const answer = await pc2.createAnswer(); + await pc1.setRemoteDescription(answer); + + assert_equals(pc1.sctp, null, 'RTCSctpTransport must remain null'); +}, 'setRemoteDescription() with answer not containing data media should not initialize pc.sctp'); + +promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + assert_equals(pc1.sctp, null, 'RTCSctpTransport must be null'); + + const offer = await generateAudioReceiveOnlyOffer(pc2); + await Promise.all([pc2.setLocalDescription(offer), pc1.setRemoteDescription(offer)]); + const answer = await pc1.createAnswer(); + await pc1.setLocalDescription(answer); + + assert_equals(pc1.sctp, null, 'RTCSctpTransport must remain null'); +}, 'setLocalDescription() with answer not containing data media should not initialize pc.sctp'); + +function validateSctpTransport(sctp) { + assert_not_equals(sctp, null, 'RTCSctpTransport must be available'); + + assert_true(sctp instanceof RTCSctpTransport, + 'Expect pc.sctp to be instance of RTCSctpTransport'); + + assert_true(sctp.transport instanceof RTCDtlsTransport, + 'Expect sctp.transport to be instance of RTCDtlsTransport'); + + assert_equals(sctp.state, 'connecting', 'RTCSctpTransport should be in the connecting state'); + + // Note: Yes, Number.POSITIVE_INFINITY is also a 'number' + assert_equals(typeof sctp.maxMessageSize, 'number', + 'Expect sctp.maxMessageSize to be a number'); +} + +promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + assert_equals(pc1.sctp, null, 'RTCSctpTransport must be null'); + + const offer = await generateDataChannelOffer(pc1); + await Promise.all([pc1.setLocalDescription(offer), pc2.setRemoteDescription(offer)]); + const answer = await pc2.createAnswer(); + await pc1.setRemoteDescription(answer); + + validateSctpTransport(pc1.sctp); +}, 'setRemoteDescription() with answer containing data media should initialize pc.sctp'); + +promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + assert_equals(pc1.sctp, null, 'RTCSctpTransport must be null'); + + const offer = await generateDataChannelOffer(pc2); + await Promise.all([pc2.setLocalDescription(offer), pc1.setRemoteDescription(offer)]); + const answer = await pc1.createAnswer(); + await pc1.setLocalDescription(answer); + + validateSctpTransport(pc1.sctp); +}, 'setLocalDescription() with answer containing data media should initialize pc.sctp'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCSctpTransport-events.html b/testing/web-platform/tests/webrtc/RTCSctpTransport-events.html new file mode 100644 index 0000000000..57b691a9cd --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCSctpTransport-events.html @@ -0,0 +1,55 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCIceTransport</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + pc1.createDataChannel(''); + assert_equals(null, pc1.sctp); + assert_equals(null, pc2.sctp); + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + assert_not_equals(null, pc1.sctp); + await pc2.setRemoteDescription(offer); + assert_not_equals(null, pc2.sctp); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + // Since this test does not exchange candidates, state remains "connecting". + assert_equals(pc1.sctp.state, "connecting"); + assert_equals(pc2.sctp.state, "connecting"); +}, 'SctpTransport objects are created at appropriate times'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + exchangeIceCandidates(pc1, pc2); + pc1.createDataChannel(''); + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + const pc1ConnectedWaiter = waitForState(pc1.sctp, 'connected'); + await pc2.setRemoteDescription(offer); + const pc2ConnectedWaiter = waitForState(pc2.sctp, 'connected'); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + await pc1ConnectedWaiter; + await pc2ConnectedWaiter; + const pc1ClosedWaiter = waitForState(pc1.sctp, 'closed'); + const pc2ClosedWaiter = waitForState(pc2.sctp, 'closed'); + pc1.close(); + await pc1ClosedWaiter; + await pc2ClosedWaiter; +}, 'SctpTransport reaches connected and closed state'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCSctpTransport-maxChannels.html b/testing/web-platform/tests/webrtc/RTCSctpTransport-maxChannels.html new file mode 100644 index 0000000000..b173e11c74 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCSctpTransport-maxChannels.html @@ -0,0 +1,49 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCSctpTransport.prototype.maxChannels</title> +<link rel="help" href="https://w3c.github.io/webrtc-pc/#rtcsctptransport-interface"> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +promise_test(async (t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_equals(pc.sctp, null, 'RTCSctpTransport must be null'); + pc.createDataChannel('test'); + const offer = await pc.createOffer(); + await pc.setRemoteDescription(offer); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + assert_not_equals(pc.sctp, null, 'RTCSctpTransport must be available'); + assert_equals(pc.sctp.maxChannels, null, 'maxChannels must not be set'); +}, 'An unconnected peerconnection must not have maxChannels set'); + +promise_test(async (t) => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + exchangeIceCandidates(pc1, pc2); + pc1.createDataChannel(''); + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + const pc1ConnectedWaiter = waitForState(pc1.sctp, 'connected'); + await pc2.setRemoteDescription(offer); + const pc2ConnectedWaiter = waitForState(pc2.sctp, 'connected'); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + assert_equals(null, pc1.sctp.maxChannels); + assert_equals(null, pc2.sctp.maxChannels); + await pc1ConnectedWaiter; + await pc2ConnectedWaiter; + assert_not_equals(null, pc1.sctp.maxChannels); + assert_not_equals(null, pc2.sctp.maxChannels); + assert_equals(pc1.sctp.maxChannels, pc2.sctp.maxChannels); +}, 'maxChannels gets instantiated after connecting'); +</script> diff --git a/testing/web-platform/tests/webrtc/RTCSctpTransport-maxMessageSize.html b/testing/web-platform/tests/webrtc/RTCSctpTransport-maxMessageSize.html new file mode 100644 index 0000000000..9976761150 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCSctpTransport-maxMessageSize.html @@ -0,0 +1,206 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCSctpTransport.prototype.maxMessageSize</title> +<link rel="help" href="https://w3c.github.io/webrtc-pc/#rtcsctptransport-interface"> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +// This test has an assert_unreached() that requires that the variable +// canSendSize (initiated below) must be 0 or greater than 2. The reason +// is that we need two non-zero values for testing the following two cases: +// +// * if remote MMS `1` < canSendSize it should result in `1`. +// * renegotiation of the above case with remoteMMS `2` should result in `2`. +// +// This is a bit unfortunate but shouldn't have any practical impact. + +// Helper class to read SDP attributes and generate SDPs with modified attribute values +class SDPAttributeHelper { + constructor(attrName, valueRegExpStr) { + this.attrName = attrName; + this.re = new RegExp(`^a=${attrName}:(${valueRegExpStr})\\r\\n`, 'm'); + } + + getValue(sdp) { + const matches = sdp.match(this.re); + return matches ? matches[1] : null; + } + + sdpWithValue(sdp, value) { + const matches = sdp.match(this.re); + const sdpParts = sdp.split(matches[0]); + const attributeLine = arguments.length > 1 ? `a=${this.attrName}:${value}\r\n` : ''; + return `${sdpParts[0]}${attributeLine}${sdpParts[1]}`; + } + + sdpWithoutAttribute(sdp) { + return this.sdpWithValue(sdp); + } +} + +const mmsAttributeHelper = new SDPAttributeHelper('max-message-size', '\\d+'); +let canSendSize = null; +const remoteSize1 = 1; +const remoteSize2 = 2; + +promise_test(async (t) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_equals(pc.sctp, null, 'RTCSctpTransport must be null'); + + let offer = await generateDataChannelOffer(pc); + assert_not_equals(mmsAttributeHelper.getValue(offer.sdp), null, + 'SDP should have max-message-size attribute'); + offer = { type: 'offer', sdp: mmsAttributeHelper.sdpWithValue(offer.sdp, 0) }; + await pc.setRemoteDescription(offer); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + assert_not_equals(pc.sctp, null, 'RTCSctpTransport must be available'); + canSendSize = pc.sctp.maxMessageSize === Number.POSITIVE_INFINITY ? 0 : pc.sctp.maxMessageSize; + if (canSendSize !== 0 && canSendSize < remoteSize2) { + assert_unreached( + 'This test needs canSendSize to be 0 or > 2 for further "below" and "above" tests'); + } +}, 'Determine the local side send limitation (canSendSize) by offering a max-message-size of 0'); + +promise_test(async (t) => { + assert_not_equals(canSendSize, null, 'canSendSize needs to be determined'); + + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_equals(pc.sctp, null, 'RTCSctpTransport must be null'); + + let offer = await generateDataChannelOffer(pc); + assert_not_equals(mmsAttributeHelper.getValue(offer.sdp), null, + 'SDP should have max-message-size attribute'); + + // Remove the max-message-size SDP attribute + offer = { type: 'offer', sdp: mmsAttributeHelper.sdpWithoutAttribute(offer.sdp) }; + await pc.setRemoteDescription(offer); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + assert_not_equals(pc.sctp, null, 'RTCSctpTransport must be available'); + // Test outcome depends on canSendSize value + if (canSendSize !== 0) { + assert_equals(pc.sctp.maxMessageSize, Math.min(65536, canSendSize), + 'Missing SDP attribute and a non-zero canSendSize should give an maxMessageSize of min(65536, canSendSize)'); + } else { + assert_equals(pc.sctp.maxMessageSize, 65536, + 'Missing SDP attribute and a canSendSize of 0 should give an maxMessageSize of 65536'); + } +}, 'Remote offer SDP missing max-message-size attribute'); + +promise_test(async (t) => { + assert_not_equals(canSendSize, null, 'canSendSize needs to be determined'); + + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_equals(pc.sctp, null, 'RTCSctpTransport must be null'); + + let offer = await generateDataChannelOffer(pc); + assert_not_equals(mmsAttributeHelper.getValue(offer.sdp), null, + 'SDP should have max-message-size attribute'); + + offer = { type: 'offer', sdp: mmsAttributeHelper.sdpWithValue(offer.sdp, remoteSize1) }; + await pc.setRemoteDescription(offer); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + assert_not_equals(pc.sctp, null, 'RTCSctpTransport must be available'); + assert_equals(pc.sctp.maxMessageSize, remoteSize1, + 'maxMessageSize should be the value provided by the remote peer (as long as it is less than canSendSize)'); +}, 'max-message-size with a (non-zero) value provided by the remote peer'); + +promise_test(async (t) => { + assert_not_equals(canSendSize, null, 'canSendSize needs to be determined'); + + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_equals(pc.sctp, null, 'RTCSctpTransport must be null'); + + let offer = await generateDataChannelOffer(pc); + assert_not_equals(mmsAttributeHelper.getValue(offer.sdp), null, + 'SDP should have max-message-size attribute'); + + offer = { type: 'offer', sdp: mmsAttributeHelper.sdpWithValue(offer.sdp, remoteSize1) }; + await pc.setRemoteDescription(offer); + let answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + assert_not_equals(pc.sctp, null, 'RTCSctpTransport must be available'); + assert_equals(pc.sctp.maxMessageSize, remoteSize1, + 'maxMessageSize should be the value provided by the remote peer (as long as it is less than canSendSize)'); + + // Start new O/A exchange that updates max-message-size to remoteSize2 + offer = await pc.createOffer(); + offer = { type: 'offer', sdp: mmsAttributeHelper.sdpWithValue(offer.sdp, remoteSize2)}; + await pc.setRemoteDescription(offer); + answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + assert_not_equals(pc.sctp, null, 'RTCSctpTransport must be available'); + assert_equals(pc.sctp.maxMessageSize, remoteSize2, + 'maxMessageSize should be the new value provided by the remote peer (as long as it is less than canSendSize)'); + + // Start new O/A exchange that updates max-message-size to zero + offer = await pc.createOffer(); + offer = { type: 'offer', sdp: mmsAttributeHelper.sdpWithValue(offer.sdp, 0)}; + await pc.setRemoteDescription(offer); + answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + assert_not_equals(pc.sctp, null, 'RTCSctpTransport must be available'); + assert_equals(pc.sctp.maxMessageSize, canSendSize, + 'maxMessageSize should be canSendSize'); + + // Start new O/A exchange that updates max-message-size to remoteSize1 again + offer = await pc.createOffer(); + offer = { type: 'offer', sdp: mmsAttributeHelper.sdpWithValue(offer.sdp, remoteSize1)}; + await pc.setRemoteDescription(offer); + answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + assert_not_equals(pc.sctp, null, 'RTCSctpTransport must be available'); + assert_equals(pc.sctp.maxMessageSize, remoteSize1, + 'maxMessageSize should be the new value provided by the remote peer (as long as it is less than canSendSize)'); +}, 'Renegotiate max-message-size with various values provided by the remote peer'); + +promise_test(async (t) => { + assert_not_equals(canSendSize, null, 'canSendSize needs to be determined'); + + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + assert_equals(pc.sctp, null, 'RTCSctpTransport must be null'); + const largerThanCanSendSize = canSendSize === 0 ? 0 : canSendSize + 1; + + let offer = await generateDataChannelOffer(pc); + assert_not_equals(mmsAttributeHelper.getValue(offer.sdp), null, + 'SDP should have max-message-size attribute'); + + offer = { type: 'offer', sdp: mmsAttributeHelper.sdpWithValue(offer.sdp, largerThanCanSendSize) }; + await pc.setRemoteDescription(offer); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + assert_not_equals(pc.sctp, null, 'RTCSctpTransport must be available'); + // Test outcome depends on canSendSize value + if (canSendSize !== 0) { + assert_equals(pc.sctp.maxMessageSize, canSendSize, + 'A remote value larger than a non-zero canSendSize should limit maxMessageSize to canSendSize'); + } else { + assert_equals(pc.sctp.maxMessageSize, Number.POSITIVE_INFINITY, + 'A remote value of zero and canSendSize zero should result in "infinity"'); + } +}, 'max-message-size with a (non-zero) value larger than canSendSize provided by the remote peer'); + +</script> diff --git a/testing/web-platform/tests/webrtc/RTCStats-helper.js b/testing/web-platform/tests/webrtc/RTCStats-helper.js new file mode 100644 index 0000000000..29d4940a8a --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCStats-helper.js @@ -0,0 +1,973 @@ +'use strict'; + +// Test is based on the following editor draft: +// webrtc-pc 20171130 +// webrtc-stats 20171122 + +// This file depends on dictionary-helper.js which should +// be loaded from the main HTML file. + +/* + [webrtc-stats] + 6.1. RTCStatsType enum + enum RTCStatsType { + "codec", + "inbound-rtp", + "outbound-rtp", + "remote-inbound-rtp", + "remote-outbound-rtp", + "csrc", + "peer-connection", + "data-channel", + "transport", + "candidate-pair", + "local-candidate", + "remote-candidate", + "certificate", + "ice-server" + }; + */ +const statsValidatorTable = { + 'codec': validateCodecStats, + 'inbound-rtp': validateInboundRtpStreamStats, + 'outbound-rtp': validateOutboundRtpStreamStats, + 'remote-inbound-rtp': validateRemoteInboundRtpStreamStats, + 'remote-outbound-rtp': validateRemoteOutboundRtpStreamStats, + 'media-source': validateMediaSourceStats, + 'csrc': validateContributingSourceStats, + 'peer-connection': validatePeerConnectionStats, + 'data-channel': validateDataChannelStats, + 'transport': validateTransportStats, + 'candidate-pair': validateIceCandidatePairStats, + 'local-candidate': validateIceCandidateStats, + 'remote-candidate': validateIceCandidateStats, + 'certificate': validateCertificateStats, + 'ice-server': validateIceServerStats +}; + +// Validate that the stats objects in a stats report +// follows the respective definitions. +// Stats objects with unknown type are ignored and +// only basic validation is done. +function validateStatsReport(statsReport) { + for(const [id, stats] of statsReport.entries()) { + assert_equals(stats.id, id, + 'expect stats.id to be the same as the key in statsReport'); + + const validator = statsValidatorTable[stats.type]; + if(validator) { + validator(statsReport, stats); + } else { + validateRtcStats(statsReport, stats); + } + } +} + +// Assert that the stats report have stats objects of +// given types +function assert_stats_report_has_stats(statsReport, statsTypes) { + const hasTypes = new Set([...statsReport.values()] + .map(stats => stats.type)); + + for(const type of statsTypes) { + assert_true(hasTypes.has(type), + `Expect statsReport to contain stats object of type ${type}`); + } +} + +function findStatsFromReport(statsReport, predicate, message) { + for (const stats of statsReport.values()) { + if (predicate(stats)) { + return stats; + } + } + + assert_unreached(message || 'none of stats in statsReport satisfy given condition') +} + +// Get stats object of type that is expected to be +// found in the statsReport +function getRequiredStats(statsReport, type) { + for(const stats of statsReport.values()) { + if(stats.type === type) { + return stats; + } + } + + assert_unreached(`required stats of type ${type} is not found in stats report`); +} + +// Get stats object by the stats ID. +// This is used to retreive other stats objects +// linked to a stats object +function getStatsById(statsReport, statsId) { + assert_true(statsReport.has(statsId), + `Expect stats report to have stats object with id ${statsId}`); + + return statsReport.get(statsId); +} + +// Validate an ID field in a stats object by making sure +// that the linked stats object is found in the stats report +// and have the type field value same as expected type +// It doesn't validate the other fields of the linked stats +// as validateStatsReport already does all validations +function validateIdField(statsReport, stats, field, type) { + assert_string_field(stats, field); + const linkedStats = getStatsById(statsReport, stats[field]); + assert_equals(linkedStats.type, type, + `Expect linked stats object to have type ${type}`); +} + +function validateOptionalIdField(statsReport, stats, field, type) { + if(stats[field] !== undefined) { + validateIdField(statsReport, stats, field, type); + } +} + +/* + [webrtc-pc] + 8.4. RTCStats Dictionary + dictionary RTCStats { + required DOMHighResTimeStamp timestamp; + required RTCStatsType type; + required DOMString id; + }; + */ +function validateRtcStats(statsReport, stats) { + assert_number_field(stats, 'timestamp'); + assert_string_field(stats, 'type'); + assert_string_field(stats, 'id'); +} + +/* + [webrtc-stats] + 7.1. RTCRtpStreamStats dictionary + dictionary RTCRtpStreamStats : RTCStats { + unsigned long ssrc; + DOMString kind; + DOMString transportId; + DOMString codecId; + }; + + kind of type DOMString + Either "audio" or "video". + + [webrtc-pc] + 8.6. Mandatory To Implement Stats + - RTCRtpStreamStats, with attributes ssrc, kind, transportId, codecId + */ +function validateRtpStreamStats(statsReport, stats) { + validateRtcStats(statsReport, stats); + + assert_unsigned_int_field(stats, 'ssrc'); + assert_string_field(stats, 'kind'); + assert_enum_field(stats, 'kind', ['audio', 'video']) + + validateIdField(statsReport, stats, 'transportId', 'transport'); + validateIdField(statsReport, stats, 'codecId', 'codec'); + +} + +/* + [webrtc-stats] + 7.2. RTCCodecStats dictionary + dictionary RTCCodecStats : RTCStats { + required unsigned long payloadType; + RTCCodecType codecType; + required DOMString transportId; + required DOMString mimeType; + unsigned long clockRate; + unsigned long channels; + DOMString sdpFmtpLine; + }; + + enum RTCCodecType { + "encode", + "decode", + }; + + [webrtc-pc] + 8.6. Mandatory To Implement Stats + - RTCCodecStats, with attributes payloadType, codecType, mimeType, clockRate, channels, sdpFmtpLine + */ + +function validateCodecStats(statsReport, stats) { + validateRtcStats(statsReport, stats); + + assert_unsigned_int_field(stats, 'payloadType'); + assert_optional_enum_field(stats, 'codecType', ['encode', 'decode']); + + validateOptionalIdField(statsReport, stats, 'transportId', 'transport'); + + assert_string_field(stats, 'mimeType'); + assert_unsigned_int_field(stats, 'clockRate'); + if (stats.kind === 'audio') { + assert_unsigned_int_field(stats, 'channels'); + } + assert_string_field(stats, 'sdpFmtpLine'); +} + +/* + [webrtc-stats] + 7.3. RTCReceivedRtpStreamStats dictionary + dictionary RTCReceivedRtpStreamStats : RTCRtpStreamStats { + unsigned long long packetsReceived; + long long packetsLost; + double jitter; + unsigned long long packetsDiscarded; + unsigned long long packetsRepaired; + unsigned long long burstPacketsLost; + unsigned long long burstPacketsDiscarded; + unsigned long burstLossCount; + unsigned long burstDiscardCount; + double burstLossRate; + double burstDiscardRate; + double gapLossRate; + double gapDiscardRate; + unsigned long framesDropped; + unsigned long partialFramesLost; + unsigned long fullFramesLost; + }; + + [webrtc-pc] + 8.6. Mandatory To Implement Stats + - RTCReceivedRtpStreamStats, with all required attributes from its + inherited dictionaries, and also attributes packetsReceived, + packetsLost, jitter, packetsDiscarded, framesDropped + */ +function validateReceivedRtpStreamStats(statsReport, stats) { + validateRtpStreamStats(statsReport, stats); + + assert_unsigned_int_field(stats, 'packetsReceived'); + assert_unsigned_int_field(stats, 'packetsLost'); + + assert_number_field(stats, 'jitter'); + + assert_unsigned_int_field(stats, 'packetsDiscarded'); + assert_unsigned_int_field(stats, 'framesDropped'); + + assert_optional_unsigned_int_field(stats, 'packetsRepaired'); + assert_optional_unsigned_int_field(stats, 'burstPacketsLost'); + assert_optional_unsigned_int_field(stats, 'burstPacketsDiscarded'); + assert_optional_unsigned_int_field(stats, 'burstLossCount'); + assert_optional_unsigned_int_field(stats, 'burstDiscardCount'); + + assert_optional_number_field(stats, 'burstLossRate'); + assert_optional_number_field(stats, 'burstDiscardRate'); + assert_optional_number_field(stats, 'gapLossRate'); + assert_optional_number_field(stats, 'gapDiscardRate'); + + assert_optional_unsigned_int_field(stats, 'partialFramesLost'); + assert_optional_unsigned_int_field(stats, 'fullFramesLost'); +} + +/* + [webrtc-stats] + 7.4. RTCInboundRtpStreamStats dictionary + dictionary RTCInboundRtpStreamStats : RTCReceivedRtpStreamStats { + DOMString trackIdentifier; + DOMString remoteId; + unsigned long framesDecoded; + unsigned long keyFramesDecoded; + unsigned long frameWidth; + unsigned long frameHeight; + unsigned long frameBitDepth; + double framesPerSecond; + unsigned long long qpSum; + double totalDecodeTime; + double totalInterFrameDelay; + double totalSquaredInterFrameDelay; + boolean voiceActivityFlag; + DOMHighResTimeStamp lastPacketReceivedTimestamp; + double averageRtcpInterval; + unsigned long long headerBytesReceived; + unsigned long long fecPacketsReceived; + unsigned long long fecPacketsDiscarded; + unsigned long long bytesReceived; + unsigned long long packetsFailedDecryption; + unsigned long long packetsDuplicated; + record<USVString, unsigned long long> perDscpPacketsReceived; + unsigned long nackCount; + unsigned long firCount; + unsigned long pliCount; + unsigned long sliCount; + DOMHighResTimeStamp estimatedPlayoutTimestamp; + double jitterBufferDelay; + unsigned long long jitterBufferEmittedCount; + unsigned long long totalSamplesReceived; + unsigned long long samplesDecodedWithSilk; + unsigned long long samplesDecodedWithCelt; + unsigned long long concealedSamples; + unsigned long long silentConcealedSamples; + unsigned long long concealmentEvents; + unsigned long long insertedSamplesForDeceleration; + unsigned long long removedSamplesForAcceleration; + double audioLevel; + double totalAudioEnergy; + double totalSamplesDuration; + unsigned long framesReceived; + DOMString decoderImplementation; + }; + + [webrtc-pc] + 8.6. Mandatory To Implement Stats + - RTCInboundRtpStreamStats, with all required attributes from its inherited + dictionaries, and also attributes remoteId, framesDecoded, nackCount, framesReceived, bytesReceived, totalAudioEnergy, totalSampleDuration + */ +function validateInboundRtpStreamStats(statsReport, stats) { + validateReceivedRtpStreamStats(statsReport, stats); + assert_string_field(stats, 'trackIdentifier'); + validateOptionalIdField(statsReport, stats, 'remoteId', 'remote-outbound-rtp'); + assert_unsigned_int_field(stats, 'framesDecoded'); + assert_optional_unsigned_int_field(stats, 'keyFramesDecoded'); + assert_optional_unsigned_int_field(stats, 'frameWidth'); + assert_optional_unsigned_int_field(stats, 'frameHeight'); + assert_optional_unsigned_int_field(stats, 'frameBitDepth'); + assert_optional_number_field(stats, 'framesPerSecond'); + assert_optional_unsigned_int_field(stats, 'qpSum'); + assert_optional_number_field(stats, 'totalDecodeTime'); + assert_optional_number_field(stats, 'totalInterFrameDelay'); + assert_optional_number_field(stats, 'totalSquaredInterFrameDelay'); + + assert_optional_boolean_field(stats, 'voiceActivityFlag'); + + assert_optional_number_field(stats, 'lastPacketReceivedTimeStamp'); + assert_optional_number_field(stats, 'averageRtcpInterval'); + + assert_optional_unsigned_int_field(stats, 'fecPacketsReceived'); + assert_optional_unsigned_int_field(stats, 'fecPacketsDiscarded'); + assert_unsigned_int_field(stats, 'bytesReceived'); + assert_optional_unsigned_int_field(stats, 'packetsFailedDecryption'); + assert_optional_unsigned_int_field(stats, 'packetsDuplicated'); + + assert_optional_dict_field(stats, 'perDscpPacketsReceived'); + if (stats['perDscpPacketsReceived']) { + Object.keys(stats['perDscpPacketsReceived']) + .forEach(k => + assert_equals(typeof k, 'string', 'Expect keys of perDscpPacketsReceived to be strings') + ); + Object.values(stats['perDscpPacketsReceived']) + .forEach(v => + assert_true(Number.isInteger(v) && (v >= 0), 'Expect values of perDscpPacketsReceived to be strings') + ); + } + + assert_unsigned_int_field(stats, 'nackCount'); + + assert_optional_unsigned_int_field(stats, 'firCount'); + assert_optional_unsigned_int_field(stats, 'pliCount'); + assert_optional_unsigned_int_field(stats, 'sliCount'); + + assert_optional_number_field(stats, 'estimatedPlayoutTimestamp'); + assert_optional_number_field(stats, 'jitterBufferDelay'); + assert_optional_unsigned_int_field(stats, 'jitterBufferEmittedCount'); + assert_optional_unsigned_int_field(stats, 'totalSamplesReceived'); + assert_optional_unsigned_int_field(stats, 'samplesDecodedWithSilk'); + assert_optional_unsigned_int_field(stats, 'samplesDecodedWithCelt'); + assert_optional_unsigned_int_field(stats, 'concealedSamples'); + assert_optional_unsigned_int_field(stats, 'silentConcealedSamples'); + assert_optional_unsigned_int_field(stats, 'concealmentEvents'); + assert_optional_unsigned_int_field(stats, 'insertedSamplesForDeceleration'); + assert_optional_unsigned_int_field(stats, 'removedSamplesForAcceleration'); + assert_optional_number_field(stats, 'audioLevel'); + assert_optional_number_field(stats, 'totalAudioEnergy'); + assert_optional_number_field(stats, 'totalSamplesDuration'); + assert_unsigned_int_field(stats, 'framesReceived'); + assert_optional_string_field(stats, 'decoderImplementation'); + assert_optional_boolean_field(stats, 'powerEfficientDecoder'); +} + +/* + [webrtc-stats] + 7.5. RTCRemoteInboundRtpStreamStats dictionary + dictionary RTCRemoteInboundRtpStreamStats : RTCReceivedRtpStreamStats { + DOMString localId; + double roundTripTime; + double totalRoundTripTime; + double fractionLost; + unsigned long long reportsReceived; + unsigned long long roundTripTimeMeasurements; + }; + + [webrtc-pc] + 8.6. Mandatory To Implement Stats + - RTCRemoteInboundRtpStreamStats, with all required attributes from its + inherited dictionaries, and also attributes localId, roundTripTime + */ +function validateRemoteInboundRtpStreamStats(statsReport, stats) { + validateReceivedRtpStreamStats(statsReport, stats); + + validateIdField(statsReport, stats, 'localId', 'outbound-rtp'); + assert_number_field(stats, 'roundTripTime'); + assert_optional_number_field(stats, 'totalRoundTripTime'); + assert_optional_number_field(stats, 'fractionLost'); + assert_optional_unsigned_int_field(stats, 'reportsReceived'); + assert_optional_unsigned_int_field(stats, 'roundTripTimeMeasurements'); +} + +/* + [webrtc-stats] + 7.6. RTCSentRtpStreamStats dictionary + dictionary RTCSentRtpStreamStats : RTCRtpStreamStats { + unsigned long packetsSent; + unsigned long long bytesSent; + }; + + [webrtc-pc] + 8.6. Mandatory To Implement Stats + - RTCSentRtpStreamStats, with all required attributes from its inherited + dictionaries, and also attributes packetsSent, bytesSent + */ +function validateSentRtpStreamStats(statsReport, stats) { + validateRtpStreamStats(statsReport, stats); + + assert_unsigned_int_field(stats, 'packetsSent'); + assert_unsigned_int_field(stats, 'bytesSent'); +} + +/* + [webrtc-stats] + 7.7. RTCOutboundRtpStreamStats dictionary + dictionary RTCOutboundRtpStreamStats : RTCSentRtpStreamStats { + DOMString mediaSourceId; + DOMString remoteId; + DOMString rid; + DOMHighResTimeStamp lastPacketSentTimestamp; + unsigned long long headerBytesSent; + unsigned long packetsDiscardedOnSend; + unsigned long long bytesDiscardedOnSend; + unsigned long fecPacketsSent; + unsigned long long retransmittedPacketsSent; + unsigned long long retransmittedBytesSent; + double targetBitrate; + unsigned long long totalEncodedBytesTarget; + unsigned long frameWidth; + unsigned long frameHeight; + unsigned long frameBitDepth; + double framesPerSecond; + unsigned long framesSent; + unsigned long hugeFramesSent; + unsigned long framesEncoded; + unsigned long keyFramesEncoded; + unsigned long framesDiscardedOnSend; + unsigned long long qpSum; + unsigned long long totalSamplesSent; + unsigned long long samplesEncodedWithSilk; + unsigned long long samplesEncodedWithCelt; + boolean voiceActivityFlag; + double totalEncodeTime; + double totalPacketSendDelay; + double averageRtcpInterval; + RTCQualityLimitationReason qualityLimitationReason; + record<DOMString, double> qualityLimitationDurations; + unsigned long qualityLimitationResolutionChanges; + record<USVString, unsigned long long> perDscpPacketsSent; + unsigned long nackCount; + unsigned long firCount; + unsigned long pliCount; + unsigned long sliCount; + DOMString encoderImplementation; + }; + [webrtc-pc] + 8.6. Mandatory To Implement Stats + - RTCOutboundRtpStreamStats, with all required attributes from its + inherited dictionaries, and also attributes remoteId, framesEncoded, nackCount, framesSent + */ +function validateOutboundRtpStreamStats(statsReport, stats) { + validateSentRtpStreamStats(statsReport, stats) + + validateOptionalIdField(statsReport, stats, 'mediaSourceId', 'media-source'); + validateOptionalIdField(statsReport, stats, 'remoteId', 'remote-inbound-rtp'); + + assert_optional_string_field(stats, 'rid'); + + assert_optional_number_field(stats, 'lastPacketSentTimestamp'); + assert_optional_unsigned_int_field(stats, 'headerBytesSent'); + assert_optional_unsigned_int_field(stats, 'packetsDiscardedOnSend'); + assert_optional_unsigned_int_field(stats, 'bytesDiscardedOnSend'); + assert_optional_unsigned_int_field(stats, 'fecPacketsSent'); + assert_optional_unsigned_int_field(stats, 'retransmittedPacketsSent'); + assert_optional_unsigned_int_field(stats, 'retransmittedBytesSent'); + assert_optional_number_field(stats, 'targetBitrate'); + assert_optional_unsigned_int_field(stats, 'totalEncodedBytesTarget'); + if (stats['kind'] === 'video') { + assert_optional_unsigned_int_field(stats, 'frameWidth'); + assert_optional_unsigned_int_field(stats, 'frameHeight'); + assert_optional_unsigned_int_field(stats, 'frameBitDepth'); + assert_optional_number_field(stats, 'framesPerSecond'); + assert_unsigned_int_field(stats, 'framesSent'); + assert_optional_unsigned_int_field(stats, 'hugeFramesSent'); + assert_unsigned_int_field(stats, 'framesEncoded'); + assert_optional_unsigned_int_field(stats, 'keyFramesEncoded'); + assert_optional_unsigned_int_field(stats, 'framesDiscardedOnSend'); + assert_optional_unsigned_int_field(stats, 'qpSum'); + } else if (stats['kind'] === 'audio') { + assert_optional_unsigned_int_field(stats, 'totalSamplesSent'); + assert_optional_unsigned_int_field(stats, 'samplesEncodedWithSilk'); + assert_optional_unsigned_int_field(stats, 'samplesEncodedWithCelt'); + assert_optional_boolean_field(stats, 'voiceActivityFlag'); + } + assert_optional_number_field(stats, 'totalEncodeTime'); + assert_optional_number_field(stats, 'totalPacketSendDelay'); + assert_optional_number_field(stats, 'averageRTCPInterval'); + + if (stats['kind'] === 'video') { + assert_optional_enum_field(stats, 'qualityLimitationReason', ['none', 'cpu', 'bandwidth', 'other']); + + assert_optional_dict_field(stats, 'qualityLimitationDurations'); + if (stats['qualityLimitationDurations']) { + Object.keys(stats['qualityLimitationDurations']) + .forEach(k => + assert_equals(typeof k, 'string', 'Expect keys of qualityLimitationDurations to be strings') + ); + Object.values(stats['qualityLimitationDurations']) + .forEach(v => + assert_equals(typeof num, 'number', 'Expect values of qualityLimitationDurations to be numbers') + ); + } + + assert_optional_unsigned_int_field(stats, 'qualityLimitationResolutionChanges'); + } + assert_unsigned_int_field(stats, 'nackCount'); + assert_optional_dict_field(stats, 'perDscpPacketsSent'); + if (stats['perDscpPacketsSent']) { + Object.keys(stats['perDscpPacketsSent']) + .forEach(k => + assert_equals(typeof k, 'string', 'Expect keys of perDscpPacketsSent to be strings') + ); + Object.values(stats['perDscpPacketsSent']) + .forEach(v => + assert_true(Number.isInteger(v) && (v >= 0), 'Expect values of perDscpPacketsSent to be strings') + ); + } + + assert_optional_unsigned_int_field(stats, 'firCount'); + assert_optional_unsigned_int_field(stats, 'pliCount'); + assert_optional_unsigned_int_field(stats, 'sliCount'); + assert_optional_string_field(stats, 'encoderImplementation'); + assert_optional_boolean_field(stats, 'powerEfficientEncoder'); + assert_optional_string_field(stats, 'scalabilityMode'); +} + +/* + [webrtc-stats] + 7.8. RTCRemoteOutboundRtpStreamStats dictionary + dictionary RTCRemoteOutboundRtpStreamStats : RTCSentRtpStreamStats { + DOMString localId; + DOMHighResTimeStamp remoteTimestamp; + unsigned long long reportsSent; + }; + + [webrtc-pc] + 8.6. Mandatory To Implement Stats + - RTCRemoteOutboundRtpStreamStats, with all required attributes from its + inherited dictionaries, and also attributes localId, remoteTimestamp + */ +function validateRemoteOutboundRtpStreamStats(statsReport, stats) { + validateSentRtpStreamStats(statsReport, stats); + + validateIdField(statsReport, stats, 'localId', 'inbound-rtp'); + assert_number_field(stats, 'remoteTimeStamp'); + assert_optional_unsigned_int_field(stats, 'reportsSent'); +} + +/* + [webrtc-stats] + 7.11 RTCMediaSourceStats dictionary + dictionary RTCMediaSourceStats : RTCStats { + DOMString trackIdentifier; + DOMString kind; + }; + + dictionary RTCAudioSourceStats : RTCMediaSourceStats { + double audioLevel; + double totalAudioEnergy; + double totalSamplesDuration; + double echoReturnLoss; + double echoReturnLossEnhancement; + }; + + dictionary RTCVideoSourceStats : RTCMediaSourceStats { + unsigned long width; + unsigned long height; + unsigned long bitDepth; + unsigned long frames; + // see https://github.com/w3c/webrtc-stats/issues/540 + double framesPerSecond; + }; + + [webrtc-pc] + 8.6. Mandatory To Implement Stats + RTCMediaSourceStats with attributes trackIdentifier, kind + RTCAudioSourceStats, with all required attributes from its inherited dictionaries and totalAudioEnergy, totalSamplesDuration + RTCVideoSourceStats, with all required attributes from its inherited dictionaries and width, height, framesPerSecond +*/ +function validateMediaSourceStats(statsReport, stats) { + validateRtcStats(statsReport, stats); + assert_string_field(stats, 'trackIdentifier'); + assert_enum_field(stats, 'kind', ['audio', 'video']); + + if (stats.kind === 'audio') { + assert_optional_number_field(stats, 'audioLevel'); + assert_number_field(stats, 'totalAudioEnergy'); + assert_number_field(stats, 'totalSamplesDuration'); + assert_optional_number_field(stats, 'echoReturnLoss'); + assert_optional_number_field(stats, 'echoReturnLossEnhancement'); + } else if (stats.kind === 'video') { + assert_unsigned_int_field(stats, 'width'); + assert_unsigned_int_field(stats, 'height'); + assert_optional_unsigned_int_field(stats, 'bitDpeth'); + assert_optional_unsigned_int_field(stats, 'frames'); + assert_number_field(stats, 'framesPerSecond'); + } +} + +/* + [webrtc-stats] + 7.9. RTCRTPContributingSourceStats + dictionary RTCRTPContributingSourceStats : RTCStats { + unsigned long contributorSsrc; + DOMString inboundRtpStreamId; + unsigned long packetsContributedTo; + double audioLevel; + }; + */ +function validateContributingSourceStats(statsReport, stats) { + validateRtcStats(statsReport, stats); + + assert_optional_unsigned_int_field(stats, 'contributorSsrc'); + + validateOptionalIdField(statsReport, stats, 'inboundRtpStreamId', 'inbound-rtp'); + assert_optional_unsigned_int_field(stats, 'packetsContributedTo'); + assert_optional_number_field(stats, 'audioLevel'); +} + +/* + [webrtc-stats] + 7.10. RTCPeerConnectionStats dictionary + dictionary RTCPeerConnectionStats : RTCStats { + unsigned long dataChannelsOpened; + unsigned long dataChannelsClosed; + unsigned long dataChannelsRequested; + unsigned long dataChannelsAccepted; + }; + + [webrtc-pc] + 8.6. Mandatory To Implement Stats + - RTCPeerConnectionStats, with attributes dataChannelsOpened, dataChannelsClosed + */ +function validatePeerConnectionStats(statsReport, stats) { + validateRtcStats(statsReport, stats); + + assert_unsigned_int_field(stats, 'dataChannelsOpened'); + assert_unsigned_int_field(stats, 'dataChannelsClosed'); + assert_optional_unsigned_int_field(stats, 'dataChannelsRequested'); + assert_optional_unsigned_int_field(stats, 'dataChannelsAccepted'); +} + +/* + [webrtc-stats] + 7.13. RTCDataChannelStats dictionary + dictionary RTCDataChannelStats : RTCStats { + DOMString label; + DOMString protocol; + // see https://github.com/w3c/webrtc-stats/issues/541 + unsigned short dataChannelIdentifier; + DOMString transportId; + RTCDataChannelState state; + unsigned long messagesSent; + unsigned long long bytesSent; + unsigned long messagesReceived; + unsigned long long bytesReceived; + }; + + [webrtc-pc] + 6.2. RTCDataChannel + enum RTCDataChannelState { + "connecting", + "open", + "closing", + "closed" + }; + + 8.6. Mandatory To Implement Stats + - RTCDataChannelStats, with attributes label, protocol, datachannelIdentifier, state, + messagesSent, bytesSent, messagesReceived, bytesReceived + */ +function validateDataChannelStats(statsReport, stats) { + validateRtcStats(statsReport, stats); + + assert_string_field(stats, 'label'); + assert_string_field(stats, 'protocol'); + assert_unsigned_int_field(stats, 'dataChannelIdentifier'); + + validateOptionalIdField(statsReport, stats, 'transportId', 'transport'); + + assert_enum_field(stats, 'state', + ['connecting', 'open', 'closing', 'closed']); + + assert_unsigned_int_field(stats, 'messagesSent'); + assert_unsigned_int_field(stats, 'bytesSent'); + assert_unsigned_int_field(stats, 'messagesReceived'); + assert_unsigned_int_field(stats, 'bytesReceived'); +} + +/* + [webrtc-stats] + 7.14. RTCTransportStats dictionary + dictionary RTCTransportStats : RTCStats { + unsigned long long packetsSent; + unsigned long long packetsReceived; + unsigned long long bytesSent; + unsigned long long bytesReceived; + DOMString rtcpTransportStatsId; + RTCIceRole iceRole; + RTCDtlsTransportState dtlsState; + DOMString selectedCandidatePairId; + DOMString localCertificateId; + DOMString remoteCertificateId; + DOMString tlsVersion; + DOMString dtlsCipher; + DOMString srtpCipher; + DOMString tlsGroup; + unsigned long selectedCandidatePairChanges; + }; + + [webrtc-pc] + 5.5. RTCDtlsTransportState Enum + enum RTCDtlsTransportState { + "new", + "connecting", + "connected", + "closed", + "failed" + }; + + 5.6. RTCIceRole Enum + enum RTCIceRole { + "unknown", + "controlling", + "controlled" + }; + + 8.6. Mandatory To Implement Stats + - RTCTransportStats, with attributes bytesSent, bytesReceived, + selectedCandidatePairId, localCertificateId, + remoteCertificateId + */ +function validateTransportStats(statsReport, stats) { + validateRtcStats(statsReport, stats); + + assert_optional_unsigned_int_field(stats, 'packetsSent'); + assert_optional_unsigned_int_field(stats, 'packetsReceived'); + assert_unsigned_int_field(stats, 'bytesSent'); + assert_unsigned_int_field(stats, 'bytesReceived'); + + validateOptionalIdField(statsReport, stats, 'rtcpTransportStatsId', + 'transport'); + + assert_optional_enum_field(stats, 'iceRole', + ['unknown', 'controlling', 'controlled']); + + assert_optional_enum_field(stats, 'dtlsState', + ['new', 'connecting', 'connected', 'closed', 'failed']); + + validateIdField(statsReport, stats, 'selectedCandidatePairId', 'candidate-pair'); + validateIdField(statsReport, stats, 'localCertificateId', 'certificate'); + validateIdField(statsReport, stats, 'remoteCertificateId', 'certificate'); + assert_optional_string_field(stats, 'tlsVersion'); + assert_optional_string_field(stats, 'dtlsCipher'); + assert_optional_string_field(stats, 'srtpCipher'); + assert_optional_string_field(stats, 'tlsGroup'); + assert_optional_unsigned_int_field(stats, 'selectedCandidatePairChanges'); +} + +/* + [webrtc-stats] + 7.15. RTCIceCandidateStats dictionary + dictionary RTCIceCandidateStats : RTCStats { + required DOMString transportId; + DOMString? address; + long port; + DOMString protocol; + RTCIceCandidateType candidateType; + long priority; + DOMString url; + DOMString relayProtocol; + }; + + [webrtc-pc] + 4.8.1.3. RTCIceCandidateType Enum + enum RTCIceCandidateType { + "host", + "srflx", + "prflx", + "relay" + }; + + 8.6. Mandatory To Implement Stats + - RTCIceCandidateStats, with attributes address, port, protocol, candidateType, url + */ +function validateIceCandidateStats(statsReport, stats) { + validateRtcStats(statsReport, stats); + + validateIdField(statsReport, stats, 'transportId', 'transport'); + // The address is mandatory to implement, but is allowed to be null + // when hidden for privacy reasons. + if (stats.address != null) { + // Departure from strict spec reading: + // This field is populated in a racy manner in Chrome. + // We allow it to be present or not present for the time being. + // TODO(https://bugs.chromium.org/1092721): Become consistent. + assert_optional_string_field(stats, 'address'); + } + assert_unsigned_int_field(stats, 'port'); + assert_string_field(stats, 'protocol'); + + assert_enum_field(stats, 'candidateType', + ['host', 'srflx', 'prflx', 'relay']); + + assert_optional_int_field(stats, 'priority'); + // The url field is mandatory for local candidates gathered from + // a STUN or TURN server, and MUST NOT be present otherwise. + // TODO(hta): Improve checking. + assert_optional_string_field(stats, 'url'); + assert_optional_string_field(stats, 'relayProtocol'); +} + +/* + [webrtc-stats] + 7.16. RTCIceCandidatePairStats dictionary + dictionary RTCIceCandidatePairStats : RTCStats { + DOMString transportId; + DOMString localCandidateId; + DOMString remoteCandidateId; + RTCStatsIceCandidatePairState state; + boolean nominated; + unsigned long packetsSent; + unsigned long packetsReceived; + unsigned long long bytesSent; + unsigned long long bytesReceived; + DOMHighResTimeStamp lastPacketSentTimestamp; + DOMHighResTimeStamp lastPacketReceivedTimestamp; + DOMHighResTimeStamp firstRequestTimestamp; + DOMHighResTimeStamp lastRequestTimestamp; + DOMHighResTimeStamp lastResponseTimestamp; + double totalRoundTripTime; + double currentRoundTripTime; + double availableOutgoingBitrate; + double availableIncomingBitrate; + unsigned long circuitBreakerTriggerCount; + unsigned long long requestsReceived; + unsigned long long requestsSent; + unsigned long long responsesReceived; + unsigned long long responsesSent; + unsigned long long retransmissionsReceived; + unsigned long long retransmissionsSent; + unsigned long long consentRequestsSent; + DOMHighResTimeStamp consentExpiredTimestamp; + unsigned long packetsDiscardedOnSend; + unsigned long long bytesDiscardedOnSend; }; + + enum RTCStatsIceCandidatePairState { + "frozen", + "waiting", + "in-progress", + "failed", + "succeeded" + }; + + [webrtc-pc] + 8.6. Mandatory To Implement Stats + - RTCIceCandidatePairStats, with attributes transportId, localCandidateId, + remoteCandidateId, state, nominated, bytesSent, bytesReceived, totalRoundTripTime, currentRoundTripTime + // not including priority per https://github.com/w3c/webrtc-pc/issues/2457 + */ +function validateIceCandidatePairStats(statsReport, stats) { + validateRtcStats(statsReport, stats); + + validateIdField(statsReport, stats, 'transportId', 'transport'); + validateIdField(statsReport, stats, 'localCandidateId', 'local-candidate'); + validateIdField(statsReport, stats, 'remoteCandidateId', 'remote-candidate'); + + assert_enum_field(stats, 'state', + ['frozen', 'waiting', 'in-progress', 'failed', 'succeeded']); + + assert_boolean_field(stats, 'nominated'); + assert_optional_unsigned_int_field(stats, 'packetsSent'); + assert_optional_unsigned_int_field(stats, 'packetsReceived'); + assert_unsigned_int_field(stats, 'bytesSent'); + assert_unsigned_int_field(stats, 'bytesReceived'); + + assert_optional_number_field(stats, 'lastPacketSentTimestamp'); + assert_optional_number_field(stats, 'lastPacketReceivedTimestamp'); + assert_optional_number_field(stats, 'firstRequestTimestamp'); + assert_optional_number_field(stats, 'lastRequestTimestamp'); + assert_optional_number_field(stats, 'lastResponseTimestamp'); + + assert_number_field(stats, 'totalRoundTripTime'); + assert_number_field(stats, 'currentRoundTripTime'); + + assert_optional_number_field(stats, 'availableOutgoingBitrate'); + assert_optional_number_field(stats, 'availableIncomingBitrate'); + + assert_optional_unsigned_int_field(stats, 'circuitBreakerTriggerCount'); + assert_optional_unsigned_int_field(stats, 'requestsReceived'); + assert_optional_unsigned_int_field(stats, 'requestsSent'); + assert_optional_unsigned_int_field(stats, 'responsesReceived'); + assert_optional_unsigned_int_field(stats, 'responsesSent'); + assert_optional_unsigned_int_field(stats, 'retransmissionsReceived'); + assert_optional_unsigned_int_field(stats, 'retransmissionsSent'); + assert_optional_unsigned_int_field(stats, 'consentRequestsSent'); + assert_optional_number_field(stats, 'consentExpiredTimestamp'); + assert_optional_unsigned_int_field(stats, 'packetsDiscardedOnSend'); + assert_optional_unsigned_int_field(stats, 'bytesDiscardedOnSend'); +} + +/* + [webrtc-stats] + 7.17. RTCCertificateStats dictionary + dictionary RTCCertificateStats : RTCStats { + DOMString fingerprint; + DOMString fingerprintAlgorithm; + DOMString base64Certificate; + DOMString issuerCertificateId; + }; + + [webrtc-pc] + 8.6. Mandatory To Implement Stats + - RTCCertificateStats, with attributes fingerprint, fingerprintAlgorithm, + base64Certificate, issuerCertificateId + */ +function validateCertificateStats(statsReport, stats) { + validateRtcStats(statsReport, stats); + + assert_string_field(stats, 'fingerprint'); + assert_string_field(stats, 'fingerprintAlgorithm'); + assert_string_field(stats, 'base64Certificate'); + assert_optional_string_field(stats, 'issuerCertificateId'); +} + +/* + [webrtc-stats] + 7.30. RTCIceServerStats dictionary + dictionary RTCIceServerStats : RTCStats { + DOMString url; + long port; + DOMString protocol; + unsigned long totalRequestsSent; + unsigned long totalResponsesReceived; + double totalRoundTripTime; + }; +*/ +function validateIceServerStats(statsReport, stats) { + validateRtcStats(statsReport, stats); + + assert_optional_string_field(stats, 'url'); + assert_optional_int_field(stats, 'port'); + assert_optional_string_field(stats, 'protocol'); + assert_optional_unsigned_int_field(stats, 'totalRequestsSent'); + assert_optional_unsigned_int_field(stats, 'totalResponsesReceived'); + assert_optional_number_field(stats, 'totalRoundTripTime'); +} diff --git a/testing/web-platform/tests/webrtc/RTCTrackEvent-constructor.html b/testing/web-platform/tests/webrtc/RTCTrackEvent-constructor.html new file mode 100644 index 0000000000..c9105e693a --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCTrackEvent-constructor.html @@ -0,0 +1,159 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCTrackEvent constructor</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> + 'use strict'; + + // Test is based on the following editor draft: + // https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + + /* + 5.7. RTCTrackEvent + [Constructor(DOMString type, RTCTrackEventInit eventInitDict)] + interface RTCTrackEvent : Event { + readonly attribute RTCRtpReceiver receiver; + readonly attribute MediaStreamTrack track; + [SameObject] + readonly attribute FrozenArray<MediaStream> streams; + readonly attribute RTCRtpTransceiver transceiver; + }; + + dictionary RTCTrackEventInit : EventInit { + required RTCRtpReceiver receiver; + required MediaStreamTrack track; + sequence<MediaStream> streams = []; + required RTCRtpTransceiver transceiver; + }; + */ + + test(t => { + const pc = new RTCPeerConnection(); + const transceiver = pc.addTransceiver('audio'); + const { receiver } = transceiver; + const { track } = receiver; + + const trackEvent = new RTCTrackEvent('track', { + receiver, track, transceiver + }); + + assert_equals(trackEvent.receiver, receiver); + assert_equals(trackEvent.track, track); + assert_array_equals(trackEvent.streams, []); + assert_equals(trackEvent.streams, trackEvent.streams, '[SameObject]'); + assert_equals(trackEvent.transceiver, transceiver); + + assert_equals(trackEvent.type, 'track'); + assert_false(trackEvent.bubbles); + assert_false(trackEvent.cancelable); + + }, `new RTCTrackEvent() with valid receiver, track, transceiver should succeed`); + + test(t => { + const pc = new RTCPeerConnection(); + const transceiver = pc.addTransceiver('audio'); + const { receiver } = transceiver; + const { track } = receiver; + + const stream = new MediaStream([track]); + + const trackEvent = new RTCTrackEvent('track', { + receiver, track, transceiver, + streams: [stream] + }); + + assert_equals(trackEvent.receiver, receiver); + assert_equals(trackEvent.track, track); + assert_array_equals(trackEvent.streams, [stream]); + assert_equals(trackEvent.transceiver, transceiver); + + }, `new RTCTrackEvent() with valid receiver, track, streams, transceiver should succeed`); + + test(t => { + const pc = new RTCPeerConnection(); + const transceiver = pc.addTransceiver('audio'); + const { receiver } = transceiver; + const { track } = receiver; + + const stream1 = new MediaStream([track]); + const stream2 = new MediaStream([track]); + + const trackEvent = new RTCTrackEvent('track', { + receiver, track, transceiver, + streams: [stream1, stream2] + }); + + assert_equals(trackEvent.receiver, receiver); + assert_equals(trackEvent.track, track); + assert_array_equals(trackEvent.streams, [stream1, stream2]); + assert_equals(trackEvent.transceiver, transceiver); + + }, `new RTCTrackEvent() with valid receiver, track, multiple streams, transceiver should succeed`); + + test(t => { + const pc = new RTCPeerConnection(); + const transceiver = pc.addTransceiver('audio'); + const receiver = pc.addTransceiver('audio').receiver; + const track = pc.addTransceiver('audio').receiver.track; + + const stream = new MediaStream(); + + const trackEvent = new RTCTrackEvent('track', { + receiver, track, transceiver, + streams: [stream] + }); + + assert_equals(trackEvent.receiver, receiver); + assert_equals(trackEvent.track, track); + assert_array_equals(trackEvent.streams, [stream]); + assert_equals(trackEvent.transceiver, transceiver); + + }, `new RTCTrackEvent() with unrelated receiver, track, streams, transceiver should succeed`); + + test(t => { + const pc = new RTCPeerConnection(); + const transceiver = pc.addTransceiver('audio'); + const { receiver } = transceiver; + const { track } = receiver; + + assert_throws_js(TypeError, () => + new RTCTrackEvent('track', { + receiver, track + })); + + }, `new RTCTrackEvent() with no transceiver should throw TypeError`); + + test(t => { + const pc = new RTCPeerConnection(); + const transceiver = pc.addTransceiver('audio'); + const { receiver } = transceiver; + + assert_throws_js(TypeError, () => + new RTCTrackEvent('track', { + receiver, transceiver + })); + + }, `new RTCTrackEvent() with no track should throw TypeError`); + + test(t => { + const pc = new RTCPeerConnection(); + const transceiver = pc.addTransceiver('audio'); + const { receiver } = transceiver; + const { track } = receiver; + + assert_throws_js(TypeError, () => + new RTCTrackEvent('track', { + track, transceiver + })); + + }, `new RTCTrackEvent() with no receiver should throw TypeError`); + + /* + Coverage Report + Interface tests are counted as 1 trivial test + + Tested 1 + Total 1 + */ +</script> diff --git a/testing/web-platform/tests/webrtc/RTCTrackEvent-fire.html b/testing/web-platform/tests/webrtc/RTCTrackEvent-fire.html new file mode 100644 index 0000000000..9435d7b6e5 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RTCTrackEvent-fire.html @@ -0,0 +1,168 @@ +<!doctype html> +<meta charset=utf-8> +<title>Change of msid in remote description should trigger related track events</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +const sdpBase =`v=0 +o=- 5511237691691746 2 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE 0 +a=ice-options:trickle +a=ice-lite +a=msid-semantic:WMS * +m=audio 9 UDP/TLS/RTP/SAVPF 111 103 9 102 0 8 105 13 110 113 126 +c=IN IP6 :: +a=rtcp:9 IN IP6 :: +a=rtcp-mux +a=mid:0 +a=sendrecv +a=ice-ufrag:z0i8R3C9C4hPRWls +a=ice-pwd:O7bPpOFAqasqoidV4yxnFVbc +a=ice-lite +a=fingerprint:sha-256 B7:9C:0D:C9:D1:42:57:97:82:4D:F9:B7:93:75:49:C3:42:21:5A:DD:9C:B5:ED:53:53:F0:B4:C8:AE:88:7A:E7 +a=setup:actpass +a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level +a=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid +a=rtpmap:0 PCMU/8000`; + +const sdp0 = sdpBase + ` +`; + +const sdp1 = sdpBase + ` +a=msid:1 2 +a=ssrc:3 cname:4 +a=ssrc:3 msid:1 2 +`; + +const sdp2 = sdpBase + ` +a=ssrc:3 cname:4 +a=ssrc:3 msid:1 2 +`; + +const sdp3 = sdpBase + ` +a=msid:1 2 +a=ssrc:3 cname:4 +a=ssrc:3 msid:3 2 +`; + +const sdp4 = sdp1.replace('msid-semantic', 'unknownattr'); + +const sdp5 = sdpBase + ` +a=msid:- +`; + +const sdp6 = sdpBase + ` +a=msid:1 2 +a=msid:1 2 +`; + +async function applyRemoteDescriptionAndReturnRemoteTrackAndStreams(pc, sdp) +{ + const testTrackPromise = new Promise(resolve => { + pc.ontrack = (event) => { resolve([event.track, event.streams]); }; + }); + await pc.setRemoteDescription({type: 'offer', sdp: sdp}); + return testTrackPromise; +} + +promise_test(async test => { + const pc = new RTCPeerConnection(); + test.add_cleanup(() => pc.close()); + + const [track, streams] = await applyRemoteDescriptionAndReturnRemoteTrackAndStreams(pc, sdp0); + assert_equals(streams.length, 1, "track event has a stream"); +}, "When a=msid is absent, the track should still be associated with a stream"); + +promise_test(async test => { + const pc = new RTCPeerConnection(); + test.add_cleanup(() => pc.close()); + + const [track, streams] = await applyRemoteDescriptionAndReturnRemoteTrackAndStreams(pc, sdp1); + assert_equals(streams.length, 1, "track event has a stream"); + assert_equals(streams[0].id, "1", "msid should match"); +}, "Source-level msid should be ignored if media-level msid is present"); + +promise_test(async test => { + const pc = new RTCPeerConnection(); + test.add_cleanup(() => pc.close()); + + const [track, streams] = await applyRemoteDescriptionAndReturnRemoteTrackAndStreams(pc, sdp2); + assert_equals(streams.length, 1, "track event has a stream"); + assert_equals(streams[0].id, "1", "msid should match"); +}, "Source-level msid should be parsed if media-level msid is absent"); + +promise_test(async test => { + const pc = new RTCPeerConnection(); + test.add_cleanup(() => pc.close()); + + let track; + let streams; + try { + [track, streams] = await applyRemoteDescriptionAndReturnRemoteTrackAndStreams(pc, sdp3); + } catch (e) { + return; + } + assert_equals(streams.length, 1, "track event has a stream"); + assert_equals(streams[0].id, "1", "msid should match"); +}, "Source-level msid should be ignored, or an error should be thrown, if a different media-level msid is present"); + +promise_test(async test => { + const pc = new RTCPeerConnection(); + test.add_cleanup(() => pc.close()); + + const [track, streams] = await applyRemoteDescriptionAndReturnRemoteTrackAndStreams(pc, sdp4); + assert_equals(streams.length, 1, "track event has a stream"); + assert_equals(streams[0].id, "1", "msid should match"); +}, "stream ids should be found even if msid-semantic is absent"); + +promise_test(async test => { + const pc = new RTCPeerConnection(); + test.add_cleanup(() => pc.close()); + + const [track, streams] = await applyRemoteDescriptionAndReturnRemoteTrackAndStreams(pc, sdp5); + assert_equals(streams.length, 0, "track event has no stream"); +}, "a=msid:- should result in a track event with no streams"); + +promise_test(async test => { + const pc = new RTCPeerConnection(); + test.add_cleanup(() => pc.close()); + + const [track, streams] = await applyRemoteDescriptionAndReturnRemoteTrackAndStreams(pc, sdp6); + assert_equals(streams.length, 1, "track event has one stream"); +}, "Duplicate a=msid should result in a track event with one stream"); + +promise_test(async test => { + const pc = new RTCPeerConnection(); + test.add_cleanup(() => pc.close()); + + const [track, streams] = await applyRemoteDescriptionAndReturnRemoteTrackAndStreams(pc, sdp1); + assert_equals(streams.length, 1, "track event has a stream"); + assert_equals(streams[0].id, "1", "msid should match"); + const stream = streams[0]; + + await pc.setLocalDescription(await pc.createAnswer()); + + const testTrackPromise = new Promise((resolve) => { stream.onremovetrack = resolve; }); + await pc.setRemoteDescription({type: 'offer', 'sdp': sdp0}); + await testTrackPromise; + + assert_equals(stream.getAudioTracks().length, 0, "stream should be empty"); +}, "Applying a remote description with removed msid should trigger firing a removetrack event on the corresponding stream"); + +promise_test(async test => { + const pc = new RTCPeerConnection(); + test.add_cleanup(() => pc.close()); + + let [track0, streams0] = await applyRemoteDescriptionAndReturnRemoteTrackAndStreams(pc, sdp0); + + await pc.setLocalDescription(await pc.createAnswer()); + + let [track1, streams1] = await applyRemoteDescriptionAndReturnRemoteTrackAndStreams(pc, sdp1); + + assert_equals(streams1.length, 1, "track event has a stream"); + assert_equals(streams1[0].id, "1", "msid should match"); + assert_equals(streams1[0].getTracks()[0], track0, "track should match"); +}, "Applying a remote description with a new msid should trigger firing an event with populated streams"); +</script> diff --git a/testing/web-platform/tests/webrtc/RollbackEvents.https.html b/testing/web-platform/tests/webrtc/RollbackEvents.https.html new file mode 100644 index 0000000000..25c83842c9 --- /dev/null +++ b/testing/web-platform/tests/webrtc/RollbackEvents.https.html @@ -0,0 +1,231 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +'use strict'; + +['audio', 'video'].forEach((kind) => { + // Make sure "ontrack" fires if a prevuously rolled back track is added back. + promise_test(async t => { + const constraints = {}; + constraints[kind] = true; + const stream = await navigator.mediaDevices.getUserMedia(constraints); + const [track] = stream.getTracks(); + t.add_cleanup(() => track.stop()); + + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTrack(track, stream); + pc2.addTrack(track, stream); + const [pc1Transceiver] = pc1.getTransceivers(); + const [pc2Transceiver] = pc2.getTransceivers(); + + let remoteStreamViaOnTrackPromise = getRemoteStreamViaOnTrackPromise(pc2); + + // Apply remote offer, but don't complete the entire exchange. + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + // The addTrack-transceiver gets associated, no need for a second + // transceiver. + assert_equals(pc2.getTransceivers().length, 1); + const remoteStream = await remoteStreamViaOnTrackPromise; + assert_equals(remoteStream.id, stream.id); + + const onRemoveTrackPromise = new Promise(r => { + remoteStream.onremovetrack = () => { r(); }; + }); + + // Cause track removal due to rollback. + await pc2.setRemoteDescription({type:'rollback'}); + // The track was removed. + await onRemoveTrackPromise; + + // Sanity check that ontrack still fires if we add it back again by applying + // the same remote offer. + remoteStreamViaOnTrackPromise = getRemoteStreamViaOnTrackPromise(pc2); + await pc2.setRemoteDescription(pc1.localDescription); + const revivedRemoteStream = await remoteStreamViaOnTrackPromise; + // This test only expects IDs to be the same. The same stream object should + // also be used, but this should be covered by separate tests. + // TODO(https://crbug.com/1321738): Add MediaStream identity tests. + assert_equals(remoteStream.id, revivedRemoteStream.id); + // No cheating, the same transciever should be used as before. + assert_equals(pc2.getTransceivers().length, 1); + }, `[${kind}] Track with stream: removal due to disassociation in rollback and then add it back again`); + + // This is the same test as above, but this time without any remote streams. + // This test could fail if [[FiredDirection]] was not reset in a rollback but + // the above version of the test might still pass due to the track being + // re-added to its stream. + promise_test(async t => { + const constraints = {}; + constraints[kind] = true; + const stream = await navigator.mediaDevices.getUserMedia(constraints); + const [track] = stream.getTracks(); + t.add_cleanup(() => track.stop()); + + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTrack(track); + pc2.addTrack(track); + const [pc1Transceiver] = pc1.getTransceivers(); + const [pc2Transceiver] = pc2.getTransceivers(); + + let remoteTrackPromise = getTrackViaOnTrackPromise(pc2); + + // Apply remote offer, but don't complete the entire exchange. + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + // The addTrack-transceiver gets associated, no need for a second + // transceiver. + assert_equals(pc2.getTransceivers().length, 1); + const remoteTrack = await remoteTrackPromise; + assert_not_equals(remoteTrack, null); + + // Cause track removal due to rollback. + await pc2.setRemoteDescription({type:'rollback'}); + // There's nothing equivalent to stream.onremovetrack when you don't have a + // stream, but the track should become muted (if it isn't already). + if (!remoteTrack.muted) { + await new Promise(r => remoteTrack.onmute = () => { r(); }); + } + assert_equals(remoteTrack.muted, true); + + // Sanity check that ontrack still fires if we add it back again by applying + // the same remote offer. + remoteTrackPromise = getTrackViaOnTrackPromise(pc2); + await pc2.setRemoteDescription(pc1.localDescription); + const revivedRemoteTrack = await remoteTrackPromise; + // We can be sure the same track is used, because the same transceiver is + // used (and transciever.receiver.track has same lifetime as transceiver). + assert_equals(pc2.getTransceivers().length, 1); + assert_equals(remoteTrack, revivedRemoteTrack); + }, `[${kind}] Track without stream: removal due to disassociation in rollback and then add it back`); + + // Make sure "ontrack" can fire in a rollback (undo making it inactive). + promise_test(async t => { + const constraints = {}; + constraints[kind] = true; + const stream = await navigator.mediaDevices.getUserMedia(constraints); + const [track] = stream.getTracks(); + t.add_cleanup(() => track.stop()); + + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTrack(track, stream); + const [pc1Transceiver] = pc1.getTransceivers(); + + let remoteStreamViaOnTrackPromise = getRemoteStreamViaOnTrackPromise(pc2); + + // Complete O/A exchange such that the transceiver gets associated. + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + const [pc2Transceiver] = pc2.getTransceivers(); + assert_equals(pc2Transceiver.direction, 'recvonly'); + assert_equals(pc2Transceiver.currentDirection, 'recvonly'); + + const remoteStream = await remoteStreamViaOnTrackPromise; + assert_equals(remoteStream.id, stream.id); + const onRemoveTrackPromise = new Promise(r => { + remoteStream.onremovetrack = () => { r(); }; + }); + + // Cause track removal. + pc1Transceiver.direction = 'inactive'; + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + // The track was removed. + await onRemoveTrackPromise; + + // Rolling back the offer revives the track, causing ontrack to fire again. + remoteStreamViaOnTrackPromise = getRemoteStreamViaOnTrackPromise(pc2); + await pc2.setRemoteDescription({type:'rollback'}); + const revivedRemoteStream = await remoteStreamViaOnTrackPromise; + // This test only expects IDs to be the same. The same stream object should + // also be used, but this should be covered by separate tests. + // TODO(https://crbug.com/1321738): Add MediaStream identity tests. + assert_equals(remoteStream.id, revivedRemoteStream.id); + }, `[${kind}] Track with stream: removal due to direction changing and then add back using rollback`); + + // Same test as above but without remote streams. + promise_test(async t => { + const constraints = {}; + constraints[kind] = true; + const stream = await navigator.mediaDevices.getUserMedia(constraints); + const [track] = stream.getTracks(); + t.add_cleanup(() => track.stop()); + + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTrack(track); + const [pc1Transceiver] = pc1.getTransceivers(); + + let remoteTrackPromise = getTrackViaOnTrackPromise(pc2); + + // Complete O/A exchange such that the transceiver gets associated. + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + const [pc2Transceiver] = pc2.getTransceivers(); + assert_equals(pc2Transceiver.direction, 'recvonly'); + assert_equals(pc2Transceiver.currentDirection, 'recvonly'); + + const remoteTrack = await remoteTrackPromise; + + // Cause track removal. + pc1Transceiver.direction = 'inactive'; + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + // There's nothing equivalent to stream.onremovetrack when you don't have a + // stream, but the track should become muted (if it isn't already). + if (!remoteTrack.muted) { + await new Promise(r => remoteTrack.onmute = () => { r(); }); + } + assert_equals(remoteTrack.muted, true); + + // Rolling back the offer revives the track, causing ontrack to fire again. + remoteTrackPromise = getTrackViaOnTrackPromise(pc2); + await pc2.setRemoteDescription({type:'rollback'}); + const revivedRemoteTrack = await remoteTrackPromise; + // We can be sure the same track is used, because the same transceiver is + // used (and transciever.receiver.track has same lifetime as transceiver). + assert_equals(pc2.getTransceivers().length, 1); + assert_equals(remoteTrack, revivedRemoteTrack); + }, `[${kind}] Track without stream: removal due to direction changing and then add back using rollback`); +}); + +function getTrackViaOnTrackPromise(pc) { + return new Promise(r => { + pc.ontrack = e => { + pc.ontrack = null; + r(e.track); + }; + }); +} + +function getRemoteStreamViaOnTrackPromise(pc) { + return new Promise(r => { + pc.ontrack = e => { + pc.ontrack = null; + r(e.streams[0]); + }; + }); +} + +</script> diff --git a/testing/web-platform/tests/webrtc/coverage/RTCDTMFSender.txt b/testing/web-platform/tests/webrtc/coverage/RTCDTMFSender.txt new file mode 100644 index 0000000000..aa30021323 --- /dev/null +++ b/testing/web-platform/tests/webrtc/coverage/RTCDTMFSender.txt @@ -0,0 +1,122 @@ +Coverage is based on the following editor draft: +https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + +7. insertDTMF + + [Trivial] + - The tones parameter is treated as a series of characters. + + [RTCDTMFSender-insertDTMF] + - The characters 0 through 9, A through D, #, and * generate the associated + DTMF tones. + + [RTCDTMFSender-insertDTMF] + - The characters a to d MUST be normalized to uppercase on entry and are equivalent + to A to D. + + [RTCDTMFSender-insertDTMF] + - As noted in [RTCWEB-AUDIO] Section 3, support for the characters 0 through 9, + A through D, #, and * are required. + + [RTCDTMFSender-insertDTMF] + - The character ',' MUST be supported, and indicates a delay of 2 seconds before + processing the next character in the tones parameter. + + [RTCDTMFSender-insertDTMF] + - All other characters (and only those other characters) MUST + be considered unrecognized. + + [Trivial] + - The duration parameter indicates the duration in ms to use for each character passed + in the tones parameters. + + [RTCDTMFSender-ontonechange] + - The duration cannot be more than 6000 ms or less than 40 ms. + + [RTCDTMFSender-ontonechange] + - The default duration is 100 ms for each tone. + + [RTCDTMFSender-ontonechange] + - The interToneGap parameter indicates the gap between tones in ms. The user agent + clamps it to at least 30 ms. The default value is 70 ms. + + [Untestable] + - The browser MAY increase the duration and interToneGap times to cause the times + that DTMF start and stop to align with the boundaries of RTP packets but it MUST + not increase either of them by more than the duration of a single RTP audio packet. + + [Trivial] + When the insertDTMF() method is invoked, the user agent MUST run the following steps: + + [Trivial] + 1. let sender be the RTCRtpSender used to send DTMF. + + [Trivial] + 2. Let transceiver be the RTCRtpTransceiver object associated with sender. + + [RTCDTMFSender-insertDTMF] + 3. If transceiver.stopped is true, throw an InvalidStateError. + + [RTCDTMFSender-insertDTMF] + 4. If transceiver.currentDirection is recvonly or inactive, throw an + InvalidStateError. + + [Trivial] + 5. Let tones be the method's first argument. + + [RTCDTMFSender-insertDTMF] + 6. If tones contains any unrecognized characters, throw an InvalidCharacterError. + + [RTCDTMFSender-insertDTMF] + 7. Set the object's toneBuffer attribute to tones. + + [RTCDTMFSender-ontonechange] + 8. If the value of the duration parameter is less than 40, set it to 40. + + [RTCDTMFSender-ontonechange-long] + If, on the other hand, the value is greater than 6000, set it to 6000. + + [RTCDTMFSender-ontonechange] + 9. If the value of the interToneGap parameter is less than 30, set it to 30. + + [RTCDTMFSender-ontonechange] + 10. If toneBuffer is an empty string, abort these steps. + + [RTCDTMFSender-ontonechange] + 11. If a Playout task is scheduled to be run; abort these steps; + + [RTCDTMFSender-ontonechange] + otherwise queue a task that runs the following steps (Playout task): + + [RTCDTMFSender-ontonechange] + 1. If transceiver.stopped is true, abort these steps. + + [RTCDTMFSender-ontonechange] + 2. If transceiver.currentDirection is recvonly or inactive, abort these steps. + + [RTCDTMFSender-ontonechange] + 3. If toneBuffer is an empty string, fire an event named tonechange with an + empty string at the RTCDTMFSender object and abort these steps. + + [RTCDTMFSender-ontonechange] + 4. Remove the first character from toneBuffer and let that character be tone. + + [Untestable] + 5. Start playout of tone for duration ms on the associated RTP media stream, + using the appropriate codec. + + [RTCDTMFSender-ontonechange] + 6. Queue a task to be executed in duration + interToneGap ms from now that + runs the steps labelled Playout task. + + [RTCDTMFSender-ontonechange] + 7. Fire an event named tonechange with a string consisting of tone at the + RTCDTMFSender object. + +Coverage Report + + Tested 31 + Not Tested 0 + Untestable 1 + + Total 32 diff --git a/testing/web-platform/tests/webrtc/coverage/identity.txt b/testing/web-platform/tests/webrtc/coverage/identity.txt new file mode 100644 index 0000000000..0d1bcca7ed --- /dev/null +++ b/testing/web-platform/tests/webrtc/coverage/identity.txt @@ -0,0 +1,220 @@ +Coverage is based on the following editor draft: +https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + +9.3 Requesting Identity Assertions + + [Trivial] + The identity assertion request process is triggered by a call to createOffer, + createAnswer, or getIdentityAssertion. When these calls are invoked and an + identity provider has been set, the following steps are executed: + + [RTCPeerConnection-getIdentityAssertion] + 1. The RTCPeerConnection instantiates an IdP as described in Identity Provider + Selection and Registering an IdP Proxy. If the IdP cannot be loaded, instantiated, + or the IdP proxy is not registered, this process fails. + + [RTCPeerConnection-getIdentityAssertion] + 2. The RTCPeerConnection invokes the generateAssertion method on the + RTCIdentityProvider methods registered by the IdP. + + [RTCPeerConnection-getIdentityAssertion] + The RTCPeerConnection generates the contents parameter to this method as + described in [RTCWEB-SECURITY-ARCH]. The value of contents includes the + fingerprint of the certificate that was selected or generated during the + construction of the RTCPeerConnection. The origin parameter contains the + origin of the script that calls the RTCPeerConnection method that triggers + this behavior. The usernameHint value is the same value that is provided + to setIdentityProvider, if any such value was provided. + + [RTCPeerConnection-getIdentityAssertion] + 3. The IdP proxy returns a Promise to the RTCPeerConnection. The IdP proxy is + expected to generate the identity assertion asynchronously. + + [RTCPeerConnection-getIdentityAssertion] + If the user has been authenticated by the IdP, and the IdP is able to generate + an identity assertion, the IdP resolves the promise with an identity assertion + in the form of an RTCIdentityAssertionResult . + + [RTCPeerConnection-getIdentityAssertion] + This step depends entirely on the IdP. The methods by which an IdP authenticates + users or generates assertions is not specified, though they could involve + interacting with the IdP server or other servers. + + [RTCPeerConnection-getIdentityAssertion] + 4. If the IdP proxy produces an error or returns a promise that does not resolve + to a valid RTCIdentityValidationResult (see 9.5 IdP Error Handling), then + identity validation fails. + + [Untestable] + 5. The RTCPeerConnection MAY store the identity assertion for use with future + offers or answers. If a fresh identity assertion is needed for any reason, + applications can create a new RTCPeerConnection. + + [RTCPeerConnection-getIdentityAssertion] + 6. If the identity request was triggered by a createOffer() or createAnswer(), + then the assertion is converted to a JSON string, base64-encoded and inserted + into an a=identity attribute in the session description. + + [RTCPeerConnection-getIdentityAssertion] + If assertion generation fails, then the promise for the corresponding function call + is rejected with a newly created OperationError. + +9.3.1 User Login Procedure + [RTCPeerConnection-getIdentityAssertion] + An IdP MAY reject an attempt to generate an identity assertion if it is unable to + verify that a user is authenticated. This might be due to the IdP not having the + necessary authentication information available to it (such as cookies). + + [RTCPeerConnection-getIdentityAssertion] + Rejecting the promise returned by generateAssertion will cause the error to propagate + to the application. Login errors are indicated by rejecting the promise with an RTCError + with errorDetail set to "idp-need-login". + + [RTCPeerConnection-getIdentityAssertion] + The URL to login at will be passed to the application in the idpLoginUrl attribute of + the RTCPeerConnection. + + [Out of Scope] + An application can load the login URL in an IFRAME or popup window; the resulting page + then SHOULD provide the user with an opportunity to enter any information necessary to + complete the authorization process. + + [Out of Scope] + Once the authorization process is complete, the page loaded in the IFRAME or popup sends + a message using postMessage [webmessaging] to the page that loaded it (through the + window.opener attribute for popups, or through window.parent for pages loaded in an IFRAME). + The message MUST consist of the DOMString "LOGINDONE". This message informs the application + that another attempt at generating an identity assertion is likely to be successful. + +9.4. Verifying Identity Assertions + The identity assertion request process involves the following asynchronous steps: + + [TODO] + 1. The RTCPeerConnection awaits any prior identity validation. Only one identity + validation can run at a time for an RTCPeerConnection. This can happen because + the resolution of setRemoteDescription is not blocked by identity validation + unless there is a target peer identity. + + [RTCPeerConnection-peerIdentity] + 2. The RTCPeerConnection loads the identity assertion from the session description + and decodes the base64 value, then parses the resulting JSON. The idp parameter + of the resulting dictionary contains a domain and an optional protocol value + that identifies the IdP, as described in [RTCWEB-SECURITY-ARCH]. + + [RTCPeerConnection-peerIdentity] + 3. The RTCPeerConnection instantiates the identified IdP as described in 9.1.1 + Identity Provider Selection and 9.2 Registering an IdP Proxy. If the IdP + cannot be loaded, instantiated or the IdP proxy is not registered, this + process fails. + + [RTCPeerConnection-peerIdentity] + 4. The RTCPeerConnection invokes the validateAssertion method registered by the IdP. + + [RTCPeerConnection-peerIdentity] + The assertion parameter is taken from the decoded identity assertion. The origin + parameter contains the origin of the script that calls the RTCPeerConnection + method that triggers this behavior. + + [RTCPeerConnection-peerIdentity] + 5. The IdP proxy returns a promise and performs the validation process asynchronously. + + [Out of Scope] + The IdP proxy verifies the identity assertion using whatever means necessary. + Depending on the authentication protocol this could involve interacting with the + IdP server. + + [RTCPeerConnection-peerIdentity] + 6. If the IdP proxy produces an error or returns a promise that does not resolve + to a valid RTCIdentityValidationResult (see 9.5 IdP Error Handling), then + identity validation fails. + + [RTCPeerConnection-peerIdentity] + 7. Once the assertion is successfully verified, the IdP proxy resolves the promise + with an RTCIdentityValidationResult containing the validated identity and the + original contents that are the payload of the assertion. + + [RTCPeerConnection-peerIdentity] + 8. The RTCPeerConnection decodes the contents and validates that it contains a + fingerprint value for every a=fingerprint attribute in the session description. + This ensures that the certificate used by the remote peer for communications + is covered by the identity assertion. + + [RTCPeerConnection-peerIdentity] + 9. The RTCPeerConnection validates that the domain portion of the identity matches + the domain of the IdP as described in [RTCWEB-SECURITY-ARCH]. If this check fails + then the identity validation fails. + + [RTCPeerConnection-peerIdentity] + 10. The RTCPeerConnection resolves the peerIdentity attribute with a new instance + of RTCIdentityAssertion that includes the IdP domain and peer identity. + + [Out of Scope] + 11. The user agent MAY display identity information to a user in its UI. Any user + identity information that is displayed in this fashion MUST use a mechanism that + cannot be spoofed by content. + + [RTCPeerConnection-peerIdentity] + If identity validation fails, the peerIdentity promise is rejected with a newly + created OperationError. + + [RTCPeerConnection-peerIdentity] + If identity validation fails and there is a target peer identity for the + RTCPeerConnection, the promise returned by setRemoteDescription MUST be rejected + with the same DOMException. + +9.5. IdP Error Handling + [RTCPeerConnection-getIdentityAssertion] + - A RTCPeerConnection might be configured with an identity provider, but loading of + the IdP URI fails. Any procedure that attempts to invoke such an identity provider + and cannot load the URI fails with an RTCError with errorDetail set to + "idp-load-failure" and the httpRequestStatusCode attribute of the error set to the + HTTP status code of the response. + + [Untestable] + - If the IdP loads fails due to the TLS certificate used for the HTTPS connection not + being trusted, it fails with an RTCError with errorDetail set to "idp-tls-failure". + This typically happens when the IdP uses certificate pinning and an intermediary + such as an enterprise firewall has intercepted the TLS connection. + + [RTCPeerConnection-getIdentityAssertion] + - If the script loaded from the identity provider is not valid JavaScript or does not + implement the correct interfaces, it causes an IdP failure with an RTCError with + errorDetail set to "idp-bad-script-failure". + + [TODO] + - An apparently valid identity provider might fail in several ways. + + If the IdP token has expired, then the IdP MUST fail with an RTCError with + errorDetail set to "idp-token-expired". + + If the IdP token is not valid, then the IdP MUST fail with an RTCError with + errorDetail set to "idp-token-invalid". + + [Untestable] + - The user agent SHOULD limit the time that it allows for an IdP to 15 seconds. + This includes both the loading of the IdP proxy and the identity assertion + generation or validation. Failure to do so potentially causes the corresponding + operation to take an indefinite amount of time. This timer can be cancelled when + the IdP proxy produces a response. Expiration of this timer cases an IdP failure + with an RTCError with errorDetail set to "idp-timeout". + + [RTCPeerConnection-getIdentityAssertion] + - If the identity provider requires the user to login, the operation will fail + RTCError with errorDetail set to "idp-need-login" and the idpLoginUrl attribute + of the error set to the URL that can be used to login. + + [RTCPeerConnection-peerIdentity] + - Even when the IdP proxy produces a positive result, the procedure that uses this + information might still fail. Additional validation of a RTCIdentityValidationResult + value is still necessary. The procedure for validation of identity assertions + describes additional steps that are required to successfully validate the output + of the IdP proxy. + + +Coverage Report + + Tested 29 + Not Tested 2 + Untestable 4 + + Total 35 diff --git a/testing/web-platform/tests/webrtc/coverage/set-session-description.txt b/testing/web-platform/tests/webrtc/coverage/set-session-description.txt new file mode 100644 index 0000000000..f2bb422703 --- /dev/null +++ b/testing/web-platform/tests/webrtc/coverage/set-session-description.txt @@ -0,0 +1,240 @@ +Coverage Report is based on the following editor draft: +https://w3c.github.io/webrtc-pc/archives/20170605/webrtc.html + +4.3.1.6 Set the RTCSessionSessionDescription + + [Trivial] + 1. Let p be a new promise. + + [Trivial] + 2. In parallel, start the process to apply description as described in [JSEP] + (section 5.5. and section 5.6.). + + [Trivial] + 1. If the process to apply description fails for any reason, then user agent + MUST queue a task that runs the following steps: + + [Untestable] + 1. If connection's [[IsClosed]] slot is true, then abort these steps. + + [Untestable] + 2. If elements of the SDP were modified, then reject p with a newly created + InvalidModificationError and abort these steps. + + [RTCPeerConnection-setLocalDescription-answer] + [RTCPeerConnection-setRemoteDescription-offer] + [RTCPeerConnection-setRemoteDescription-answer] + 3. If the description's type is invalid for the current signaling state of + connection as described in [JSEP] (section 5.5. and section 5.6.), then + reject p with a newly created InvalidStateError and abort these steps. + + [RTCPeerConnection-setRemoteDescription-offer] + 4. If the content of description is not valid SDP syntax, then reject p + with an RTCError (with errorDetail set to "sdp-syntax-error" and the + sdpLineNumber attribute set to the line number in the SDP where the + syntax error was detected) and abort these steps. + + [Untestable] + 5. If the content of description is invalid, then reject p with a newly + created InvalidAccessError and abort these steps. + + [Untestable] + 6. For all other errors, for example if description cannot be applied at + the media layer, reject p with a newly created OperationError. + + [Trivial] + 2. If description is applied successfully, the user agent MUST queue a task + that runs the following steps: + + [Untestable] + 1. If connection's [[isClosed]] slot is true, then abort these steps. + + [RTCPeerConnection-setLocalDescription] + 2. If description is set as a local description, then run one of the + following steps: + + [RTCPeerConnection-setLocalDescription-offer] + - If description is of type "offer", set connection.pendingLocalDescription + to description and signaling state to have-local-offer. + + [RTCPeerConnection-setLocalDescription-answer] + - If description is of type "answer", then this completes an offer answer + negotiation. + + Set connection's currentLocalDescription to description and + currentRemoteDescription to the value of pendingRemoteDescription. + + Set both pendingRemoteDescription and pendingLocalDescription to null. + Finally set connection's signaling state to stable + + [RTCPeerConnection-setLocalDescription-rollback] + - If description is of type "rollback", then this is a rollback. Set + connection.pendingLocalDescription to null and signaling state to stable. + + [RTCPeerConnection-setLocalDescription-pranswer] + - If description is of type "pranswer", then set + connection.pendingLocalDescription to description and signaling state to + have-local-pranswer. + + [RTCPeerConnection-setRemoteDescription] + 3. Otherwise, if description is set as a remote description, then run one of the + following steps: + + [RTCPeerConnection-setRemoteDescription-offer] + - If description is of type "offer", set connection.pendingRemoteDescription + attribute to description and signaling state to have-remote-offer. + + [RTCPeerConnection-setRemoteDescription-answer] + - If description is of type "answer", then this completes an offer answer + negotiation. + + Set connection's currentRemoteDescription to description and + currentLocalDescription to the value of pendingLocalDescription. + + Set both pendingRemoteDescription and pendingLocalDescription to null. + + Finally setconnection's signaling state to stable + + [RTCPeerConnection-setRemoteDescription-rollback] + - If description is of type "rollback", then this is a rollback. + Set connection.pendingRemoteDescription to null and signaling state to stable. + + [RTCPeerConnection-setRemoteDescription-rollback] + - If description is of type "pranswer", then set + connection.pendingRemoteDescription to description and signaling state + to have-remote-pranswer. + + [RTCPeerConnection-setLocalDescription] + [RTCPeerConnection-setRemoteDescription] + 4. If connection's signaling state changed above, fire a simple event named + signalingstatechange at connection. + + [TODO] + 5. If description is of type "answer", and it initiates the closure of an existing + SCTP association, as defined in [SCTP-SDP], Sections 10.3 and 10.4, set the value + of connection's [[sctpTransport]] internal slot to null. + + [RTCSctpTransport] + 6. If description is of type "answer" or "pranswer", then run the following steps: + + [RTCSctpTransport] + 1. If description initiates the establishment of a new SCTP association, + as defined in [SCTP-SDP], Sections 10.3 and 10.4, set the value of connection's + [[sctpTransport]] internal slot to a newly created RTCSctpTransport. + + [TODO] + 2. If description negotiates the DTLS role of the SCTP transport, and there is an + RTCDataChannel with a null id, then generate an ID according to + [RTCWEB-DATA-PROTOCOL]. + + [Untestable] + If no available ID could be generated, then run the following steps: + + [Untestable] + 1. Let channel be the RTCDataChannel object for which an ID could not be + generated. + + [Untestable] + 2. Set channel's readyState attribute to closed. + + [Untestable] + 3. Fire an event named error with a ResourceInUse exception at channel. + + [Untestable] + 4. Fire a simple event named close at channel. + + [TODO RTCPeerConnection-setDescription-transceiver] + 7. If description is set as a local description, then run the following steps for + each media description in description that is not yet associated with an + RTCRtpTransceiver object: + + [TODO RTCPeerConnection-setDescription-transceiver] + 1. Let transceiver be the RTCRtpTransceiver used to create the media + description. + + [TODO RTCPeerConnection-setDescription-transceiver] + 2. Set transceiver's mid value to the mid of the corresponding media + description. + + [RTCPeerConnection-ontrack] + 8. If description is set as a remote description, then run the following steps + for each media description in description: + + [TODO RTCPeerConnection-setDescription-transceiver] + 1. As described by [JSEP] (section 5.9.), attempt to find an existing + RTCRtpTransceiver object, transceiver, to represent the media description. + + [RTCPeerConnection-ontrack] + 2. If no suitable transceiver is found (transceiver is unset), run the following + steps: + + [RTCPeerConnection-ontrack] + 1. Create an RTCRtpSender, sender, from the media description. + + [RTCPeerConnection-ontrack] + 2. Create an RTCRtpReceiver, receiver, from the media description. + + [RTCPeerConnection-ontrack] + 3. Create an RTCRtpTransceiver with sender, receiver and direction, and let + transceiver be the result. + + [RTCPeerConnection-ontrack] + 3. Set transceiver's mid value to the mid of the corresponding media description. + If the media description has no MID, and transceiver's mid is unset, generate + a random value as described in [JSEP] (section 5.9.). + + [RTCPeerConnection-ontrack] + 4. If the direction of the media description is sendrecv or sendonly, and + transceiver.receiver.track has not yet been fired in a track event, process + the remote track for the media description, given transceiver. + + [TODO RTCPeerConnection-setDescription-transceiver] + 5. If the media description is rejected, and transceiver is not already stopped, + stop the RTCRtpTransceiver transceiver. + + + [TODO RTCPeerConnection-setDescription-transceiver] + 9. If description is of type "rollback", then run the following steps: + + [TODO RTCPeerConnection-setDescription-transceiver] + 1. If the mid value of an RTCRtpTransceiver was set to a non-null value by + the RTCSessionDescription that is being rolled back, set the mid value + of that transceiver to null, as described by [JSEP] (section 4.1.8.2.). + + [TODO RTCPeerConnection-setDescription-transceiver] + 2. If an RTCRtpTransceiver was created by applying the RTCSessionDescription + that is being rolled back, and a track has not been attached to it via + addTrack, remove that transceiver from connection's set of transceivers, + as described by [JSEP] (section 4.1.8.2.). + + [TODO RTCPeerConnection-setDescription-transceiver] + 3. Restore the value of connection's [[SctpTransport]] internal slot to its + value at the last stable signaling state. + + [RTCPeerConnection-onnegotiationneeded] + 10. If connection's signaling state is now stable, update the negotiation-needed + flag. If connection's [[NegotiationNeeded]] slot was true both before and after + this update, queue a task that runs the following steps: + + [Untestable] + 1. If connection's [[IsClosed]] slot is true, abort these steps. + + [RTCPeerConnection-onnegotiationneeded] + 2. If connection's [[NegotiationNeeded]] slot is false, abort these steps. + + [RTCPeerConnection-onnegotiationneeded] + 3. Fire a simple event named negotiationneeded at connection. + + [Trivial] + 11. Resolve p with undefined. + + [Trivial] + 3. Return p. + + +Coverage Report + + Tested 35 + Not Tested 15 + Untestable 8 + Total 58 diff --git a/testing/web-platform/tests/webrtc/dictionary-helper.js b/testing/web-platform/tests/webrtc/dictionary-helper.js new file mode 100644 index 0000000000..dab7e49fad --- /dev/null +++ b/testing/web-platform/tests/webrtc/dictionary-helper.js @@ -0,0 +1,101 @@ +'use strict'; + +// Helper assertion functions to validate dictionary fields +// on dictionary objects returned from APIs + +function assert_unsigned_int_field(object, field) { + const num = object[field]; + assert_true(Number.isInteger(num) && (num >= 0), + `Expect dictionary.${field} to be unsigned integer`); +} + +function assert_int_field(object, field) { + const num = object[field]; + assert_true(Number.isInteger(num), + `Expect dictionary.${field} to be integer`); +} + +function assert_string_field(object, field) { + const str = object[field]; + assert_equals(typeof str, 'string', + `Expect dictionary.${field} to be string`); +} + +function assert_number_field(object, field) { + const num = object[field]; + assert_equals(typeof num, 'number', + `Expect dictionary.${field} to be number`); +} + +function assert_boolean_field(object, field) { + const bool = object[field]; + assert_equals(typeof bool, 'boolean', + `Expect dictionary.${field} to be boolean`); +} + +function assert_array_field(object, field) { + assert_true(Array.isArray(object[field]), + `Expect dictionary.${field} to be array`); +} + +function assert_dict_field(object, field) { + assert_equals(typeof object[field], 'object', + `Expect dictionary.${field} to be plain object`); + + assert_not_equals(object[field], null, + `Expect dictionary.${field} to not be null`); +} + +function assert_enum_field(object, field, validValues) { + assert_string_field(object, field); + assert_true(validValues.includes(object[field]), + `Expect dictionary.${field} to have one of the valid enum values: ${validValues}`); +} + +function assert_optional_unsigned_int_field(object, field) { + if(object[field] !== undefined) { + assert_unsigned_int_field(object, field); + } +} + +function assert_optional_int_field(object, field) { + if(object[field] !== undefined) { + assert_int_field(object, field); + } +} + +function assert_optional_string_field(object, field) { + if(object[field] !== undefined) { + assert_string_field(object, field); + } +} + +function assert_optional_number_field(object, field) { + if(object[field] !== undefined) { + assert_number_field(object, field); + } +} + +function assert_optional_boolean_field(object, field) { + if(object[field] !== undefined) { + assert_boolean_field(object, field); + } +} + +function assert_optional_array_field(object, field) { + if(object[field] !== undefined) { + assert_array_field(object, field); + } +} + +function assert_optional_dict_field(object, field) { + if(object[field] !== undefined) { + assert_dict_field(object, field); + } +} + +function assert_optional_enum_field(object, field, validValues) { + if(object[field] !== undefined) { + assert_enum_field(object, field, validValues); + } +} diff --git a/testing/web-platform/tests/webrtc/getstats.html b/testing/web-platform/tests/webrtc/getstats.html new file mode 100644 index 0000000000..d6a692bb78 --- /dev/null +++ b/testing/web-platform/tests/webrtc/getstats.html @@ -0,0 +1,130 @@ +<!doctype html> +<!-- +This test uses data only, and thus does not require fake media devices. +--> + +<html> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title>RTCPeerConnection GetStats</title> +</head> +<body> + <div id="log"></div> + <h2>Retrieved stats info</h2> + <pre> + <input type="button" onclick="showStats()" value="Show stats"></input> + <div id="stats"> + </div> + </pre> + + <!-- These files are in place when executing on W3C. --> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script type="text/javascript"> + var test = async_test('Can get stats from a basic WebRTC call.'); + var statsToShow; + var gFirstConnection = null; + var gSecondConnection = null; + + var onIceCandidateToFirst = test.step_func(function(event) { + gSecondConnection.addIceCandidate(event.candidate); + }); + + var onIceCandidateToSecond = test.step_func(function(event) { + gFirstConnection.addIceCandidate(event.candidate); + }); + + var getStatsRecordByType = function(stats, type) { + for (let stat of stats.values()) { + if (stat.type == type) { + return stat; + } + } + return null; + } + + var onIceConnectionStateChange = test.step_func(function(event) { + // Wait until connection is established. + // Note - not all browsers reach 'completed' state, so we're + // checking for 'connected' state instead. + if (gFirstConnection.iceConnectionState != 'connected') { + return; + } + gFirstConnection.getStats() + .then(function(report) { + let reportDictionary = {}; + for (let stats of report.values()) { + reportDictionary[stats.id] = stats; + } + statsToShow = JSON.stringify(reportDictionary, null, 2); + // Check the stats properties. + assert_not_equals(report, null, 'No report'); + let sessionStat = getStatsRecordByType(report, 'peer-connection'); + assert_not_equals(sessionStat, null, 'Did not find peer-connection stats'); + assert_own_property(sessionStat, 'dataChannelsOpened', 'no dataChannelsOpened stat'); + // Once every 4000 or so tests, the datachannel won't be opened when the getStats + // function is done, so allow both 0 and 1 datachannels. + assert_true(sessionStat.dataChannelsOpened == 1 || sessionStat.dataChannelsOpened == 0, + 'dataChannelsOpened count wrong'); + test.done(); + }) + .catch(test.step_func(function(e) { + assert_unreached(e.name + ': ' + e.message + ': '); + })); + }); + + // This function starts the test. + test.step(function() { + gFirstConnection = new RTCPeerConnection(null); + test.add_cleanup(() => gFirstConnection.close()); + gFirstConnection.onicecandidate = onIceCandidateToFirst; + gFirstConnection.oniceconnectionstatechange = onIceConnectionStateChange; + + gSecondConnection = new RTCPeerConnection(null); + test.add_cleanup(() => gSecondConnection.close()); + gSecondConnection.onicecandidate = onIceCandidateToSecond; + + // The createDataChannel is necessary and sufficient to make + // sure the ICE connection be attempted. + gFirstConnection.createDataChannel('channel'); + var atStep = 'Create offer'; + + gFirstConnection.createOffer() + .then(function(offer) { + atStep = 'Set local description at first'; + return gFirstConnection.setLocalDescription(offer); + }) + .then(function() { + atStep = 'Set remote description at second'; + return gSecondConnection.setRemoteDescription( + gFirstConnection.localDescription); + }) + .then(function() { + atStep = 'Create answer'; + return gSecondConnection.createAnswer(); + }) + .then(function(answer) { + atStep = 'Set local description at second'; + return gSecondConnection.setLocalDescription(answer); + }) + .then(function() { + atStep = 'Set remote description at first'; + return gFirstConnection.setRemoteDescription( + gSecondConnection.localDescription); + }) + .catch(test.step_func(function(e) { + assert_unreached('Error ' + e.name + ': ' + e.message + + ' happened at step ' + atStep); + })); + }); + + function showStats() { + // Show the retrieved stats info + var showStats = document.getElementById('stats'); + showStats.innerHTML = statsToShow; + } + +</script> + +</body> +</html> diff --git a/testing/web-platform/tests/webrtc/historical.html b/testing/web-platform/tests/webrtc/historical.html new file mode 100644 index 0000000000..ae7a29dec0 --- /dev/null +++ b/testing/web-platform/tests/webrtc/historical.html @@ -0,0 +1,51 @@ +<!doctype html> +<title>Historical WebRTC features</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +[ + 'reliable', + 'maxRetransmitTime', +].forEach((member) => { + test(() => { + assert_false(member in RTCDataChannel.prototype); + }, `RTCDataChannel member ${member} should not exist`); +}); + +[ + "addStream", + "createDTMFSender", + "getLocalStreams", + "getRemoteStreams", + "getStreamById", + "onaddstream", + "onremovestream", + "removeStream", + "updateIce", +].forEach(function(name) { + test(function() { + assert_false(name in RTCPeerConnection.prototype); + }, "RTCPeerConnection member " + name + " should not exist"); +}); + +[ + "setDirection", +].forEach(function(name) { + test(function() { + assert_false(name in RTCRtpTransceiver.prototype); + }, "RTCRtpTransceiver member " + name + " should not exist"); +}); + +[ + "DataChannel", + "mozRTCIceCandidate", + "mozRTCPeerConnection", + "mozRTCSessionDescription", + "webkitRTCPeerConnection", +].forEach(function(name) { + test(function() { + assert_false(name in window); + }, name + " interface should not exist"); +}); +</script> diff --git a/testing/web-platform/tests/webrtc/idlharness.https.window.js b/testing/web-platform/tests/webrtc/idlharness.https.window.js new file mode 100644 index 0000000000..98685f1cd1 --- /dev/null +++ b/testing/web-platform/tests/webrtc/idlharness.https.window.js @@ -0,0 +1,146 @@ +// META: script=/resources/WebIDLParser.js +// META: script=/resources/idlharness.js +// META: script=./RTCPeerConnection-helper.js +// META: timeout=long + +'use strict'; + +// The following helper functions are called from RTCPeerConnection-helper.js: +// generateAnswer() +// getNoiseStream() + +// Put the global IDL test objects under a parent object. +// This allows easier search for the test cases when +// viewing the web page +const idlTestObjects = {}; + +// Helper function to create RTCTrackEvent object +function initTrackEvent() { + const pc = new RTCPeerConnection(); + const transceiver = pc.addTransceiver('audio'); + const { sender, receiver } = transceiver; + const { track } = receiver; + return new RTCTrackEvent('track', { + receiver, track, transceiver + }); +} + +// List of async test driver functions +const asyncInitTasks = [ + asyncInitCertificate, + asyncInitTransports, + asyncInitMediaStreamTrack, +]; + +// Asynchronously generate an RTCCertificate +function asyncInitCertificate() { + return RTCPeerConnection.generateCertificate({ + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256' + }).then(cert => { + idlTestObjects.certificate = cert; + }); +} + +// Asynchronously generate instances of +// RTCSctpTransport, RTCDtlsTransport, +// and RTCIceTransport +function asyncInitTransports() { + const pc = new RTCPeerConnection(); + pc.createDataChannel('test'); + + // setting answer description initializes pc.sctp + return pc.createOffer() + .then(offer => + pc.setLocalDescription(offer) + .then(() => generateAnswer(offer))) + .then(answer => pc.setRemoteDescription(answer)) + .then(() => { + const sctpTransport = pc.sctp; + assert_true(sctpTransport instanceof RTCSctpTransport, + 'Expect pc.sctp to be instance of RTCSctpTransport'); + idlTestObjects.sctpTransport = sctpTransport; + + const dtlsTransport = sctpTransport.transport; + assert_true(dtlsTransport instanceof RTCDtlsTransport, + 'Expect sctpTransport.transport to be instance of RTCDtlsTransport'); + idlTestObjects.dtlsTransport = dtlsTransport; + + const iceTransport = dtlsTransport.iceTransport; + assert_true(iceTransport instanceof RTCIceTransport, + 'Expect sctpTransport.transport to be instance of RTCDtlsTransport'); + idlTestObjects.iceTransport = iceTransport; + }); +} + +// Asynchoronously generate MediaStreamTrack from getUserMedia +function asyncInitMediaStreamTrack() { + return getNoiseStream({ audio: true }) + .then(mediaStream => { + idlTestObjects.mediaStreamTrack = mediaStream.getTracks()[0]; + }); +} + +// Run all async test drivers, report and swallow any error +// thrown/rejected. Proper test for correct initialization +// of the objects are done in their respective test files. +function asyncInit() { + return Promise.all(asyncInitTasks.map( + task => { + const t = async_test(`Test driver for ${task.name}`); + let promise; + t.step(() => { + promise = task().then( + t.step_func_done(), + t.step_func(err => + assert_unreached(`Failed to run ${task.name}: ${err}`))); + }); + return promise; + })); +} + +idl_test( + ['webrtc'], + ['webidl', 'mediacapture-streams', 'hr-time', 'dom', 'html'], + async idlArray => { + idlArray.add_objects({ + RTCPeerConnection: [`new RTCPeerConnection()`], + RTCSessionDescription: [`new RTCSessionDescription({ type: 'offer' })`], + RTCIceCandidate: [`new RTCIceCandidate({ sdpMid: 1 })`], + RTCDataChannel: [`new RTCPeerConnection().createDataChannel('')`], + RTCRtpTransceiver: [`new RTCPeerConnection().addTransceiver('audio')`], + RTCRtpSender: [`new RTCPeerConnection().addTransceiver('audio').sender`], + RTCRtpReceiver: [`new RTCPeerConnection().addTransceiver('audio').receiver`], + RTCPeerConnectionIceEvent: [`new RTCPeerConnectionIceEvent('ice')`], + RTCPeerConnectionIceErrorEvent: [ + `new RTCPeerConnectionIceErrorEvent('ice-error', { port: 0, errorCode: 701 });` + ], + RTCTrackEvent: [`initTrackEvent()`], + RTCErrorEvent: [`new RTCErrorEvent('error')`], + RTCDataChannelEvent: [ + `new RTCDataChannelEvent('channel', { + channel: new RTCPeerConnection().createDataChannel('') + })` + ], + // Async initialized objects below + RTCCertificate: ['idlTestObjects.certificate'], + RTCSctpTransport: ['idlTestObjects.sctpTransport'], + RTCDtlsTransport: ['idlTestObjects.dtlsTransport'], + RTCIceTransport: ['idlTestObjects.iceTransport'], + MediaStreamTrack: ['idlTestObjects.mediaStreamTrack'], + }); + /* + TODO + RTCRtpContributingSource + RTCRtpSynchronizationSource + RTCDTMFSender + RTCDTMFToneChangeEvent + RTCIdentityProviderRegistrar + RTCIdentityAssertion + */ + + await asyncInit(); + } +); diff --git a/testing/web-platform/tests/webrtc/legacy/README.txt b/testing/web-platform/tests/webrtc/legacy/README.txt new file mode 100644 index 0000000000..8adbf6aa17 --- /dev/null +++ b/testing/web-platform/tests/webrtc/legacy/README.txt @@ -0,0 +1,2 @@ +This directory contains files that test for behavior relevant to webrtc, +particularly defined in https://w3c.github.io/webrtc-pc/#legacy-interface-extensions diff --git a/testing/web-platform/tests/webrtc/legacy/RTCPeerConnection-createOffer-offerToReceive.html b/testing/web-platform/tests/webrtc/legacy/RTCPeerConnection-createOffer-offerToReceive.html new file mode 100644 index 0000000000..f710498e75 --- /dev/null +++ b/testing/web-platform/tests/webrtc/legacy/RTCPeerConnection-createOffer-offerToReceive.html @@ -0,0 +1,274 @@ +<!doctype html> +<meta charset=utf-8> +<title>Test legacy offerToReceiveAudio/Video options</title> +<link rel="help" href="https://w3c.github.io/webrtc-pc/#legacy-configuration-extensions"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + /* + * 4.3.3.2 Configuration data extensions + * partial dictionary RTCOfferOptions + */ + + /* + * offerToReceiveAudio of type boolean + * When this is given a non-false value, no outgoing track of type + * "audio" is attached to the PeerConnection, and the existing + * localDescription (if any) doesn't contain any sendrecv or recv + * audio media sections, createOffer() will behave as if + * addTransceiver("audio") had been called once prior to the createOffer() call. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.createOffer({ offerToReceiveAudio: true }) + .then(offer1 => { + assert_equals(countAudioLine(offer1.sdp), 1, + 'Expect created offer to have audio line'); + + // The first createOffer implicitly calls addTransceiver('audio'), + // so all following offers will also have audio media section + // in their SDP. + return pc.createOffer({ offerToReceiveAudio: false }) + .then(offer2 => { + assert_equals(countAudioLine(offer2.sdp), 1, + 'Expect audio line to remain in created offer'); + }) + }); + }, 'createOffer() with offerToReceiveAudio should add audio line to all subsequent created offers'); + + /* + * offerToReceiveVideo of type boolean + * When this is given a non-false value, and no outgoing track + * of type "video" is attached to the PeerConnection, and the + * existing localDescription (if any) doesn't contain any sendecv + * or recv video media sections, createOffer() will behave as if + * addTransceiver("video") had been called prior to the createOffer() call. + */ + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.createOffer({ offerToReceiveVideo: true }) + .then(offer1 => { + assert_equals(countVideoLine(offer1.sdp), 1, + 'Expect created offer to have video line'); + + return pc.createOffer({ offerToReceiveVideo: false }) + .then(offer2 => { + assert_equals(countVideoLine(offer2.sdp), 1, + 'Expect video line to remain in created offer'); + }) + }); + }, 'createOffer() with offerToReceiveVideo should add video line to all subsequent created offers'); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.createOffer({ + offerToReceiveAudio: true, + offerToReceiveVideo: false + }).then(offer1 => { + assert_equals(countAudioLine(offer1.sdp), 1, + 'Expect audio line to be found in created offer'); + + assert_equals(countVideoLine(offer1.sdp), 0, + 'Expect video line to not be found in create offer'); + + return pc.createOffer({ + offerToReceiveAudio: false, + offerToReceiveVideo: true + }).then(offer2 => { + assert_equals(countAudioLine(offer2.sdp), 1, + 'Expect audio line to remain in created offer'); + + assert_equals(countVideoLine(offer2.sdp), 1, + 'Expect video line to be found in create offer'); + }) + }); + }, 'createOffer() with offerToReceiveAudio:true, then with offerToReceiveVideo:true, should have result offer with both audio and video line'); + + + // Run some tests for both audio and video kinds + ['audio', 'video'].forEach((kind) => { + const capsKind = kind[0].toUpperCase() + kind.slice(1); + + const offerToReceiveTrue = {}; + offerToReceiveTrue[`offerToReceive${capsKind}`] = true; + + const offerToReceiveFalse = {}; + offerToReceiveFalse[`offerToReceive${capsKind}`] = false; + + // Start testing + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const dummy = pc.createDataChannel('foo'); // Just to have something to offer + + return pc.createOffer(offerToReceiveFalse) + .then(() => { + assert_equals(pc.getTransceivers().length, 0, + 'Expect pc to have no transceivers'); + }); + }, `createOffer() with offerToReceive${capsKind} set to false should not create a transceiver`); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.createOffer(offerToReceiveTrue) + .then(() => { + assert_equals(pc.getTransceivers().length, 1, + 'Expect pc to have one transceiver'); + + const transceiver = pc.getTransceivers()[0]; + assert_equals(transceiver.direction, 'recvonly', + 'Expect transceiver to have "recvonly" direction'); + }); + }, `createOffer() with offerToReceive${capsKind} should create a "recvonly" transceiver`); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.createOffer(offerToReceiveTrue) + .then(() => { + assert_equals(pc.getTransceivers().length, 1, + 'Expect pc to have one transceiver'); + + const transceiver = pc.getTransceivers()[0]; + assert_equals(transceiver.direction, 'recvonly', + 'Expect transceiver to have "recvonly" direction'); + }) + .then(() => pc.createOffer(offerToReceiveTrue)) + .then(() => { + assert_equals(pc.getTransceivers().length, 1, + 'Expect pc to still have only one transceiver'); + }) + ; + }, `offerToReceive${capsKind} option should be ignored if a non-stopped "recvonly" transceiver exists`); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return getTrackFromUserMedia(kind) + .then(([track, stream]) => { + pc.addTrack(track, stream); + return pc.createOffer(); + }) + .then(() => { + assert_equals(pc.getTransceivers().length, 1, + 'Expect pc to have one transceiver'); + + const transceiver = pc.getTransceivers()[0]; + assert_equals(transceiver.direction, 'sendrecv', + 'Expect transceiver to have "sendrecv" direction'); + }) + .then(() => pc.createOffer(offerToReceiveTrue)) + .then(() => { + assert_equals(pc.getTransceivers().length, 1, + 'Expect pc to still have only one transceiver'); + }) + ; + }, `offerToReceive${capsKind} option should be ignored if a non-stopped "sendrecv" transceiver exists`); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return getTrackFromUserMedia(kind) + .then(([track, stream]) => { + pc.addTrack(track, stream); + return pc.createOffer(offerToReceiveFalse); + }) + .then(() => { + assert_equals(pc.getTransceivers().length, 1, + 'Expect pc to have one transceiver'); + + const transceiver = pc.getTransceivers()[0]; + assert_equals(transceiver.direction, 'sendonly', + 'Expect transceiver to have "sendonly" direction'); + }) + ; + }, `offerToReceive${capsKind} set to false with a track should create a "sendonly" transceiver`); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + pc.addTransceiver(kind, {direction: 'recvonly'}); + + return pc.createOffer(offerToReceiveFalse) + .then(() => { + assert_equals(pc.getTransceivers().length, 1, + 'Expect pc to have one transceiver'); + + const transceiver = pc.getTransceivers()[0]; + assert_equals(transceiver.direction, 'inactive', + 'Expect transceiver to have "inactive" direction'); + }) + ; + }, `offerToReceive${capsKind} set to false with a "recvonly" transceiver should change the direction to "inactive"`); + + promise_test(t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const pc2 = new RTCPeerConnection(); + + t.add_cleanup(() => pc2.close()); + + return getTrackFromUserMedia(kind) + .then(([track, stream]) => { + pc.addTrack(track, stream); + return pc.createOffer(); + }) + .then((offer) => pc.setLocalDescription(offer)) + .then(() => pc2.setRemoteDescription(pc.localDescription)) + .then(() => pc2.createAnswer()) + .then((answer) => pc2.setLocalDescription(answer)) + .then(() => pc.setRemoteDescription(pc2.localDescription)) + .then(() => pc.createOffer(offerToReceiveFalse)) + .then((offer) => { + assert_equals(pc.getTransceivers().length, 1, + 'Expect pc to have one transceiver'); + + const transceiver = pc.getTransceivers()[0]; + assert_equals(transceiver.direction, 'sendonly', + 'Expect transceiver to have "sendonly" direction'); + }) + ; + }, `subsequent offerToReceive${capsKind} set to false with a track should change the direction to "sendonly"`); + }); + + promise_test(t => { + const pc = new RTCPeerConnection(); + + t.add_cleanup(() => pc.close()); + + return pc.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true }) + .then(() => { + assert_equals(pc.getTransceivers().length, 2, + 'Expect pc to have two transceivers'); + + assert_equals(pc.getTransceivers()[0].direction, 'recvonly', + 'Expect first transceiver to have "recvonly" direction'); + assert_equals(pc.getTransceivers()[1].direction, 'recvonly', + 'Expect second transceiver to have "recvonly" direction'); + }); + }, 'offerToReceiveAudio and Video should create two "recvonly" transceivers'); + +</script> diff --git a/testing/web-platform/tests/webrtc/legacy/RTCRtpTransceiver-with-OfferToReceive-options.https.html b/testing/web-platform/tests/webrtc/legacy/RTCRtpTransceiver-with-OfferToReceive-options.https.html new file mode 100644 index 0000000000..65a4d7e393 --- /dev/null +++ b/testing/web-platform/tests/webrtc/legacy/RTCRtpTransceiver-with-OfferToReceive-options.https.html @@ -0,0 +1,172 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCRtpTransceiver with OfferToReceive legacy options</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='/mediacapture-streams/permission-helper.js'></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + const stopTracks = (...streams) => { + streams.forEach(stream => stream.getTracks().forEach(track => track.stop())); + }; + + // comparable() - produces copy of object that is JSON comparable. + // o = original object (required) + // t = template of what to examine. Useful if o is non-enumerable (optional) + + const comparable = (o, t = o) => { + if (typeof o != 'object' || !o) { + return o; + } + if (Array.isArray(t) && Array.isArray(o)) { + return o.map((n, i) => comparable(n, t[i])); + } + return Object.keys(t).sort() + .reduce((r, key) => (r[key] = comparable(o[key], t[key]), r), {}); + }; + + const stripKeyQuotes = s => s.replace(/"(\w+)":/g, "$1:"); + + const hasProps = (observed, expected) => { + const observable = comparable(observed, expected); + assert_equals(stripKeyQuotes(JSON.stringify(observable)), + stripKeyQuotes(JSON.stringify(comparable(expected)))); + }; + + const checkAddTransceiverWithStream = async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + await setMediaPermission(); + const audioStream = await navigator.mediaDevices.getUserMedia({audio: true}); + const videoStream = await navigator.mediaDevices.getUserMedia({video: true}); + t.add_cleanup(() => stopTracks(audioStream, videoStream)); + + const audio = audioStream.getAudioTracks()[0]; + const video = videoStream.getVideoTracks()[0]; + + pc.addTransceiver(audio, {streams: [audioStream]}); + pc.addTransceiver(video, {streams: [videoStream]}); + + hasProps(pc.getTransceivers(), + [ + { + receiver: {track: {kind: "audio"}}, + sender: {track: audio}, + direction: "sendrecv", + mid: null, + currentDirection: null, + stopped: false + }, + { + receiver: {track: {kind: "video"}}, + sender: {track: video}, + direction: "sendrecv", + mid: null, + currentDirection: null, + stopped: false + } + ]); + + const offer = await pc.createOffer(); + assert_true(offer.sdp.includes("a=msid:" + audioStream.id), + "offer contains the expected audio msid"); + assert_true(offer.sdp.includes("a=msid:" + videoStream.id), + "offer contains the expected video msid"); + }; + + const checkAddTransceiverWithOfferToReceive = async (t, kinds) => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const propsToSet = kinds.map(kind => { + if (kind == "audio") { + return "offerToReceiveAudio"; + } else if (kind == "video") { + return "offerToReceiveVideo"; + } + }); + + const options = {}; + + for (const prop of propsToSet) { + options[prop] = true; + } + + let offer = await pc.createOffer(options); + + const expected = []; + + if (options.offerToReceiveAudio) { + expected.push( + { + receiver: {track: {kind: "audio"}}, + sender: {track: null}, + direction: "recvonly", + mid: null, + currentDirection: null, + stopped: false + }); + } + + if (options.offerToReceiveVideo) { + expected.push( + { + receiver: {track: {kind: "video"}}, + sender: {track: null}, + direction: "recvonly", + mid: null, + currentDirection: null, + stopped: false + }); + } + + hasProps(pc.getTransceivers(), expected); + + // Test offerToReceive: false + for (const prop of propsToSet) { + options[prop] = false; + } + + // Check that sendrecv goes to sendonly + for (const transceiver of pc.getTransceivers()) { + transceiver.direction = "sendrecv"; + } + + for (const transceiverCheck of expected) { + transceiverCheck.direction = "sendonly"; + } + + offer = await pc.createOffer(options); + hasProps(pc.getTransceivers(), expected); + + // Check that recvonly goes to inactive + for (const transceiver of pc.getTransceivers()) { + transceiver.direction = "recvonly"; + } + + for (const transceiverCheck of expected) { + transceiverCheck.direction = "inactive"; + } + + offer = await pc.createOffer(options); + hasProps(pc.getTransceivers(), expected); + }; + +const tests = [ + checkAddTransceiverWithStream, + function checkAddTransceiverWithOfferToReceiveAudio(t) { + return checkAddTransceiverWithOfferToReceive(t, ["audio"]); + }, + function checkAddTransceiverWithOfferToReceiveVideo(t) { + return checkAddTransceiverWithOfferToReceive(t, ["video"]); + }, + function checkAddTransceiverWithOfferToReceiveBoth(t) { + return checkAddTransceiverWithOfferToReceive(t, ["audio", "video"]); + } +].forEach(test => promise_test(test, test.name)); + +</script> diff --git a/testing/web-platform/tests/webrtc/legacy/onaddstream.https.html b/testing/web-platform/tests/webrtc/legacy/onaddstream.https.html new file mode 100644 index 0000000000..b5e8a402b8 --- /dev/null +++ b/testing/web-platform/tests/webrtc/legacy/onaddstream.https.html @@ -0,0 +1,157 @@ +<!doctype html> +<meta charset=utf-8> +<title>onaddstream tests</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='/mediacapture-streams/permission-helper.js'></script> +<script> + 'use strict'; + + const stopTracks = (...streams) => { + streams.forEach(stream => stream.getTracks().forEach(track => track.stop())); + }; + + const collectEvents = (target, name, check) => { + const events = []; + const handler = e => { + check(e); + events.push(e); + }; + + target.addEventListener(name, handler); + + const finishCollecting = () => { + target.removeEventListener(name, handler); + return events; + }; + + return {finish: finishCollecting}; + }; + + const collectAddTrackEvents = stream => { + const checkEvent = e => { + assert_true(e.track instanceof MediaStreamTrack, "Track is set on event"); + assert_true(stream.getTracks().includes(e.track), + "track in addtrack event is in the stream"); + }; + return collectEvents(stream, "addtrack", checkEvent); + }; + + const collectRemoveTrackEvents = stream => { + const checkEvent = e => { + assert_true(e.track instanceof MediaStreamTrack, "Track is set on event"); + assert_true(!stream.getTracks().includes(e.track), + "track in removetrack event is not in the stream"); + }; + return collectEvents(stream, "removetrack", checkEvent); + }; + + const collectTrackEvents = pc => { + const checkEvent = e => { + assert_true(e.track instanceof MediaStreamTrack, "Track is set on event"); + assert_true(e.receiver instanceof RTCRtpReceiver, "Receiver is set on event"); + assert_true(e.transceiver instanceof RTCRtpTransceiver, "Transceiver is set on event"); + assert_true(Array.isArray(e.streams), "Streams is set on event"); + e.streams.forEach(stream => { + assert_true(stream.getTracks().includes(e.track), + "Each stream in event contains the track"); + }); + assert_equals(e.receiver, e.transceiver.receiver, + "Receiver belongs to transceiver"); + assert_equals(e.track, e.receiver.track, + "Track belongs to receiver"); + }; + + return collectEvents(pc, "track", checkEvent); + }; + + // comparable() - produces copy of object that is JSON comparable. + // o = original object (required) + // t = template of what to examine. Useful if o is non-enumerable (optional) + + const comparable = (o, t = o) => { + if (typeof o != 'object' || !o) { + return o; + } + if (Array.isArray(t) && Array.isArray(o)) { + return o.map((n, i) => comparable(n, t[i])); + } + return Object.keys(t).sort() + .reduce((r, key) => (r[key] = comparable(o[key], t[key]), r), {}); + }; + + const stripKeyQuotes = s => s.replace(/"(\w+)":/g, "$1:"); + + const hasProps = (observed, expected) => { + const observable = comparable(observed, expected); + assert_equals(stripKeyQuotes(JSON.stringify(observable)), + stripKeyQuotes(JSON.stringify(comparable(expected)))); + }; + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + await setMediaPermission(); + const stream1 = await navigator.mediaDevices.getUserMedia({audio: true, video: true}); + t.add_cleanup(() => stopTracks(stream1)); + const audio1 = stream1.getAudioTracks()[0]; + pc1.addTrack(audio1, stream1); + const video1 = stream1.getVideoTracks()[0]; + pc1.addTrack(video1, stream1); + + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + const stream2 = await navigator.mediaDevices.getUserMedia({audio: true, video: true}); + t.add_cleanup(() => stopTracks(stream2)); + const audio2 = stream2.getAudioTracks()[0]; + pc2.addTrack(audio2, stream2); + const video2 = stream2.getVideoTracks()[0]; + pc2.addTrack(video2, stream2); + + const offer = await pc1.createOffer(); + + let trackEventCollector = collectTrackEvents(pc2); + let addstreamEventCollector = collectEvents(pc2, "addstream", e => { + hasProps(e, {stream: {id: stream1.id}}); + assert_equals(e.stream.getAudioTracks().length, 1, "One audio track"); + assert_equals(e.stream.getVideoTracks().length, 1, "One video track"); + }); + + await pc2.setRemoteDescription(offer); + + let addstreamEvents = addstreamEventCollector.finish(); + assert_equals(addstreamEvents.length, 1, "Should have 1 addstream event"); + + let trackEvents = trackEventCollector.finish(); + + hasProps(trackEvents, + [ + {streams: [addstreamEvents[0].stream]}, + {streams: [addstreamEvents[0].stream]} + ]); + + await pc1.setLocalDescription(offer); + const answer = await pc2.createAnswer(); + + trackEventCollector = collectTrackEvents(pc1); + addstreamEventCollector = collectEvents(pc1, "addstream", e => { + hasProps(e, {stream: {id: stream2.id}}); + assert_equals(e.stream.getAudioTracks().length, 1, "One audio track"); + assert_equals(e.stream.getVideoTracks().length, 1, "One video track"); + }); + + await pc1.setRemoteDescription(answer); + addstreamEvents = addstreamEventCollector.finish(); + assert_equals(addstreamEvents.length, 1, "Should have 1 addstream event"); + + trackEvents = trackEventCollector.finish(); + + hasProps(trackEvents, + [ + {streams: [addstreamEvents[0].stream]}, + {streams: [addstreamEvents[0].stream]} + ]); + },"Check onaddstream"); +</script> diff --git a/testing/web-platform/tests/webrtc/no-media-call.html b/testing/web-platform/tests/webrtc/no-media-call.html new file mode 100644 index 0000000000..dba0b1d2df --- /dev/null +++ b/testing/web-platform/tests/webrtc/no-media-call.html @@ -0,0 +1,100 @@ +<!doctype html> + +<html> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title>RTCPeerConnection No-Media Connection Test</title> +</head> +<body> + <div id="log"></div> + <h2>iceConnectionState info</h2> + <div id="stateinfo"> + </div> + + <!-- These files are in place when executing on W3C. --> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="RTCPeerConnection-helper.js"></script> + <script type="text/javascript"> + let gFirstConnection = null; + let gSecondConnection = null; + + function onIceCandidate(otherConnction, event, reject) { + try { + otherConnction.addIceCandidate(event.candidate); + } catch(e) { + reject(e); + } + }; + + function onIceConnectionStateChange(done, failed, event) { + try { + assert_equals(event.type, 'iceconnectionstatechange'); + assert_not_equals(gFirstConnection.iceConnectionState, "failed", + "iceConnectionState of first connection"); + assert_not_equals(gSecondConnection.iceConnectionState, "failed", + "iceConnectionState of second connection"); + const stateinfo = document.getElementById('stateinfo'); + stateinfo.innerHTML = 'First: ' + gFirstConnection.iceConnectionState + + '<br>Second: ' + gSecondConnection.iceConnectionState; + // Note: All these combinations are legal states indicating that the + // call has connected. All browsers should end up in completed/completed, + // but as of this moment, we've chosen to terminate the test early. + // TODO: Revise test to ensure completed/completed is reached. + const allowedStates = [ 'connected', 'completed']; + if (allowedStates.includes(gFirstConnection.iceConnectionState) && + allowedStates.includes(gSecondConnection.iceConnectionState)) { + done(); + } + } catch(e) { + failed(e); + } + }; + + // This function starts the test. + promise_test((test) => { + return new Promise(async (resolve, reject) => { + gFirstConnection = new RTCPeerConnection(null); + test.add_cleanup(() => gFirstConnection.close()); + gFirstConnection.onicecandidate = + (event) => onIceCandidate(gSecondConnection, event, reject); + gFirstConnection.oniceconnectionstatechange = + (event) => onIceConnectionStateChange(resolve, reject, event); + + gSecondConnection = new RTCPeerConnection(null); + test.add_cleanup(() => gSecondConnection.close()); + gSecondConnection.onicecandidate = + (event) => onIceCandidate(gFirstConnection, event, reject); + gSecondConnection.oniceconnectionstatechange = + (event) => onIceConnectionStateChange(resolve, reject, event); + + const offer = await generateVideoReceiveOnlyOffer(gFirstConnection); + + await gFirstConnection.setLocalDescription(offer); + + // This would normally go across the application's signaling solution. + // In our case, the "signaling" is to call this function. + + await gSecondConnection.setRemoteDescription({ type: 'offer', + sdp: offer.sdp }); + + const answer = await gSecondConnection.createAnswer(); + + await gSecondConnection.setLocalDescription(answer); + + assert_equals(gSecondConnection.getSenders().length, 1); + assert_not_equals(gSecondConnection.getSenders()[0], null); + assert_not_equals(gSecondConnection.getSenders()[0].transport, null); + + // Similarly, this would go over the application's signaling solution. + await gFirstConnection.setRemoteDescription({ type: 'answer', + sdp: answer.sdp }); + + // The test is terminated by onIceConnectionStateChange() calling resolve + // once both connections are connected. + }) + }); +</script> + +</body> +</html> diff --git a/testing/web-platform/tests/webrtc/promises-call.html b/testing/web-platform/tests/webrtc/promises-call.html new file mode 100644 index 0000000000..ee64b463ee --- /dev/null +++ b/testing/web-platform/tests/webrtc/promises-call.html @@ -0,0 +1,113 @@ +<!doctype html> +<!-- +This test uses data only, and thus does not require fake media devices. +--> + +<html> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title>RTCPeerConnection Data-Only Connection Test with Promises</title> +</head> +<body> + <div id="log"></div> + <h2>iceConnectionState info</h2> + <div id="stateinfo"> + </div> + + <!-- These files are in place when executing on W3C. --> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script type="text/javascript"> + var test = async_test('Can set up a basic WebRTC call with only data using promises.'); + + var gFirstConnection = null; + var gSecondConnection = null; + + var onIceCandidateToFirst = test.step_func(function(event) { + gSecondConnection.addIceCandidate(event.candidate); + }); + + var onIceCandidateToSecond = test.step_func(function(event) { + gFirstConnection.addIceCandidate(event.candidate); + }); + + var onIceConnectionStateChange = test.step_func(function(event) { + assert_equals(event.type, 'iceconnectionstatechange'); + var stateinfo = document.getElementById('stateinfo'); + stateinfo.innerHTML = 'First: ' + gFirstConnection.iceConnectionState + + '<br>Second: ' + gSecondConnection.iceConnectionState; + // Note: All these combinations are legal states indicating that the + // call has connected. All browsers should end up in completed/completed, + // but as of this moment, we've chosen to terminate the test early. + // TODO: Revise test to ensure completed/completed is reached. + if (gFirstConnection.iceConnectionState == 'connected' && + gSecondConnection.iceConnectionState == 'connected') { + test.done() + } + if (gFirstConnection.iceConnectionState == 'connected' && + gSecondConnection.iceConnectionState == 'completed') { + test.done() + } + if (gFirstConnection.iceConnectionState == 'completed' && + gSecondConnection.iceConnectionState == 'connected') { + test.done() + } + if (gFirstConnection.iceConnectionState == 'completed' && + gSecondConnection.iceConnectionState == 'completed') { + test.done() + } + }); + + // This function starts the test. + test.step(function() { + gFirstConnection = new RTCPeerConnection(null); + test.add_cleanup(() => gFirstConnection.close()); + gFirstConnection.onicecandidate = onIceCandidateToFirst; + gFirstConnection.oniceconnectionstatechange = onIceConnectionStateChange; + + gSecondConnection = new RTCPeerConnection(null); + test.add_cleanup(() => gSecondConnection.close()); + gSecondConnection.onicecandidate = onIceCandidateToSecond; + gSecondConnection.oniceconnectionstatechange = onIceConnectionStateChange; + + // The createDataChannel is necessary and sufficient to make + // sure the ICE connection be attempted. + gFirstConnection.createDataChannel('channel'); + + var atStep = 'Create offer'; + + gFirstConnection.createOffer() + .then(function(offer) { + atStep = 'Set local description at first'; + return gFirstConnection.setLocalDescription(offer); + }) + .then(function() { + atStep = 'Set remote description at second'; + return gSecondConnection.setRemoteDescription( + gFirstConnection.localDescription); + }) + .then(function() { + atStep = 'Create answer'; + return gSecondConnection.createAnswer(); + }) + .then(function(answer) { + atStep = 'Set local description at second'; + return gSecondConnection.setLocalDescription(answer); + }) + .then(function() { + atStep = 'Set remote description at first'; + return gFirstConnection.setRemoteDescription( + gSecondConnection.localDescription); + }) + .then(function() { + atStep = 'Negotiation completed'; + }) + .catch(test.step_func(function(e) { + assert_unreached('Error ' + e.name + ': ' + e.message + + ' happened at step ' + atStep); + })); + }); +</script> + +</body> +</html> diff --git a/testing/web-platform/tests/webrtc/protocol/README.txt b/testing/web-platform/tests/webrtc/protocol/README.txt new file mode 100644 index 0000000000..5e17fbf9c3 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/README.txt @@ -0,0 +1,22 @@ +This directory contains files that test for behavior relevant to webrtc, +but which is specified in protocol specifications from the IETF, not in +API recommendations from the W3C. + +The main specifications are given in the following RFCs: + +- RFC 7742, "WebRTC Video Processing and Codec Requirements" +- RFC 7874, "WebRTC Audio Codec and Processing Requirements" +- RFC 8825 (draft-ietf-rtcweb-overview) +- RFC 8826 (draft-ietf-rtcweb-security) +- RFC 8827 (draft-ietf-rtcweb-security-arch) +- RFC 8828 (draft-ietf-rtcweb-ip-handling) +- RFC 8829 (draft-ietf-rtcweb-jsep) +- RFC 8831 (draft-ietf-rtcweb-data-channel) +- RFC 8832 (draft-ietf-rtcweb-data-protocol) +- RFC 8834 (draft-ietf-rtcweb-rtp-usage) +- RFC 8835 (draft-ietf-rtcweb-transports) +- RFC 8851 (draft-ietf-mmusic-rid) +- RFC 8853 (draft-ietf-mmusic-sdp-simulcast) +- RFC 8854 (draft-ietf-rtcweb-fec) + +This list is incomplete. diff --git a/testing/web-platform/tests/webrtc/protocol/RTCPeerConnection-payloadTypes.html b/testing/web-platform/tests/webrtc/protocol/RTCPeerConnection-payloadTypes.html new file mode 100644 index 0000000000..066fc2e085 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/RTCPeerConnection-payloadTypes.html @@ -0,0 +1,49 @@ +<!DOCTYPE html> +<html> +<head> +<title>RTCPeerConnection RTP payload types</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +</head> +<body> +<script> + +// Test that when creating an offer we do not run out of valid payload types. +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + + pc1.addTransceiver('audio', { direction: 'recvonly' }); + pc1.addTransceiver('video', { direction: 'recvonly' }); + const offer = await pc1.createOffer(); + + // Extract all payload types from the m= lines. + const payloadTypes = offer.sdp.split('\n') + .map(line => line.trim()) + .filter(line => line.startsWith('m=')) + .map(line => line.split(' ').slice(3).join(' ')) + .join(' ') + .split(' ') + .map(payloadType => parseInt(payloadType, 10)); + + // The list of allowed payload types is taken from here + // https://www.iana.org/assignments/rtp-parameters/rtp-parameters.xhtml#rtp-parameters-1. + const forbiddenPayloadTypes = payloadTypes + .filter(payloadType => { + if (payloadType >= 96 && payloadType <= 127) { + return false; + } + if (payloadType >= 72 && payloadType < 96) { + return true; + } + if (payloadType >= 35 && payloadType < 72) { + return false; + } + // TODO: Check against static payload type list. + return false; + }); + assert_equals(forbiddenPayloadTypes.length, 0) +}, 'createOffer with the maximum set of codecs does not generate invalid payload types'); +</script> +</body> +</html> diff --git a/testing/web-platform/tests/webrtc/protocol/bundle.https.html b/testing/web-platform/tests/webrtc/protocol/bundle.https.html new file mode 100644 index 0000000000..3d2b835baf --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/bundle.https.html @@ -0,0 +1,150 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection BUNDLE</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await getNoiseStream({audio: true, video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + stream.getTracks().forEach(track => caller.addTrack(track, stream)); + + + exchangeIceCandidates(caller, callee); + const offer = await caller.createOffer(); + // remove the a=group:BUNDLE from the SDP when signaling. + const sdp = offer.sdp.replace(/a=group:BUNDLE (.*)\r\n/, ''); + const ontrack = new Promise(r => callee.ontrack = r); + + await callee.setRemoteDescription({type: 'offer', sdp}); + await caller.setLocalDescription(offer); + + const answer = await callee.createAnswer(); + await caller.setRemoteDescription(answer); + await callee.setLocalDescription(answer); + + const {streams: [recvStream]} = await ontrack; + assert_equals(recvStream.getTracks().length, 2, "Tracks should be added to the stream before sRD resolves."); + const v = document.createElement('video'); + v.autoplay = true; + v.srcObject = recvStream; + v.id = recvStream.id; + await new Promise(r => v.onloadedmetadata = r); + + const senders = caller.getSenders(); + const dtlsTransports = senders.map(s => s.transport); + assert_equals(dtlsTransports.length, 2); + assert_not_equals(dtlsTransports[0], dtlsTransports[1]); + + const iceTransports = dtlsTransports.map(t => t.iceTransport); + assert_equals(iceTransports.length, 2); + assert_not_equals(iceTransports[0], iceTransports[1]); +}, 'not negotiating BUNDLE creates two separate ice and dtls transports'); + +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + const stream = await getNoiseStream({audio: true, video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + stream.getTracks().forEach(track => caller.addTrack(track, stream)); + + exchangeIceCandidates(caller, callee); + const offer = await caller.createOffer(); + const ontrack = new Promise(r => callee.ontrack = r); + await callee.setRemoteDescription(offer); + await caller.setLocalDescription(offer); + const secondTransport = caller.getSenders()[1].transport; // Save a reference to this transport. + + const answer = await callee.createAnswer(); + await caller.setRemoteDescription(answer); + await callee.setLocalDescription(answer); + + const {streams: [recvStream]} = await ontrack; + assert_equals(recvStream.getTracks().length, 2, "Tracks should be added to the stream before sRD resolves."); + const v = document.createElement('video'); + v.autoplay = true; + v.srcObject = recvStream; + v.id = recvStream.id; + await new Promise(r => v.onloadedmetadata = r); + + const senders = caller.getSenders(); + const dtlsTransports = senders.map(s => s.transport); + assert_equals(dtlsTransports.length, 2); + assert_equals(dtlsTransports[0], dtlsTransports[1]); + assert_not_equals(dtlsTransports[1], secondTransport); + assert_equals(secondTransport.state, 'closed'); +}, 'bundles on the first transport and closes the second'); + +promise_test(async t => { + const sdp = `v=0 +o=- 0 3 IN IP4 127.0.0.1 +s=- +t=0 0 +a=fingerprint:sha-256 A7:24:72:CA:6E:02:55:39:BA:66:DF:6E:CC:4C:D8:B0:1A:BF:1A:56:65:7D:F4:03:AD:7E:77:43:2A:29:EC:93 +a=ice-ufrag:ETEn +a=ice-pwd:OtSK0WpNtpUjkY4+86js7Z/l +m=audio 9 UDP/TLS/RTP/SAVPF 111 +c=IN IP4 0.0.0.0 +a=rtcp-mux +a=sendonly +a=mid:audio +a=rtpmap:111 opus/48000/2 +a=setup:actpass +m=video 9 UDP/TLS/RTP/SAVPF 100 +c=IN IP4 0.0.0.0 +a=rtcp-mux +a=sendonly +a=mid:video +a=rtpmap:100 VP8/90000 +a=fmtp:100 max-fr=30;max-fs=3600 +a=setup:actpass +`; + const pc = new RTCPeerConnection({ bundlePolicy: 'max-bundle' }); + t.add_cleanup(() => pc.close()); + await pc.setRemoteDescription({ type: 'offer', sdp }); + await pc.setLocalDescription(); + const transceivers = pc.getTransceivers(); + assert_equals(transceivers.length, 2); + assert_false(transceivers[0].stopped); + assert_true(transceivers[1].stopped); +}, 'max-bundle with an offer without bundle only negotiates the first m-line'); + +promise_test(async t => { + const sdp = `v=0 +o=- 0 3 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE audio video +m=audio 9 UDP/TLS/RTP/SAVPF 111 +c=IN IP4 0.0.0.0 +a=fingerprint:sha-256 A7:24:72:CA:6E:02:55:39:BA:66:DF:6E:CC:4C:D8:B0:1A:BF:1A:56:65:7D:F4:03:AD:7E:77:43:2A:29:EC:93 +a=ice-ufrag:ETEn +a=ice-pwd:OtSK0WpNtpUjkY4+86js7Z/l +a=rtcp-mux +a=sendonly +a=mid:audio +a=rtpmap:111 opus/48000/2 +a=setup:actpass +m=video 9 UDP/TLS/RTP/SAVPF 100 +c=IN IP4 0.0.0.0 +a=bundle-only +a=sendonly +a=mid:video +a=rtpmap:100 VP8/90000 +a=fmtp:100 max-fr=30;max-fs=3600 +`; + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + await pc.setRemoteDescription({ type: 'offer', sdp }); +}, 'sRD(offer) works with no transport attributes in a bundle-only m-section'); +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/candidate-exchange.https.html b/testing/web-platform/tests/webrtc/protocol/candidate-exchange.https.html new file mode 100644 index 0000000000..c54f26e6d8 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/candidate-exchange.https.html @@ -0,0 +1,218 @@ +<!DOCTYPE html> +<html> +<head> +<title>Candidate exchange</title> +<meta name=timeout content=long> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +</head> +<body> +<script> + +class StateLogger { + constructor(source, eventname, field) { + source.addEventListener(eventname, event => { + this.events.push(source[field]); + }); + this.events = [source[field]]; + } +} + +class IceStateLogger extends StateLogger { + constructor(source) { + super(source, 'iceconnectionstatechange', 'iceConnectionState'); + } +} + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1.createDataChannel('datachannel'); + pc1IceStates = new IceStateLogger(pc1); + pc2IceStates = new IceStateLogger(pc1); + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + // Note - it's been claimed that this state sometimes jumps straight + // to "completed". If so, this test should be flaky. + await waitForIceStateChange(pc1, ['connected']); + assert_array_equals(pc1IceStates.events, ['new', 'checking', 'connected']); + assert_array_equals(pc2IceStates.events, ['new', 'checking', 'connected']); +}, 'Two way ICE exchange works'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1IceStates = new IceStateLogger(pc1); + pc2IceStates = new IceStateLogger(pc1); + let candidates = []; + pc1.createDataChannel('datachannel'); + pc1.onicecandidate = e => { + candidates.push(e.candidate); + } + // Candidates from PC2 are not delivered to pc1, so pc1 will use + // peer-reflexive candidates. + await exchangeOfferAnswer(pc1, pc2); + const waiter = waitForIceGatheringState(pc1, ['complete']); + await waiter; + for (const candidate of candidates) { + if (candidate) { + pc2.addIceCandidate(candidate); + } + } + await Promise.all([waitForIceStateChange(pc1, ['connected', 'completed']), + waitForIceStateChange(pc2, ['connected', 'completed'])]); + const candidate_pair = pc1.sctp.transport.iceTransport.getSelectedCandidatePair(); + assert_equals(candidate_pair.local.type, 'host'); + assert_equals(candidate_pair.remote.type, 'prflx'); + assert_array_equals(pc1IceStates.events, ['new', 'checking', 'connected']); + assert_array_equals(pc2IceStates.events, ['new', 'checking', 'connected']); +}, 'Adding only caller -> callee candidates gives a connection'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1IceStates = new IceStateLogger(pc1); + pc2IceStates = new IceStateLogger(pc1); + let candidates = []; + pc1.createDataChannel('datachannel'); + pc2.onicecandidate = e => { + candidates.push(e.candidate); + } + // Candidates from pc1 are not delivered to pc2. so pc2 will use + // peer-reflexive candidates. + await exchangeOfferAnswer(pc1, pc2); + const waiter = waitForIceGatheringState(pc2, ['complete']); + await waiter; + for (const candidate of candidates) { + if (candidate) { + pc1.addIceCandidate(candidate); + } + } + await Promise.all([waitForIceStateChange(pc1, ['connected', 'completed']), + waitForIceStateChange(pc2, ['connected', 'completed'])]); + const candidate_pair = pc2.sctp.transport.iceTransport.getSelectedCandidatePair(); + assert_equals(candidate_pair.local.type, 'host'); + assert_equals(candidate_pair.remote.type, 'prflx'); + assert_array_equals(pc1IceStates.events, ['new', 'checking', 'connected']); + assert_array_equals(pc2IceStates.events, ['new', 'checking', 'connected']); +}, 'Adding only callee -> caller candidates gives a connection'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1IceStates = new IceStateLogger(pc1); + pc2IceStates = new IceStateLogger(pc1); + let pc2ToPc1Candidates = []; + pc1.createDataChannel('datachannel'); + pc2.onicecandidate = e => { + pc2ToPc1Candidates.push(e.candidate); + // This particular test verifies that candidates work + // properly if added from the pc2 onicecandidate event. + if (!e.candidate) { + for (const candidate of pc2ToPc1Candidates) { + if (candidate) { + pc1.addIceCandidate(candidate); + } + } + } + } + // Candidates from |pc1| are not delivered to |pc2|. |pc2| will use + // peer-reflexive candidates. + await exchangeOfferAnswer(pc1, pc2); + await Promise.all([waitForIceStateChange(pc1, ['connected', 'completed']), + waitForIceStateChange(pc2, ['connected', 'completed'])]); + const candidate_pair = pc2.sctp.transport.iceTransport.getSelectedCandidatePair(); + assert_equals(candidate_pair.local.type, 'host'); + assert_equals(candidate_pair.remote.type, 'prflx'); + assert_array_equals(pc1IceStates.events, ['new', 'checking', 'connected']); + assert_array_equals(pc2IceStates.events, ['new', 'checking', 'connected']); +}, 'Adding callee -> caller candidates from end-of-candidates gives a connection'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1IceStates = new IceStateLogger(pc1); + pc2IceStates = new IceStateLogger(pc1); + let pc1ToPc2Candidates = []; + let pc2ToPc1Candidates = []; + pc1.createDataChannel('datachannel'); + pc1.onicecandidate = e => { + pc1ToPc2Candidates.push(e.candidate); + } + pc2.onicecandidate = e => { + pc2ToPc1Candidates.push(e.candidate); + } + const offer = await pc1.createOffer(); + await Promise.all([pc1.setLocalDescription(offer), + pc2.setRemoteDescription(offer)]); + const answer = await pc2.createAnswer(); + await waitForIceGatheringState(pc1, ['complete']); + await pc2.setLocalDescription(answer).then(() => { + for (const candidate of pc1ToPc2Candidates) { + if (candidate) { + pc2.addIceCandidate(candidate); + } + } + }); + await waitForIceGatheringState(pc2, ['complete']); + pc1.setRemoteDescription(answer).then(async () => { + for (const candidate of pc2ToPc1Candidates) { + if (candidate) { + await pc1.addIceCandidate(candidate); + } + } + }); + await Promise.all([waitForIceStateChange(pc1, ['connected', 'completed']), + waitForIceStateChange(pc2, ['connected', 'completed'])]); + const candidate_pair = + pc1.sctp.transport.iceTransport.getSelectedCandidatePair(); + assert_equals(candidate_pair.local.type, 'host'); + // When we supply remote candidates, we expect a jump to the 'host' candidate, + // but it might also remain as 'prflx'. + assert_true(candidate_pair.remote.type == 'host' || + candidate_pair.remote.type == 'prflx'); + assert_array_equals(pc1IceStates.events, ['new', 'checking', 'connected']); + assert_array_equals(pc2IceStates.events, ['new', 'checking', 'connected']); +}, 'Explicit offer/answer exchange gives a connection'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + pc1.createDataChannel('datachannel'); + pc1.onicecandidate = assert_unreached; + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + await new Promise(resolve => { + pc1.onicecandidate = resolve; + }); +}, 'Candidates always arrive after setLocalDescription(offer) resolves'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1.createDataChannel('datachannel'); + pc2.onicecandidate = assert_unreached; + const offer = await pc1.createOffer(); + await pc2.setRemoteDescription(offer); + await pc2.setLocalDescription(await pc2.createAnswer()); + await new Promise(resolve => { + pc2.onicecandidate = resolve; + }); +}, 'Candidates always arrive after setLocalDescription(answer) resolves'); + +</script> +</body> +</html> diff --git a/testing/web-platform/tests/webrtc/protocol/crypto-suite.https.html b/testing/web-platform/tests/webrtc/protocol/crypto-suite.https.html new file mode 100644 index 0000000000..f13f221b88 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/crypto-suite.https.html @@ -0,0 +1,85 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.createOffer</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="../RTCStats-helper.js"></script> +<script> +'use strict'; + +// draft-ietf-rtcweb-security-20 section 6.5 +// +// All Implementations MUST support DTLS 1.2 with the +// TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 cipher suite and the P-256 +// curve [FIPS186]. +// ....... The DTLS-SRTP protection profile +// SRTP_AES128_CM_HMAC_SHA1_80 MUST be supported for SRTP. +// Implementations MUST favor cipher suites which support (Perfect +// Forward Secrecy) PFS over non-PFS cipher suites and SHOULD favor AEAD +// over non-AEAD cipher suites. + +const acceptableTlsVersions = new Set([ + 'FEFD', // DTLS 1.2 - RFC 6437 section 4.1 + '0304', // TLS 1.3 - RFC 8446 section 5.1 +]); + +const acceptableDtlsCiphersuites = new Set([ + 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256', +]); + +const acceptableSrtpCiphersuites = new Set([ + 'SRTP_AES128_CM_HMAC_SHA1_80', + 'AES_CM_128_HMAC_SHA1_80', +]); + +const acceptableTlsGroups = new Set([ + 'P-256', +]); + +const acceptableValues = { + 'tlsVersion': acceptableTlsVersions, + 'dtlsCipher': acceptableDtlsCiphersuites, + 'srtpCipher': acceptableSrtpCiphersuites, + 'tlsGroup': acceptableTlsGroups, +}; + +function verifyStat(name, transportStats) { + assert_not_equals(typeof transportStats, 'undefined'); + assert_true(name in transportStats, 'Value present:'); + assert_true(acceptableValues[name].has(transportStats[name])); +} + +for (const name of Object.keys(acceptableValues)) { + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1.createDataChannel('foo'); + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + await waitForState(pc1.sctp.transport, 'connected'); + const statsReport = await pc1.getStats(); + const transportStats = findStatsFromReport(statsReport, + stats => stats.type === 'transport') + verifyStat(name, transportStats); + }, name + ' is acceptable on data-only'); + + promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const transceiver = pc1.addTransceiver('video'); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + await waitForState(transceiver.sender.transport, 'connected'); + const statsReport = await pc1.getStats(); + const transportStats = findStatsFromReport(statsReport, + stats => stats.type === 'transport') + verifyStat(name, transportStats); + }, name + ' is acceptable on video-only'); +} +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/dtls-certificates.html b/testing/web-platform/tests/webrtc/protocol/dtls-certificates.html new file mode 100644 index 0000000000..bc4794cbc1 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/dtls-certificates.html @@ -0,0 +1,42 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection DTLS certifcate interop</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +// https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-generatecertificate +const certificateParameters = { + ecdsa: { + name: 'ECDSA', + namedCurve: 'P-256', + }, + rsa: { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, +}; + +Object.keys(certificateParameters).forEach(async localType => { + Object.keys(certificateParameters).forEach(async remoteType => { + promise_test(async t => { + const localParameters = certificateParameters[localType]; + const remoteParameters = certificateParameters[remoteType]; + const firstCertificate = await RTCPeerConnection.generateCertificate(localParameters); + const secondCertificate = await RTCPeerConnection.generateCertificate(remoteParameters); + const pc1 = new RTCPeerConnection({certificates: [firstCertificate]}); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection({certificates: [secondCertificate]}); + t.add_cleanup(() => pc2.close()); + pc1.createDataChannel('test'); + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + await waitForConnectionStateChange(pc1, ['connected']); + }, `RTCPeerConnection establishes using ${localType} and ${remoteType} certificates`); + }); +}); + +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/dtls-fingerprint-validation.html b/testing/web-platform/tests/webrtc/protocol/dtls-fingerprint-validation.html new file mode 100644 index 0000000000..0ddc8488ae --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/dtls-fingerprint-validation.html @@ -0,0 +1,37 @@ +<!DOCTYPE html> +<html> +<head> +<title>DTLS fingerprint validation</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +</head> +<body> +<script> + +// Tests that an invalid fingerprint leads to a connectionState 'failed'. +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + pc1.createDataChannel('datachannel'); + exchangeIceCandidates(pc1, pc2); + const offer = await pc1.createOffer(); + await pc2.setRemoteDescription(offer); + await pc1.setLocalDescription(offer); + const answer = await pc2.createAnswer(); + await pc1.setRemoteDescription(new RTCSessionDescription({ + type: answer.type, + sdp: answer.sdp.replace(/a=fingerprint:sha-256 .*/g, + 'a=fingerprint:sha-256 00:00:00:00:00:00:00:00:00:00:00:00:00:' + + '00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00'), + })); + await pc2.setLocalDescription(answer); + + await waitForConnectionStateChange(pc1, ['failed']); + await waitForConnectionStateChange(pc2, ['failed']); +}, 'Connection fails if one side provides a wrong DTLS fingerprint'); +</script> +</body> +</html> diff --git a/testing/web-platform/tests/webrtc/protocol/dtls-setup.https.html b/testing/web-platform/tests/webrtc/protocol/dtls-setup.https.html new file mode 100644 index 0000000000..892e6db413 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/dtls-setup.https.html @@ -0,0 +1,135 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection a=setup SDP parameter test</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +'use strict'; + +// Tests for correct behavior of DTLS a=setup parameter. + +// SDP copied from JSEP Example 7.1 +// It contains two media streams with different ufrags, and bundle +// turned on. +const kSdp = `v=0 +o=- 4962303333179871722 1 IN IP4 0.0.0.0 +s=- +t=0 0 +a=ice-options:trickle +a=group:BUNDLE a1 v1 +a=group:LS a1 v1 +m=audio 10100 UDP/TLS/RTP/SAVPF 96 0 8 97 98 +c=IN IP4 203.0.113.100 +a=mid:a1 +a=sendrecv +a=rtpmap:96 opus/48000/2 +a=rtpmap:0 PCMU/8000 +a=rtpmap:8 PCMA/8000 +a=rtpmap:97 telephone-event/8000 +a=rtpmap:98 telephone-event/48000 +a=maxptime:120 +a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid +a=extmap:2 urn:ietf:params:rtp-hdrext:ssrc-audio-level +a=msid:47017fee-b6c1-4162-929c-a25110252400 f83006c5-a0ff-4e0a-9ed9-d3e6747be7d9 +a=ice-ufrag:ETEn +a=ice-pwd:OtSK0WpNtpUjkY4+86js7ZQl +a=fingerprint:sha-256 19:E2:1C:3B:4B:9F:81:E6:B8:5C:F4:A5:A8:D8:73:04:BB:05:2F:70:9F:04:A9:0E:05:E9:26:33:E8:70:88:A2 +a=setup:actpass +a=dtls-id:1 +a=rtcp:10101 IN IP4 203.0.113.100 +a=rtcp-mux +a=rtcp-rsize +m=video 10102 UDP/TLS/RTP/SAVPF 100 101 +c=IN IP4 203.0.113.100 +a=mid:v1 +a=sendrecv +a=rtpmap:100 VP8/90000 +a=rtpmap:101 rtx/90000 +a=fmtp:101 apt=100 +a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid +a=rtcp-fb:100 ccm fir +a=rtcp-fb:100 nack +a=rtcp-fb:100 nack pli +a=msid:47017fee-b6c1-4162-929c-a25110252400 f30bdb4a-5db8-49b5-bcdc-e0c9a23172e0 +a=ice-ufrag:BGKk +a=ice-pwd:mqyWsAjvtKwTGnvhPztQ9mIf +a=fingerprint:sha-256 19:E2:1C:3B:4B:9F:81:E6:B8:5C:F4:A5:A8:D8:73:04:BB:05:2F:70:9F:04:A9:0E:05:E9:26:33:E8:70:88:A2 +a=setup:actpass +a=dtls-id:1 +a=rtcp:10103 IN IP4 203.0.113.100 +a=rtcp-mux +a=rtcp-rsize +`; + +for (let setup of ['actpass', 'active', 'passive']) { + promise_test(async t => { + const sdp = kSdp.replace(/a=setup:actpass/g, + 'a=setup:' + setup); + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + await pc1.setRemoteDescription({type: 'offer', sdp: sdp}); + const answer = await pc1.createAnswer(); + const resultingSetup = answer.sdp.match(/a=setup:\S+/); + if (setup === 'active') { + assert_equals(resultingSetup[0], 'a=setup:passive'); + } else if (setup === 'passive') { + assert_equals(resultingSetup[0], 'a=setup:active'); + } else if (setup === 'actpass') { + // For actpass, either active or passive are legal, although + // active is RECOMMENDED by RFC 5763 / 8842. + assert_in_array(resultingSetup[0], ['a=setup:active', 'a=setup:passive']); + } + await pc1.setLocalDescription(answer); + }, 'PC should accept initial offer with setup=' + setup); +} + +for (let setup of ['actpass', 'active', 'passive']) { + const roleMap = { + actpass: 'client', + active: 'server', + passive: 'client', + }; + promise_test(async t => { + const sdp = kSdp.replace(/a=setup:actpass/g, + 'a=setup:' + setup); + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + await pc1.setRemoteDescription({type: 'offer', sdp: sdp}); + const answer = await pc1.createAnswer(); + const resultingSetup = answer.sdp.match(/a=setup:\S+/); + if (setup === 'active') { + assert_equals(resultingSetup[0], 'a=setup:passive'); + } else if (setup === 'passive') { + assert_equals(resultingSetup[0], 'a=setup:active'); + } else if (setup === 'actpass') { + // For actpass, either active or passive are legal, although + // active is RECOMMENDED by RFC 5763 / 8842. + assert_in_array(resultingSetup[0], ['a=setup:active', 'a=setup:passive']); + } + await pc1.setLocalDescription(answer); + const stats = await pc1.getStats(); + let transportStats; + stats.forEach(report => { + if (report.type === 'transport' && report.dtlsRole) { + transportStats = report; + } + }); + assert_equals(transportStats.dtlsRole, roleMap[setup]); + }, 'PC with setup=' + setup + ' should have a dtlsRole of ' + roleMap[setup]); +} + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + pc1.createDataChannel("wpt"); + await pc1.setLocalDescription(); + const stats = await pc1.getStats(); + let transportStats; + stats.forEach(report => { + if (report.type === 'transport' && report.dtlsRole) { + transportStats = report; + } + }); + assert_equals(transportStats.dtlsRole, 'unknown'); +}, 'dtlsRole is `unknown` before negotiation of the DTLS handshake'); +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/h264-profile-levels.https.html b/testing/web-platform/tests/webrtc/protocol/h264-profile-levels.https.html new file mode 100644 index 0000000000..cb0b581c30 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/h264-profile-levels.https.html @@ -0,0 +1,115 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection H.264 profile levels</title> +<meta name="timeout" content="long"> +<script src="../third_party/sdp/sdp.js"></script> +<script src="simulcast.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> + +function mungeLevel(sdp, level) { + level_hex = Math.round(level * 10).toString(16); + return { + type: sdp.type, + sdp: sdp.sdp.replace(/(profile-level-id=....)(..)/g, + "$1" + level_hex) + } +} + +// Numbers taken from +// https://en.wikipedia.org/wiki/Advanced_Video_Coding#Levels +let levelTable = { + 1: {mbs: 1485, fs: 99}, + 1.1: {mbs: 3000, fs: 396}, + 1.2: {mbs: 6000, fs: 396}, + 1.3: {mbs: 11880, fs: 396}, + 2: {mbs: 11880, fs: 396}, + 2.1: {mbs: 19800, fs: 792}, + 2.2: {mbs: 20250, fs: 1620}, + 3: {mbs: 40500, fs: 1620}, + 3.1: {mbs: 108000, fs: 3600}, + 3.2: {mbs: 216000, fs: 5120}, + 4: {mbs: 245760, fs: 8192}, + 4.1: {mbs: 245760, fs: 8192}, + 4.2: {mbs: 522240, fs: 8704}, + 5: {mbs: 589824, fs: 22800}, + 5.1: {mbs: 983040, fs: 36864}, + 5.2: {mbs: 2073600, fs: 36864}, + 6: {mbs: 4177920, fs: 139264}, + 6.1: {mbs: 8355840, fs: 139264}, + 6.2: {mbs: 16711680, fs: 139264}, +}; + +function sizeFitsLevel(width, height, fps, level) { + const frameSizeMacroblocks = width * height / 256; + const macroblocksPerSecond = frameSizeMacroblocks * fps; + assert_less_than_equal(frameSizeMacroblocks, + levelTable[level].fs, 'frame size'); + assert_less_than_equal(macroblocksPerSecond, + levelTable[level].mbs, 'macroblocks/second'); +} + +// Constant for now, may be variable later. +const framesPerSecond = 30; + +for (let level of Object.keys(levelTable)) { + promise_test(async t => { + assert_implements('getCapabilities' in RTCRtpSender, 'RTCRtpSender.getCapabilities not supported'); + assert_implements(RTCRtpSender.getCapabilities('video').codecs.find(c => c.mimeType === 'video/H264'), 'H264 not supported'); + + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + const v = document.createElement('video'); + + // Generate the largest video we can get from the attached device. + // This means platform inconsistency. + // The fake video in Chrome WPT tests is 3840x2160. + const stream = await navigator.mediaDevices.getUserMedia( + {video: {width: 12800, height: 7200, frameRate: framesPerSecond}}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const transceiver = pc1.addTransceiver(stream.getVideoTracks()[0], { + streams: [stream], + }); + preferCodec(transceiver, 'video/H264'); + + exchangeIceCandidates(pc1, pc2); + const trackEvent = new Promise(r => pc2.ontrack = r); + + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer), + await pc2.setRemoteDescription(offer); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(mungeLevel(answer, level)); + + v.srcObject = new MediaStream([(await trackEvent).track]); + let metadataLoaded = new Promise((resolve) => { + v.autoplay = true; + v.id = stream.id + v.addEventListener('loadedmetadata', () => { + resolve(); + }); + }); + await metadataLoaded; + // Ensure that H.264 is in fact used. + const statsReport = await transceiver.sender.getStats(); + for (const stats of statsReport.values()) { + if (stats.type === 'outbound-rtp') { + const activeCodec = stats.codecId; + const codecStats = statsReport.get(activeCodec); + assert_implements_optional(codecStats.mimeType ==='video/H264', + 'Level ' + level + ' H264 video is not supported'); + } + } + // TODO(hta): This will not catch situations where the initial size is + // within the permitted bounds, but resolution or framerate changes to + // outside the permitted bounds after a while. Should be addressed. + sizeFitsLevel(v.videoWidth, v.videoHeight, framesPerSecond, level); + }, 'Level ' + level + ' H264 video is appropriately constrained'); + +} +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/handover-datachannel.html b/testing/web-platform/tests/webrtc/protocol/handover-datachannel.html new file mode 100644 index 0000000000..8f224f822a --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/handover-datachannel.html @@ -0,0 +1,61 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Handovers</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +promise_test(async t => { + const offerPc = new RTCPeerConnection(); + const answerPcFirst = new RTCPeerConnection(); + const answerPcSecond = new RTCPeerConnection(); + t.add_cleanup(() => { + offerPc.close(); + answerPcFirst.close(); + answerPcSecond.close(); + }); + const offerDatachannel1 = offerPc.createDataChannel('initial'); + exchangeIceCandidates(offerPc, answerPcFirst); + + // Negotiate connection with PC 1 + const offer1 = await offerPc.createOffer(); + await offerPc.setLocalDescription(offer1); + await answerPcFirst.setRemoteDescription(offer1); + const answer1 = await answerPcFirst.createAnswer(); + await offerPc.setRemoteDescription(answer1); + await answerPcFirst.setLocalDescription(answer1); + const datachannelAtAnswerPcFirst = await new Promise( + r => answerPcFirst.ondatachannel = ({channel}) => r(channel)); + const iceTransport = offerPc.sctp.transport; + // Check that messages get through. + datachannelAtAnswerPcFirst.send('hello'); + const message1 = await awaitMessage(offerDatachannel1); + assert_equals(message1, 'hello'); + + // Renegotiate with PC 2 + // Note - ICE candidates will also be sent to answerPc1, but that shouldn't matter. + exchangeIceCandidates(offerPc, answerPcSecond); + const offer2 = await offerPc.createOffer(); + await offerPc.setLocalDescription(offer2); + await answerPcSecond.setRemoteDescription(offer2); + const answer2 = await answerPcSecond.createAnswer(); + await offerPc.setRemoteDescription(answer2); + await answerPcSecond.setLocalDescription(answer2); + + // Kill the first PC. This should not affect anything, but leaving it may cause untoward events. + answerPcFirst.close(); + + const answerDataChannel2 = answerPcSecond.createDataChannel('second back'); + + const datachannelAtOfferPcSecond = await new Promise(r => offerPc.ondatachannel = ({channel}) => r(channel)); + + await new Promise(r => datachannelAtOfferPcSecond.onopen = r); + + datachannelAtOfferPcSecond.send('hello again'); + const message2 = await awaitMessage(answerDataChannel2); + assert_equals(message2, 'hello again'); +}, 'Handover with datachannel reinitiated from new callee completes'); + +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/handover.html b/testing/web-platform/tests/webrtc/protocol/handover.html new file mode 100644 index 0000000000..748cbeff8d --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/handover.html @@ -0,0 +1,72 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Handovers</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +promise_test(async t => { + const offerPc = new RTCPeerConnection(); + const answerPcFirst = new RTCPeerConnection(); + const answerPcSecond = new RTCPeerConnection(); + t.add_cleanup(() => { + offerPc.close(); + answerPcFirst.close(); + answerPcSecond.close(); + }); + offerPc.addTransceiver('audio'); + // Negotiate connection with PC 1 + const offer1 = await offerPc.createOffer(); + await offerPc.setLocalDescription(offer1); + await answerPcFirst.setRemoteDescription(offer1); + const answer1 = await answerPcFirst.createAnswer(); + await offerPc.setRemoteDescription(answer1); + await answerPcFirst.setLocalDescription(answer1); + // Renegotiate with PC 2 + const offer2 = await offerPc.createOffer(); + await offerPc.setLocalDescription(offer2); + await answerPcSecond.setRemoteDescription(offer2); + const answer2 = await answerPcSecond.createAnswer(); + await offerPc.setRemoteDescription(answer2); + await answerPcSecond.setLocalDescription(answer2); +}, 'Negotiation of handover initiated at caller works'); + +promise_test(async t => { + const offerPc = new RTCPeerConnection(); + const answerPcFirst = new RTCPeerConnection(); + const answerPcSecond = new RTCPeerConnection(); + t.add_cleanup(() => { + offerPc.close(); + answerPcFirst.close(); + answerPcSecond.close(); + }); + offerPc.addTransceiver('audio'); + // Negotiate connection with PC 1 + const offer1 = await offerPc.createOffer(); + await offerPc.setLocalDescription(offer1); + await answerPcFirst.setRemoteDescription(offer1); + const answer1 = await answerPcFirst.createAnswer(); + await offerPc.setRemoteDescription(answer1); + await answerPcFirst.setLocalDescription(answer1); + // Renegotiate with PC 2 + // The offer from PC 2 needs to be consistent on at least the following: + // - Number, type and order of media sections + // - MID values + // - Payload type values + // Do a "fake" offer/answer using the original offer against PC2 to achieve this. + await answerPcSecond.setRemoteDescription(offer1); + // Discard the output of this round. + await answerPcSecond.setLocalDescription(await answerPcSecond.createAnswer()); + + // Now we can initiate an offer from the new PC. + const offer2 = await answerPcSecond.createOffer(); + await answerPcSecond.setLocalDescription(offer2); + await offerPc.setRemoteDescription(offer2); + const answer2 = await offerPc.createAnswer(); + await answerPcSecond.setRemoteDescription(answer2); + await offerPc.setLocalDescription(answer2); +}, 'Negotiation of handover initiated at callee works'); + +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/ice-state.https.html b/testing/web-platform/tests/webrtc/protocol/ice-state.https.html new file mode 100644 index 0000000000..becce59509 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/ice-state.https.html @@ -0,0 +1,130 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection Failed State</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +// Tests for correct behavior of ICE state. + +// SDP copied from JSEP Example 7.1 +// It contains two media streams with different ufrags, and bundle +// turned on. +const kSdp = `v=0 +o=- 4962303333179871722 1 IN IP4 0.0.0.0 +s=- +t=0 0 +a=ice-options:trickle +a=group:BUNDLE a1 v1 +a=group:LS a1 v1 +m=audio 10100 UDP/TLS/RTP/SAVPF 96 0 8 97 98 +c=IN IP4 203.0.113.100 +a=mid:a1 +a=sendrecv +a=rtpmap:96 opus/48000/2 +a=rtpmap:0 PCMU/8000 +a=rtpmap:8 PCMA/8000 +a=rtpmap:97 telephone-event/8000 +a=rtpmap:98 telephone-event/48000 +a=maxptime:120 +a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid +a=extmap:2 urn:ietf:params:rtp-hdrext:ssrc-audio-level +a=msid:47017fee-b6c1-4162-929c-a25110252400 f83006c5-a0ff-4e0a-9ed9-d3e6747be7d9 +a=ice-ufrag:ETEn +a=ice-pwd:OtSK0WpNtpUjkY4+86js7ZQl +a=fingerprint:sha-256 19:E2:1C:3B:4B:9F:81:E6:B8:5C:F4:A5:A8:D8:73:04:BB:05:2F:70:9F:04:A9:0E:05:E9:26:33:E8:70:88:A2 +a=setup:actpass +a=dtls-id:1 +a=rtcp:10101 IN IP4 203.0.113.100 +a=rtcp-mux +a=rtcp-rsize +m=video 10102 UDP/TLS/RTP/SAVPF 100 101 +c=IN IP4 203.0.113.100 +a=mid:v1 +a=sendrecv +a=rtpmap:100 VP8/90000 +a=rtpmap:101 rtx/90000 +a=fmtp:101 apt=100 +a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid +a=rtcp-fb:100 ccm fir +a=rtcp-fb:100 nack +a=rtcp-fb:100 nack pli +a=msid:47017fee-b6c1-4162-929c-a25110252400 f30bdb4a-5db8-49b5-bcdc-e0c9a23172e0 +a=ice-ufrag:BGKk +a=ice-pwd:mqyWsAjvtKwTGnvhPztQ9mIf +a=fingerprint:sha-256 19:E2:1C:3B:4B:9F:81:E6:B8:5C:F4:A5:A8:D8:73:04:BB:05:2F:70:9F:04:A9:0E:05:E9:26:33:E8:70:88:A2 +a=setup:actpass +a=dtls-id:1 +a=rtcp:10103 IN IP4 203.0.113.100 +a=rtcp-mux +a=rtcp-rsize +`; + +// Returns a promise that resolves when |pc.iceConnectionState| is in one of the +// wanted states, and rejects if it is in one of the unwanted states. +// This is a variant of the function in RTCPeerConnection-helper.js. +function waitForIceStateChange(pc, wantedStates, unwantedStates=[]) { + return new Promise((resolve, reject) => { + if (wantedStates.includes(pc.iceConnectionState)) { + resolve(); + return; + } else if (unwantedStates.includes(pc.iceConnectionState)) { + reject('Unexpected state encountered: ' + pc.iceConnectionState); + return; + } + pc.addEventListener('iceconnectionstatechange', () => { + if (wantedStates.includes(pc.iceConnectionState)) { + resolve(); + } else if (unwantedStates.includes(pc.iceConnectionState)) { + reject('Unexpected state encountered: ' + pc.iceConnectionState); + } + }); + }); +} + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + let [track, streams] = await getTrackFromUserMedia('video'); + const sender = pc1.addTrack(track); + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + await waitForIceStateChange(pc1, ['connected', 'completed']); +}, 'PC should enter connected (or completed) state when candidates are sent'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + let [track, streams] = await getTrackFromUserMedia('video'); + const sender = pc1.addTrack(track); + const offer = await pc1.createOffer(); + assert_greater_than_equal(offer.sdp.search('a=ice-options:trickle'), 0); +}, 'PC should generate offer with a=ice-options:trickle'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + await pc1.setRemoteDescription({type: 'offer', sdp: kSdp}); + const answer = await pc1.createAnswer(); + await pc1.setLocalDescription(answer); + assert_greater_than_equal(answer.sdp.search('a=ice-options:trickle'), 0); + // When we use trickle ICE, and don't signal end-of-caniddates, we + // expect failure to result in 'disconnected' state rather than 'failed'. + const stateWaiter = waitForIceStateChange(pc1, ['disconnected'], + ['failed']); + // Add a bogus candidate. The candidate is drawn from the + // IANA "test-net-3" pool (RFC5737), so is guaranteed not to respond. + const candidateStr1 = + 'candidate:1 1 udp 2113929471 203.0.113.100 10100 typ host'; + await pc1.addIceCandidate({candidate: candidateStr1, + sdpMid: 'a1', + usernameFragment: 'ETEn'}); + await stateWaiter; +}, 'PC should enter disconnected state when a failing candidate is sent'); + +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/ice-ufragpwd.html b/testing/web-platform/tests/webrtc/protocol/ice-ufragpwd.html new file mode 100644 index 0000000000..bd151284cb --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/ice-ufragpwd.html @@ -0,0 +1,55 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection Failed State</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +// Tests for validating ice-ufrag and ice-pwd syntax defined in +// https://tools.ietf.org/html/rfc5245#section-15.4 +// Alphanumeric, '+' and '/' are allowed. + +const preamble = `v=0 +o=- 0 3 IN IP4 127.0.0.1 +s=- +t=0 0 +a=fingerprint:sha-256 A7:24:72:CA:6E:02:55:39:BA:66:DF:6E:CC:4C:D8:B0:1A:BF:1A:56:65:7D:F4:03:AD:7E:77:43:2A:29:EC:93 +m=video 1 RTP/SAVPF 100 +c=IN IP4 0.0.0.0 +a=rtcp-mux +a=sendonly +a=mid:video +a=rtpmap:100 VP8/30 +a=setup:actpass +`; +const valid_ufrag = 'a=ice-ufrag:ETEn\r\n'; +const valid_pwd = 'a=ice-pwd:OtSK0WpNtpUjkY4+86js7Z/l\r\n'; +const not_ice_char = '$'; // A snowman emoji would be cool but is not interoperable. + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const sdp = preamble + + valid_ufrag.replace('ETEn', 'E' + not_ice_char + 'En') + + valid_pwd; + + return promise_rejects_dom(t, 'InvalidAccessError', + pc.setRemoteDescription({type: 'offer', sdp})); +}, 'setRemoteDescription with a ice-ufrag containing a non-ice-char fails'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + const sdp = preamble + + valid_ufrag + + valid_pwd.replace('K0Wp', 'K' + not_ice_char + 'Wp'); + + return promise_rejects_dom(t, 'InvalidAccessError', + pc.setRemoteDescription({type: 'offer', sdp})); +}, 'setRemoteDescription with a ice-pwd containing a non-ice-char fails'); +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/jsep-initial-offer.https.html b/testing/web-platform/tests/webrtc/protocol/jsep-initial-offer.https.html new file mode 100644 index 0000000000..50527f88df --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/jsep-initial-offer.https.html @@ -0,0 +1,41 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.createOffer</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> + 'use strict'; + + // Tests for the construction of initial offers according to + // draft-ietf-rtcweb-jsep-24 section 5.2.1 + promise_test(async t => { + const pc = new RTCPeerConnection(); + const offer = await generateVideoReceiveOnlyOffer(pc); + let offer_lines = offer.sdp.split('\r\n'); + // The first 3 lines are dictated by JSEP. + assert_equals(offer_lines[0], "v=0"); + assert_equals(offer_lines[1].slice(0, 2), "o="); + + assert_regexp_match(offer_lines[1], /^o=\S+ \d+ \d+ IN IP4 \S+$/); + const fields = RegExp(/^o=\S+ (\d+) (\d+) IN IP4 (\S+)/).exec(offer_lines[1]); + // Per RFC 3264, the sess-id should be representable in an uint64 + // Note: JSEP -24 has this wrong - see bug: + // https://github.com/rtcweb-wg/jsep/issues/855 + assert_less_than(Number(fields[1]), 2**64); + // Per RFC 3264, the version should be less than 2^62 to avoid overflow + assert_less_than(Number(fields[2]), 2**62); + // JSEP says that the address part SHOULD be a meaningless address + // "such as" IN IP4 0.0.0.0. This is to prevent unintentional disclosure + // of IP addresses, so this is important enough to verify. Right now we + // allow 127.0.0.1 and 0.0.0.0, but there are other things we could allow. + // Maybe 0.0.0.0/8, 127.0.0.0/8, 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24? + // (See RFC 3330, RFC 5737) + assert_true(fields[3] == "0.0.0.0" || fields[3] == "127.0.0.1", + fields[3] + " must be a meaningless IPV4 address") + + assert_regexp_match(offer_lines[2], /^s=\S+$/); + // After this, the order is not dictated by JSEP. + // TODO: Check lines subsequent to the s= line. + }, 'Offer conforms to basic SDP requirements'); +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/missing-fields.html b/testing/web-platform/tests/webrtc/protocol/missing-fields.html new file mode 100644 index 0000000000..d5aafd230e --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/missing-fields.html @@ -0,0 +1,47 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerconnection SDP parse tests</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +function removeSdpLines(description, toRemove) { + const edited = description.sdp.split('\n').filter(function(line) { + return (!line.startsWith(toRemove)); + }).join('\n'); + return {type: description.type, sdp: edited}; +} + +promise_test(async t => { + const caller = new RTCPeerConnection(); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + t.add_cleanup(() => callee.close()); + caller.addTrack(trackFactories.audio()); + const offer = await caller.createOffer(); + await caller.setLocalDescription(offer); + let remote_offer = removeSdpLines(offer, 'a=mid:'); + remote_offer = removeSdpLines(remote_offer, 'a=group:'); + await callee.setRemoteDescription(remote_offer); + const answer = await callee.createAnswer(); + await caller.setRemoteDescription(answer); +}, 'Offer description with no mid is accepted'); + +promise_test(async t => { + const caller = new RTCPeerConnection(); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + t.add_cleanup(() => callee.close()); + caller.addTrack(trackFactories.audio()); + const offer = await caller.createOffer(); + await caller.setLocalDescription(offer); + await callee.setRemoteDescription(offer); + const answer = await callee.createAnswer(); + let remote_answer = removeSdpLines(answer, 'a=mid:'); + remote_answer = removeSdpLines(remote_answer, 'a=group:'); + await caller.setRemoteDescription(remote_answer); +}, 'Answer description with no mid is accepted'); + +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/msid-generate.html b/testing/web-platform/tests/webrtc/protocol/msid-generate.html new file mode 100644 index 0000000000..29226c704e --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/msid-generate.html @@ -0,0 +1,160 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerconnection MSID generation</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="../third_party/sdp/sdp.js"></script> +<script> + +function msidLines(desc) { + const sections = SDPUtils.splitSections(desc.sdp); + return SDPUtils.matchPrefix(sections[1], 'a=msid:'); +} + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const dc = pc.createDataChannel('foo'); + const desc = await pc.createOffer(); + assert_equals(msidLines(desc).length, 0); +}, 'No media track produces no MSID'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream1 = await getNoiseStream({audio: true}); + pc.addTrack(stream1.getTracks()[0]); + const desc = await pc.createOffer(); + const msid_lines = msidLines(desc); + assert_equals(msid_lines.length, 1); + assert_regexp_match(msid_lines[0], /^a=msid:-/); +}, 'AddTrack without a stream produces MSID with no stream ID'); + +// token-char from RFC 4566 +// This is printable characters except whitespace, and ["(),/:;<=>?@[\]] +const token_char = '\\x21\\x23-\\x27\\x2A-\\x2B\\x2D-\\x2E\\x30-\\x39\\x41-\\x5A\\x5E-\\x7E'; + +// msid-value from RFC 8830 +const msid_attr = RegExp(`^a=msid:[${token_char}]{1,64}( [${token_char}]{1,64})?$`); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream1 = await getNoiseStream({audio: true}); + pc.addTrack(stream1.getTracks()[0], stream1); + const desc = await pc.createOffer(); + const msid_lines = msidLines(desc); + assert_equals(msid_lines.length, 1); + assert_regexp_match(msid_lines[0], msid_attr); +}, 'AddTrack with a stream produces MSID with a stream ID'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream1 = await getNoiseStream({audio: true}); + const stream2 = new MediaStream(stream1.getTracks()); + pc.addTrack(stream1.getTracks()[0], stream1, stream2); + const desc = await pc.createOffer(); + const msid_lines = msidLines(desc); + assert_equals(msid_lines.length, 2); + assert_regexp_match(msid_lines[0], msid_attr); + assert_regexp_match(msid_lines[1], msid_attr); +}, 'AddTrack with two streams produces two MSID lines'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream1 = await getNoiseStream({audio: true}); + pc.addTrack(stream1.getTracks()[0], stream1, stream1); + const desc = await pc.createOffer(); + const msid_lines = msidLines(desc); + assert_equals(msid_lines.length, 1); + assert_regexp_match(msid_lines[0], msid_attr); +}, 'AddTrack with the stream twice produces single MSID with a stream ID'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream1 = await getNoiseStream({audio: true}); + pc.addTransceiver(stream1.getTracks()[0]); + const desc = await pc.createOffer(); + const msid_lines = msidLines(desc); + assert_equals(msid_lines.length, 1); + assert_regexp_match(msid_lines[0], /^a=msid:-/); +}, 'AddTransceiver without a stream produces MSID with no stream ID'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream1 = await getNoiseStream({audio: true}); + pc.addTransceiver(stream1.getTracks()[0], {streams: [stream1]}); + const desc = await pc.createOffer(); + const msid_lines = msidLines(desc); + assert_equals(msid_lines.length, 1); + assert_regexp_match(msid_lines[0], msid_attr); +}, 'AddTransceiver with a stream produces MSID with a stream ID'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream1 = await getNoiseStream({audio: true}); + const stream2 = new MediaStream(stream1.getTracks()); + pc.addTransceiver(stream1.getTracks()[0], {streams: [stream1, stream2]}); + const desc = await pc.createOffer(); + const msid_lines = msidLines(desc); + assert_equals(msid_lines.length, 2); + assert_regexp_match(msid_lines[0], msid_attr); + assert_regexp_match(msid_lines[1], msid_attr); +}, 'AddTransceiver with two streams produces two MSID lines'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream1 = await getNoiseStream({audio: true}); + pc.addTransceiver(stream1.getTracks()[0], {streams: [stream1, stream1]}); +const desc = await pc.createOffer(); + const msid_lines = msidLines(desc); + assert_equals(msid_lines.length, 1); + assert_regexp_match(msid_lines[0], msid_attr); +}, 'AddTransceiver with the stream twice produces single MSID with a stream ID'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream1 = await getNoiseStream({audio: true}); + const {sender} = pc.addTransceiver(stream1.getTracks()[0]); + sender.setStreams(stream1); + const desc = await pc.createOffer(); + const msid_lines = msidLines(desc); + assert_equals(msid_lines.length, 1); + assert_regexp_match(msid_lines[0], msid_attr); +}, 'SetStreams with a stream produces MSID with a stream ID'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream1 = await getNoiseStream({audio: true}); + const stream2 = new MediaStream(stream1.getTracks()); + const {sender} = pc.addTransceiver(stream1.getTracks()[0]); + sender.setStreams(stream1, stream2); + const desc = await pc.createOffer(); + const msid_lines = msidLines(desc); + assert_equals(msid_lines.length, 2); + assert_regexp_match(msid_lines[0], msid_attr); + assert_regexp_match(msid_lines[1], msid_attr); +}, 'SetStreams with two streams produces two MSID lines'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const stream1 = await getNoiseStream({audio: true}); + const {sender} = pc.addTransceiver(stream1.getTracks()[0]); + sender.setStreams(stream1, stream1); + const desc = await pc.createOffer(); + const msid_lines = msidLines(desc); + assert_equals(msid_lines.length, 1); + assert_regexp_match(msid_lines[0], msid_attr); +}, 'SetStreams with the stream twice produces single MSID with a stream ID'); + +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/msid-parse.html b/testing/web-platform/tests/webrtc/protocol/msid-parse.html new file mode 100644 index 0000000000..5596446e00 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/msid-parse.html @@ -0,0 +1,83 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerconnection MSID parsing</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +'use strict'; +const preamble = `v=0 +o=- 0 3 IN IP4 127.0.0.1 +s=- +t=0 0 +a=fingerprint:sha-256 A7:24:72:CA:6E:02:55:39:BA:66:DF:6E:CC:4C:D8:B0:1A:BF:1A:56:65:7D:F4:03:AD:7E:77:43:2A:29:EC:93 +a=ice-ufrag:6HHHdzzeIhkE0CKj +a=ice-pwd:XYDGVpfvklQIEnZ6YnyLsAew +m=video 1 RTP/SAVPF 100 +c=IN IP4 0.0.0.0 +a=rtcp-mux +a=sendonly +a=mid:video +a=rtpmap:100 VP8/30 +a=setup:actpass +`; + + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const ontrackPromise = addEventListenerPromise(t, pc, 'track'); + await pc.setRemoteDescription({type: 'offer', sdp: preamble}); + const trackevent = await ontrackPromise; + assert_equals(pc.getReceivers().length, 1); + assert_equals(trackevent.streams.length, 1, 'Stream count'); +}, 'Description with no msid produces a track with a stream'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const ontrackPromise = addEventListenerPromise(t, pc, 'track'); + await pc.setRemoteDescription({type: 'offer', + sdp: preamble + 'a=msid:- foobar\n'}); + const trackevent = await ontrackPromise; + assert_equals(pc.getReceivers().length, 1); + assert_equals(trackevent.streams.length, 0); +}, 'Description with msid:- appid produces a track with no stream'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const ontrackPromise = addEventListenerPromise(t, pc, 'track'); + await pc.setRemoteDescription({type: 'offer', + sdp: preamble + 'a=msid:foo bar\n'}); + const trackevent = await ontrackPromise; + assert_equals(pc.getReceivers().length, 1); + assert_equals(trackevent.streams.length, 1); + assert_equals(trackevent.streams[0].id, 'foo'); +}, 'Description with msid:foo bar produces a stream with id foo'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const ontrackPromise = addEventListenerPromise(t, pc, 'track'); + await pc.setRemoteDescription({type: 'offer', + sdp: preamble + 'a=msid:foo bar\n' + + 'a=msid:baz bar\n'}); + const trackevent = await ontrackPromise; + assert_equals(pc.getReceivers().length, 1); + assert_equals(trackevent.streams.length, 2); +}, 'Description with two msid produces two streams'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const ontrackPromise = addEventListenerPromise(t, pc, 'track'); + await pc.setRemoteDescription({type: 'offer', + sdp: preamble + 'a=msid:foo\n'}); + const trackevent = await ontrackPromise; + assert_equals(pc.getReceivers().length, 1); + assert_equals(trackevent.streams.length, 1); + assert_equals(trackevent.streams[0].id, 'foo'); +}, 'Description with msid foo but no track id is accepted'); + +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/rtp-clockrate.html b/testing/web-platform/tests/webrtc/protocol/rtp-clockrate.html new file mode 100644 index 0000000000..4177420050 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/rtp-clockrate.html @@ -0,0 +1,40 @@ +<!doctype html> +<meta charset=utf-8> +<!-- This file contains a test that waits for two seconds. --> +<meta name="timeout" content="long"> +<title>RTP clockrate</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +async function initiateSingleTrackCallAndReturnReceiver(t, kind) { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({[kind]:true}); + const [track] = stream.getTracks(); + t.add_cleanup(() => track.stop()); + pc1.addTrack(track, stream); + + exchangeIceCandidates(pc1, pc2); + const trackEvent = await exchangeOfferAndListenToOntrack(t, pc1, pc2); + await exchangeAnswer(pc1, pc2); + await waitForConnectionStateChange(pc2, ['connected']); + return trackEvent.receiver; +} + +promise_test(async t => { + // the getSynchronizationSources API exposes the rtp timestamp. + const receiver = await initiateSingleTrackCallAndReturnReceiver(t, 'video'); + const first = await listenForSSRCs(t, receiver); + await new Promise(resolve => t.step_timeout(resolve, 2000)); + const second = await listenForSSRCs(t, receiver); + // rtpTimestamp may wrap at 0xffffffff, take care of that. + const actualClockRate = ((second[0].rtpTimestamp - first[0].rtpTimestamp + 0xffffffff) % 0xffffffff) / (second[0].timestamp - first[0].timestamp) * 1000; + assert_approx_equals(actualClockRate, 90000, 9000, 'Video clockrate is approximately 90000'); +}, 'video rtp timestamps increase by approximately 90000 per second'); +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/rtp-demuxing.html b/testing/web-platform/tests/webrtc/protocol/rtp-demuxing.html new file mode 100644 index 0000000000..de08b2197f --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/rtp-demuxing.html @@ -0,0 +1,109 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection payload type demuxing</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +'use strict'; +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + exchangeIceCandidates(caller, callee); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + stream.getTracks().forEach(track => caller.addTrack(track, stream)); + stream.getTracks().forEach(track => caller.addTrack(track.clone(), stream.clone())); + + let callCount = 0; + let metadataToBeLoaded = new Promise(resolve => { + callee.ontrack = (e) => { + const stream = e.streams[0]; + const v = document.createElement('video'); + v.autoplay = true; + v.srcObject = stream; + v.id = stream.id + v.addEventListener('loadedmetadata', () => { + if (++callCount === 2) { + resolve(); + } + }); + }; + }); + + // Restrict first transceiver to VP8, second to H264. + const {codecs} = RTCRtpSender.getCapabilities('video'); + const vp8 = codecs.find(c => c.mimeType === 'video/VP8'); + const h264 = codecs.find(c => c.mimeType === 'video/H264'); + caller.getTransceivers()[0].setCodecPreferences([vp8]); + caller.getTransceivers()[1].setCodecPreferences([h264]); + + const offer = await caller.createOffer(); + // Replace the mid header extension and all ssrc lines + // with bogus. The receiver will be forced to do payload type demuxing. + const sdp = offer.sdp + .replace(/rtp-hdrext:sdes/g, 'rtp-hdrext:something') + .replace(/a=ssrc:/g, 'a=notssrc'); + + await callee.setRemoteDescription({type: 'offer', sdp}); + await caller.setLocalDescription(offer); + + const answer = await callee.createAnswer(); + await caller.setRemoteDescription(answer); + await callee.setLocalDescription(answer); + + await metadataToBeLoaded; +}, 'Can demux two video tracks with different payload types on a bundled connection'); + +promise_test(async t => { + const caller = new RTCPeerConnection({bundlePolicy: 'max-compat'}); + t.add_cleanup(() => caller.close()); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => callee.close()); + exchangeIceCandidates(caller, callee); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + stream.getTracks().forEach(track => caller.addTrack(track, stream)); + stream.getTracks().forEach(track => caller.addTrack(track.clone(), stream.clone())); + + let callCount = 0; + let metadataToBeLoaded = new Promise(resolve => { + callee.ontrack = (e) => { + const stream = e.streams[0]; + const v = document.createElement('video'); + v.autoplay = true; + v.srcObject = stream; + v.id = stream.id + v.addEventListener('loadedmetadata', () => { + if (++callCount === 2) { + resolve(); + } + }); + }; + }); + + const offer = await caller.createOffer(); + // Replace BUNDLE, the mid header extension and all ssrc lines + // with bogus. The receiver will be forced to do payload type demuxing + // which is still possible because the different m-lines arrive on + // different ports/sockets. + const sdp = offer.sdp.replace('BUNDLE', 'SOMETHING') + .replace(/rtp-hdrext:sdes/g, 'rtp-hdrext:something') + .replace(/a=ssrc:/g, 'a=notssrc'); + + await callee.setRemoteDescription({type: 'offer', sdp}); + await caller.setLocalDescription(offer); + + const answer = await callee.createAnswer(); + await caller.setRemoteDescription(answer); + await callee.setLocalDescription(answer); + + await metadataToBeLoaded; +}, 'Can demux two video tracks with the same payload type on an unbundled connection'); + +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/rtp-extension-support.html b/testing/web-platform/tests/webrtc/protocol/rtp-extension-support.html new file mode 100644 index 0000000000..045701c171 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/rtp-extension-support.html @@ -0,0 +1,78 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection RTP extensions</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../third_party/sdp/sdp.js"></script> +<script> +'use strict'; + +async function setup() { + const pc1 = new RTCPeerConnection(); + pc1.addTransceiver('audio'); + // Make sure there is more than one rid, since there's no reason to use + // rtp-stream-id/repaired-rtp-stream-id otherwise. Some implementations + // may use them for unicast anyway, which isn't a spec violation, just + // a little silly. + pc1.addTransceiver('video', {sendEncodings: [{rid: '0'}, {rid: '1'}]}); + const offer = await pc1.createOffer(); + pc1.close(); + return offer.sdp; +} + +// Extensions that MUST be supported +const mandatoryExtensions = [ + // Directly referenced in WebRTC RTP usage + 'urn:ietf:params:rtp-hdrext:ssrc-audio-level', // RFC 8834 5.2.2 + 'urn:ietf:params:rtp-hdrext:sdes:mid', // RFC 8834 5.2.4 + 'urn:3gpp:video-orientation', // RFC 8834 5.2.5 + // Required for support of simulcast with RID + 'urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id', // RFC 8852 4.3 + 'urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id', // RFC 8852 4.4 +]; + +// For further testing: +// - Add test for rapid synchronization - RFC 8834 5.2.1 +// - Add test for encrypted header extensions (RFC 6904) +// - Separate tests for extensions in audio and video sections + +for (const extension of mandatoryExtensions) { + promise_test(async t => { + const sdp = await setup(); + const extensions = SDPUtils.matchPrefix(sdp, 'a=extmap:') + .map(SDPUtils.parseExtmap); + assert_true(!!extensions.find(ext => ext.uri === extension)); + }, `RTP header extension ${extension} is present in offer`); +} + +// Test for illegal remote behavior: Reassignment of hdrext ID +// in a subsequent offer/answer cycle. +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + pc1.addTransceiver('audio'); + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + // Do a second offer/answer cycle. + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + const answer = await pc2.createAnswer(); + + // Swap the extension number of the two required extensions + answer.sdp = answer.sdp.replace('urn:ietf:params:rtp-hdrext:ssrc-audio-level', + 'xyzzy') + .replace('urn:ietf:params:rtp-hdrext:sdes:mid', + 'urn:ietf:params:rtp-hdrext:ssrc-audio-level') + .replace('xyzzy', + 'urn:ietf:params:rtp-hdrext:sdes:mid'); + + return promise_rejects_dom(t, 'InvalidAccessError', + pc1.setRemoteDescription(answer)); +}, 'RTP header extension reassignment causes failure'); + +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/rtp-headerextensions.html b/testing/web-platform/tests/webrtc/protocol/rtp-headerextensions.html new file mode 100644 index 0000000000..c377a613f6 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/rtp-headerextensions.html @@ -0,0 +1,101 @@ +<!doctype html> +<meta charset=utf-8> +<title>payload type handling (assuming rtcp-mux)</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../third_party/sdp/sdp.js"></script> +<script> +'use strict'; +// Tests behaviour from https://www.rfc-editor.org/rfc/rfc8834.html#name-header-extensions + +function createOfferSdp(extmaps) { + let sdp = `v=0 +o=- 0 3 IN IP4 127.0.0.1 +s=- +t=0 0 +a=fingerprint:sha-256 A7:24:72:CA:6E:02:55:39:BA:66:DF:6E:CC:4C:D8:B0:1A:BF:1A:56:65:7D:F4:03:AD:7E:77:43:2A:29:EC:93 +a=ice-ufrag:6HHHdzzeIhkE0CKj +a=ice-pwd:XYDGVpfvklQIEnZ6YnyLsAew +`; + sdp += 'a=group:BUNDLE ' + ['audio', 'video'].filter(kind => extmaps[kind]).join(' ') + '\r\n'; + if (extmaps.audio) { + sdp += `m=audio 9 RTP/SAVPF 111 +c=IN IP4 0.0.0.0 +a=rtcp-mux +a=sendonly +a=mid:audio +a=rtpmap:111 opus/48000/2 +a=setup:actpass +` + extmaps.audio.map(ext => SDPUtils.writeExtmap(ext)); + } + if (extmaps.video) { + sdp += `m=video 9 RTP/SAVPF 112 +c=IN IP4 0.0.0.0 +a=rtcp-mux +a=sendonly +a=mid:video +a=rtpmap:112 VP8/90000 +a=setup:actpass +` + extmaps.video.map(ext => SDPUtils.writeExtmap(ext)); + } + return sdp; +} + +[ + // https://www.rfc-editor.org/rfc/rfc8834.html#section-5.2.4 + { + audio: [{id: 1, uri: 'urn:ietf:params:rtp-hdrext:sdes:mid'}], + video: [{id: 1, uri: 'urn:ietf:params:rtp-hdrext:sdes:mid'}], + description: 'MID', + }, + { + // https://www.rfc-editor.org/rfc/rfc8834.html#section-5.2.2 + audio: [{id: 1, uri: 'urn:ietf:params:rtp-hdrext:ssrc-audio-level'}], + description: 'Audio level', + }, + { + // https://www.rfc-editor.org/rfc/rfc8834.html#section-5.2.5 + video: [{id: 1, uri: 'urn:3gpp:video-orientation'}], + description: 'Video orientation', + } +].forEach(testcase => { + promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + await pc.setRemoteDescription({type: 'offer', sdp: createOfferSdp(testcase)}); + const answer = await pc.createAnswer(); + const sections = SDPUtils.splitSections(answer.sdp); + sections.shift(); + sections.forEach(section => { + const rtpParameters = SDPUtils.parseRtpParameters(section); + assert_equals(rtpParameters.headerExtensions.length, 1); + assert_equals(rtpParameters.headerExtensions[0].id, testcase[SDPUtils.getKind(section)][0].id); + assert_equals(rtpParameters.headerExtensions[0].uri, testcase[SDPUtils.getKind(section)][0].uri); + }); + }, testcase.description + ' header extension is supported.'); +}); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + pc.addTransceiver('video'); + const offer = await pc.createOffer(); + const section = SDPUtils.splitSections(offer.sdp)[1]; + const extensions = SDPUtils.matchPrefix(section, 'a=extmap:') + .map(line => SDPUtils.parseExtmap(line)); + const extension_not_mid = extensions.find(e => e.uri !== 'urn:ietf:params:rtp-hdrext:sdes:mid'); + await pc.setRemoteDescription({type :'offer', sdp: offer.sdp.replace(extension_not_mid.uri, 'bogus')}); + + await pc.setLocalDescription(); + const answer_section = SDPUtils.splitSections(pc.localDescription.sdp)[1]; + const answer_extensions = SDPUtils.matchPrefix(answer_section, 'a=extmap:') + .map(line => SDPUtils.parseExtmap(line)); + assert_equals(answer_extensions.length, extensions.length - 1); + assert_false(!!extensions.find(e => e.uri === 'bogus')); + for (const answer_extension of answer_extensions) { + assert_true(!!extensions.find(e => e.uri === answer_extension.uri)); + } +}, 'Negotiates the subset of supported extensions offered'); +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/rtp-payloadtypes.html b/testing/web-platform/tests/webrtc/protocol/rtp-payloadtypes.html new file mode 100644 index 0000000000..af7656d131 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/rtp-payloadtypes.html @@ -0,0 +1,61 @@ +<!doctype html> +<meta charset=utf-8> +<title>payload type handling (assuming rtcp-mux)</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +'use strict'; +// Tests behaviour from https://tools.ietf.org/html/rfc5761#section-4 + +function createOfferSdp(opusPayloadType) { + return `v=0 +o=- 0 3 IN IP4 127.0.0.1 +s=- +t=0 0 +a=fingerprint:sha-256 A7:24:72:CA:6E:02:55:39:BA:66:DF:6E:CC:4C:D8:B0:1A:BF:1A:56:65:7D:F4:03:AD:7E:77:43:2A:29:EC:93 +a=ice-ufrag:6HHHdzzeIhkE0CKj +a=ice-pwd:XYDGVpfvklQIEnZ6YnyLsAew +m=audio 9 RTP/SAVPF ${opusPayloadType} +c=IN IP4 0.0.0.0 +a=rtcp-mux +a=sendonly +a=mid:audio +a=rtpmap:${opusPayloadType} opus/48000/2 +a=setup:actpass +`; +} + +promise_test(async t => { + for (let payloadType = 96; payloadType <= 127; payloadType++) { + const pc = new RTCPeerConnection(); + await pc.setRemoteDescription({type: 'offer', sdp: createOfferSdp(payloadType)}); + const answer = await pc.createAnswer(); + assert_true(answer.sdp.includes(`a=rtpmap:${payloadType} opus/48000/2`)); + pc.close(); + } +}, 'setRemoteDescription with a codec in the range 96-127 works'); + +// This is written as a separate test since it currently fails in Chrome. +promise_test(async t => { + for (let payloadType = 35; payloadType <= 63; payloadType++) { + const pc = new RTCPeerConnection(); + await pc.setRemoteDescription({type: 'offer', sdp: createOfferSdp(payloadType)}); + const answer = await pc.createAnswer(); + assert_true(answer.sdp.includes(`a=rtpmap:${payloadType} opus/48000/2`)); + pc.close(); + } +}, 'setRemoteDescription with a codec in the range 35-63 works'); + +promise_test(async t => { + for (let payloadType = 64; payloadType <= 95; payloadType++) { + const pc = new RTCPeerConnection(); + await promise_rejects_dom(t, 'InvalidAccessError', + pc.setRemoteDescription({type: 'offer', sdp: createOfferSdp(payloadType)}), + 'Failed to reject on PT ' + payloadType); + + + pc.close(); + } +}, 'setRemoteDescription with a codec in the range 64-95 throws an InvalidAccessError'); +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/rtx-codecs.https.html b/testing/web-platform/tests/webrtc/protocol/rtx-codecs.https.html new file mode 100644 index 0000000000..78519c75cc --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/rtx-codecs.https.html @@ -0,0 +1,153 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTX codec integrity checks</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="../third_party/sdp/sdp.js"></script> +<script> +'use strict'; + +// Tests for conformance to rules for RTX codecs. +// Basic rule: Offers and answers must contain RTX codecs, and the +// RTX codecs must have an a=fmtp line that points to a non-RTX codec. + +// Helper function for doing one round of offer/answer exchange +// between two local peer connections. +// Calls setRemoteDescription(offer/answer) before +// setLocalDescription(offer/answer) to ensure the remote description +// is set and candidates can be added before the local peer connection +// starts generating candidates and ICE checks. +async function doSignalingHandshake(localPc, remotePc, options={}) { + let offer = await localPc.createOffer(); + // Modify offer if callback has been provided + if (options.modifyOffer) { + offer = await options.modifyOffer(offer); + } + + // Apply offer. + await remotePc.setRemoteDescription(offer); + await localPc.setLocalDescription(offer); + + let answer = await remotePc.createAnswer(); + // Modify answer if callback has been provided + if (options.modifyAnswer) { + answer = await options.modifyAnswer(answer); + } + + // Apply answer. + await localPc.setRemoteDescription(answer); + await remotePc.setLocalDescription(answer); +} + +function verifyRtxReferences(description) { + const mediaSection = SDPUtils.getMediaSections(description.sdp)[0]; + const rtpParameters = SDPUtils.parseRtpParameters(mediaSection); + for (const codec of rtpParameters.codecs) { + if (codec.name === 'rtx') { + assert_own_property(codec.parameters, 'apt', 'rtx codec has apt parameter'); + const referenced_codec = rtpParameters.codecs.find( + c => c.payloadType === parseInt(codec.parameters.apt)); + assert_true(referenced_codec !== undefined, `Found referenced codec`); + } + } +} + + + +promise_test(async t => { + const pc = new RTCPeerConnection(); + const offer = await generateVideoReceiveOnlyOffer(pc); + verifyRtxReferences(offer); +}, 'Initial offer should have sensible RTX mappings'); + +async function negotiateAndReturnAnswer(t) { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + let [track, streams] = await getTrackFromUserMedia('video'); + const sender = pc1.addTrack(track); + await doSignalingHandshake(pc1, pc2); + return pc2.localDescription; +} + +promise_test(async t => { + const answer = await negotiateAndReturnAnswer(t); + verifyRtxReferences(answer); +}, 'Self-negotiated answer should have sensible RTX parameters'); + +promise_test(async t => { + const sampleOffer = `v=0 +o=- 1878890426675213188 2 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE video +a=msid-semantic: WMS +m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 +c=IN IP4 0.0.0.0 +a=rtcp:9 IN IP4 0.0.0.0 +a=ice-ufrag:RGPK +a=ice-pwd:rAyHEAKC7ckxQgWaRZXukz+Z +a=ice-options:trickle +a=fingerprint:sha-256 8C:29:0A:8F:11:06:BF:1C:58:B3:CA:E6:F1:F1:DC:99:4C:6C:89:E9:FF:BC:D4:38:11:18:1F:40:19:C8:49:37 +a=setup:actpass +a=mid:video +a=recvonly +a=rtcp-mux +a=rtpmap:97 rtx/90000 +a=fmtp:97 apt=98 +a=rtpmap:98 VP8/90000 +a=rtcp-fb:98 ccm fir +a=rtcp-fb:98 nack +a=rtcp-fb:98 nack pli +a=rtcp-fb:98 goog-remb +a=rtcp-fb:98 transport-cc +`; + const pc = new RTCPeerConnection(); + let [track, streams] = await getTrackFromUserMedia('video'); + const sender = pc.addTrack(track); + await pc.setRemoteDescription({type: 'offer', sdp: sampleOffer}); + const answer = await pc.createAnswer(); + verifyRtxReferences(answer); +}, 'A remote offer generates sensible RTX references in answer'); + +promise_test(async t => { + const sampleOffer = `v=0 +o=- 1878890426675213188 2 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE video +a=msid-semantic: WMS +m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 +c=IN IP4 0.0.0.0 +a=rtcp:9 IN IP4 0.0.0.0 +a=ice-ufrag:RGPK +a=ice-pwd:rAyHEAKC7ckxQgWaRZXukz+Z +a=ice-options:trickle +a=fingerprint:sha-256 8C:29:0A:8F:11:06:BF:1C:58:B3:CA:E6:F1:F1:DC:99:4C:6C:89:E9:FF:BC:D4:38:11:18:1F:40:19:C8:49:37 +a=setup:actpass +a=mid:video +a=recvonly +a=rtcp-mux +a=rtpmap:96 VP8/90000 +a=rtpmap:97 rtx/90000 +a=fmtp:97 apt=98 +a=rtpmap:98 VP8/90000 +a=rtcp-fb:98 ccm fir +a=rtcp-fb:98 nack +a=rtcp-fb:98 nack pli +a=rtcp-fb:98 goog-remb +a=rtcp-fb:98 transport-cc +a=rtpmap:99 rtx/90000 +a=fmtp:99 apt=96 +`; + const pc = new RTCPeerConnection(); + let [track, streams] = await getTrackFromUserMedia('video'); + const sender = pc.addTrack(track); + await pc.setRemoteDescription({type: 'offer', sdp: sampleOffer}); + const answer = await pc.createAnswer(); + verifyRtxReferences(answer); +}, 'A remote offer with duplicate codecs generates sensible RTX references in answer'); + +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/sctp-format.html b/testing/web-platform/tests/webrtc/protocol/sctp-format.html new file mode 100644 index 0000000000..207e51d4c3 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/sctp-format.html @@ -0,0 +1,25 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerconnection SDP SCTP format test</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +promise_test(async t => { + const caller = new RTCPeerConnection(); + const callee = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + t.add_cleanup(() => callee.close()); + caller.createDataChannel('channel'); + const offer = await caller.createOffer(); + const [preamble, media_section, postamble] = offer.sdp.split('\r\nm='); + assert_true(typeof(postamble) === 'undefined'); + assert_greater_than(media_section.search( + /^application \d+ UDP\/DTLS\/SCTP webrtc-datachannel\r\n/), -1); + assert_greater_than(media_section.search(/\r\na=sctp-port:\d+\r\n/), -1); + assert_greater_than(media_section.search(/\r\na=mid:/), -1); +}, 'Generated Datachannel SDP uses correct SCTP offer syntax'); + +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/sdes-dont-dont-dont.html b/testing/web-platform/tests/webrtc/protocol/sdes-dont-dont-dont.html new file mode 100644 index 0000000000..e938c84c8b --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/sdes-dont-dont-dont.html @@ -0,0 +1,55 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection MUST NOT support SDES</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="/webrtc/third_party/sdp/sdp.js"></script> +<script> +'use strict'; + +// Test support for +// https://www.rfc-editor.org/rfc/rfc8826#section-4.3.1 + +const sdp = `v=0 +o=- 0 3 IN IP4 127.0.0.1 +s=- +t=0 0 +m=video 9 UDP/TLS/RTP/SAVPF 100 +c=IN IP4 0.0.0.0 +a=rtcp-mux +a=sendonly +a=mid:video +a=rtpmap:100 VP8/90000 +a=fmtp:100 max-fr=30;max-fs=3600 +a=crypto:0 AES_CM_128_HMAC_SHA1_80 inline:2nra27hTUb9ilyn2rEkBEQN9WOFts26F/jvofasw +a=ice-ufrag:ETEn +a=ice-pwd:OtSK0WpNtpUjkY4+86js7Z/l +`; + +// Negative test for Chrome legacy behavior. +promise_test(async t => { + const sdes_constraint = {'mandatory': {'DtlsSrtpKeyAgreement': false}}; + const pc = new RTCPeerConnection(null, sdes_constraint); + t.add_cleanup(() => pc.close()); + + pc.addTransceiver('audio'); + const offer = await pc.createOffer(); + assert_false(offer.sdp.includes('\na=crypto:')); +}, 'does not create offers with SDES'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + try { + await pc.setRemoteDescription({type: 'offer', sdp}); + assert_unreached("Must not accept SDP without fingerprint"); + } catch (e) { + // TODO: which error is correct? See + // https://github.com/w3c/webrtc-pc/issues/2672 + assert_true(['OperationError', 'InvalidAccessError'].includes(e.name)); + } +}, 'rejects a remote offer that only includes SDES and no DTLS fingerprint'); +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/simulcast-answer.html b/testing/web-platform/tests/webrtc/protocol/simulcast-answer.html new file mode 100644 index 0000000000..5e19bc08ff --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/simulcast-answer.html @@ -0,0 +1,101 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Simulcast Answer</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +'use strict'; + +const offer_sdp = `v=0 +o=- 3840232462471583827 2 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE 0 +a=msid-semantic: WMS +m=video 9 UDP/TLS/RTP/SAVPF 96 +c=IN IP4 0.0.0.0 +a=rtcp:9 IN IP4 0.0.0.0 +a=ice-ufrag:Li6+ +a=ice-pwd:3C05CTZBRQVmGCAq7hVasHlT +a=ice-options:trickle +a=fingerprint:sha-256 5B:D3:8E:66:0E:7D:D3:F3:8E:E6:80:28:19:FC:55:AD:58:5D:B9:3D:A8:DE:45:4A:E7:87:02:F8:3C:0B:3B:B3 +a=setup:actpass +a=mid:0 +a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id +a=recvonly +a=rtcp-mux +a=rtpmap:96 VP8/90000 +a=rtcp-fb:96 goog-remb +a=rtcp-fb:96 transport-cc +a=rtcp-fb:96 ccm fir +a=rid:foo recv +a=rid:bar recv +a=rid:baz recv +a=simulcast:recv foo;bar;baz +`; +// Tests for the construction of answers with simulcast according to: +// draft-ietf-mmusic-sdp-simulcast-13 +// draft-ietf-mmusic-rid-15 +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const expected_rids = ['foo', 'bar', 'baz']; + + await pc.setRemoteDescription({type: 'offer', sdp: offer_sdp}); + const transceiver = pc.getTransceivers()[0]; + // The created transceiver should be in "recvonly" state. Allow it to send. + transceiver.direction = 'sendonly'; + const answer = await pc.createAnswer(); + const answer_lines = answer.sdp.split('\r\n'); + // Check for a RID line for each layer. + for (const rid of expected_rids) { + const result = answer_lines.find(line => line.startsWith(`a=rid:${rid}`)); + assert_not_equals(result, undefined, `RID attribute for '${rid}' missing.`); + } + + // Check for simulcast attribute with send direction and all RIDs. + const result = answer_lines.find( + line => line.startsWith(`a=simulcast:send ${expected_rids.join(';')}`)); + assert_not_equals(result, undefined, 'Could not find simulcast attribute.'); +}, 'createAnswer() with multiple send encodings should create simulcast answer'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const expected_rids = ['foo', 'bar', 'baz']; + + // Try to disable the `bar` encoding in a=simulcast by prefixing it with the + // `~` character. + await pc.setRemoteDescription({type: 'offer', sdp: offer_sdp.replace(/(a=simulcast:.*)bar/, '$1~bar')}); + const transceiver = pc.getTransceivers()[0]; + transceiver.direction = 'sendonly'; + await pc.setLocalDescription(); + + const parameters = pc.getSenders()[0].getParameters(); + const barEncoding = parameters.encodings.find(encoding => encoding.rid === 'bar'); + assert_not_equals(barEncoding, undefined); + assert_not_equals(barEncoding.active, false); +}, 'Using the ~rid SDP syntax in a remote offer does not control the local encodings active flag'); + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const expected_rids = ['foo', 'bar', 'baz']; + + await pc.setRemoteDescription({type: 'offer', sdp: offer_sdp}); + const transceiver = pc.getTransceivers()[0]; + transceiver.direction = 'sendonly'; + await pc.setLocalDescription(); + + // Disabling the encoding should not change the rid to ~rid. + const parameters = pc.getSenders()[0].getParameters(); + parameters.encodings.forEach(e => e.active = false); + await pc.getSenders()[0].setParameters(parameters); + const offer = await pc.createOffer(); + + const offer_lines = offer.sdp.split('\r\n'); + const result = offer_lines.find( + line => line.startsWith(`a=simulcast:send ${expected_rids.join(';')}`)); + assert_not_equals(result, undefined, 'Could not find simulcast attribute.'); +}, 'Disabling encodings locally does not change the SDP'); +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/simulcast-offer.html b/testing/web-platform/tests/webrtc/protocol/simulcast-offer.html new file mode 100644 index 0000000000..77ae7f9510 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/simulcast-offer.html @@ -0,0 +1,33 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Simulcast Offer</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +'use strict'; + +// Tests for the construction of offers with simulcast according to: +// draft-ietf-mmusic-sdp-simulcast-13 +// draft-ietf-mmusic-rid-15 +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + const expected_rids = ['foo', 'bar', 'baz']; + pc.addTransceiver('video', { + sendEncodings: expected_rids.map(rid => ({rid})) + }); + + const offer = await pc.createOffer(); + let offer_lines = offer.sdp.split('\r\n'); + // Check for a RID line for each layer. + for (const rid of expected_rids) { + let result = offer_lines.find(line => line.startsWith(`a=rid:${rid}`)); + assert_not_equals(result, undefined, `RID attribute for '${rid}' missing.`); + } + + // Check for simulcast attribute with send direction and all RIDs. + let result = offer_lines.find( + line => line.startsWith(`a=simulcast:send ${expected_rids.join(';')}`)); + assert_not_equals(result, undefined, "Could not find simulcast attribute."); +}, 'createOffer() with multiple send encodings should create simulcast offer'); +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/split.https.html b/testing/web-platform/tests/webrtc/protocol/split.https.html new file mode 100644 index 0000000000..3fc3bda2a5 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/split.https.html @@ -0,0 +1,98 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection BUNDLE</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="/webrtc/third_party/sdp/sdp.js"></script> +<script> +'use strict'; +promise_test(async t => { + const caller = new RTCPeerConnection(); + t.add_cleanup(() => caller.close()); + const calleeAudio = new RTCPeerConnection(); + t.add_cleanup(() => calleeAudio.close()); + const calleeVideo = new RTCPeerConnection(); + t.add_cleanup(() => calleeVideo.close()); + + const stream = await getNoiseStream({audio: true, video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + stream.getTracks().forEach(track => caller.addTrack(track, stream)); + + let metadataToBeLoaded; + calleeVideo.ontrack = (e) => { + const stream = e.streams[0]; + const v = document.createElement('video'); + v.autoplay = true; + v.srcObject = stream; + v.id = stream.id + metadataToBeLoaded = new Promise((resolve) => { + v.addEventListener('loadedmetadata', () => { + resolve(); + }); + }); + }; + + caller.addEventListener('icecandidate', (e) => { + // route depending on sdpMlineIndex + if (e.candidate) { + const target = e.candidate.sdpMLineIndex === 0 ? calleeAudio : calleeVideo; + target.addIceCandidate({sdpMid: e.candidate.sdpMid, candidate: e.candidate.candidate}); + } else { + calleeAudio.addIceCandidate(); + calleeVideo.addIceCandidate(); + } + }); + calleeAudio.addEventListener('icecandidate', (e) => { + if (e.candidate) { + caller.addIceCandidate({sdpMid: e.candidate.sdpMid, candidate: e.candidate.candidate}); + } + // Note: caller.addIceCandidate is only called for video to avoid calling it twice. + }); + calleeVideo.addEventListener('icecandidate', (e) => { + if (e.candidate) { + caller.addIceCandidate({sdpMid: e.candidate.sdpMid, candidate: e.candidate.candidate}); + } else { + caller.addIceCandidate(); + } + }); + + const offer = await caller.createOffer(); + const sections = SDPUtils.splitSections(offer.sdp); + // Remove the a=group:BUNDLE from the SDP when signaling. + const bundle = SDPUtils.matchPrefix(sections[0], 'a=group:BUNDLE')[0]; + sections[0] = sections[0].replace(bundle + '\r\n', ''); + + const audioSdp = sections[0] + sections[1]; + const videoSdp = sections[0] + sections[2]; + + await calleeAudio.setRemoteDescription({type: 'offer', sdp: audioSdp}); + await calleeVideo.setRemoteDescription({type: 'offer', sdp: videoSdp}); + await caller.setLocalDescription(offer); + + const answerAudio = await calleeAudio.createAnswer(); + const answerVideo = await calleeVideo.createAnswer(); + const audioSections = SDPUtils.splitSections(answerAudio.sdp); + const videoSections = SDPUtils.splitSections(answerVideo.sdp); + + // Remove the fingerprint from the session part of the SDP if present + // and move it to the media section. + SDPUtils.matchPrefix(audioSections[0], 'a=fingerprint:').forEach(line => { + audioSections[0] = audioSections[0].replace(line + '\r\n', ''); + audioSections[1] += line + '\r\n'; + }); + SDPUtils.matchPrefix(videoSections[0], 'a=fingerprint:').forEach(line => { + videoSections[0] = videoSections[0].replace(line + '\r\n', ''); + videoSections[1] += line + '\r\n'; + }); + + const sdp = audioSections[0] + audioSections[1] + videoSections[1]; + await caller.setRemoteDescription({type: 'answer', sdp}); + await calleeAudio.setLocalDescription(answerAudio); + await calleeVideo.setLocalDescription(answerVideo); + + await metadataToBeLoaded; + assert_equals(calleeAudio.connectionState, 'connected'); + assert_equals(calleeVideo.connectionState, 'connected'); +}, 'Connect audio and video to two independent PeerConnections'); +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/unknown-mediatypes.html b/testing/web-platform/tests/webrtc/protocol/unknown-mediatypes.html new file mode 100644 index 0000000000..f5176d1c87 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/unknown-mediatypes.html @@ -0,0 +1,34 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerconnection SDP handling of unknown media types</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + pc1.addTrack(stream.getTracks()[0], stream); + const offer = await pc1.createOffer(); + await pc2.setRemoteDescription({ + type: 'offer', + sdp: offer.sdp + .replace('m=audio ', 'm=unicorns ') + }); + await pc1.setLocalDescription(offer); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + // Do not attempt to call pc1.setRemoteDescription. + + const [preamble, media_section, postamble] = answer.sdp.split('\r\nm='); + assert_true(typeof(postamble) === 'undefined'); + assert_greater_than(media_section.search( + /^unicorns 0/), -1); +}, 'Unknown media types are rejected with the port set to 0'); +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/video-codecs.https.html b/testing/web-platform/tests/webrtc/protocol/video-codecs.https.html new file mode 100644 index 0000000000..4ce0618bca --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/video-codecs.https.html @@ -0,0 +1,95 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection.prototype.createOffer</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +/* + * Chromium note: this requires build bots with H264 support. See + * https://bugs.chromium.org/p/chromium/issues/detail?id=840659 + * for details on how to enable support. + */ +// Tests for conformance to RFC 7742, +// "WebRTC Video Processing and Codec Requirements" +// The document was formerly known as draft-ietf-rtcweb-video-codecs. +// +// This tests that the browser is a WebRTC Browser as defined there. + +// TODO: Section 3.2: screen capture video MUST be prepared +// to handle resolution changes. + +// TODO: Section 4: MUST support generating CVO (orientation) + +// Section 5: Browsers MUST implement VP8 and H.264 Constrained Baseline +promise_test(async t => { + const pc = new RTCPeerConnection(); + const offer = await generateVideoReceiveOnlyOffer(pc); + let video_section_found = false; + for (let section of offer.sdp.split(/\r\nm=/)) { + if (section.search('video') != 0) { + continue; + } + video_section_found = true; + // RTPMAP lines have the format a=rtpmap:<pt> <codec>/<clock rate> + let rtpmap_regex = /\r\na=rtpmap:(\d+) (\S+)\/\d+\r\n/g; + let match = rtpmap_regex.exec(offer.sdp); + let payload_type_map = new Array(); + while (match) { + payload_type_map[match[1]] = match[2]; + match = rtpmap_regex.exec(offer.sdp); + } + assert_true(payload_type_map.indexOf('VP8') > -1, + 'VP8 is supported'); + assert_true(payload_type_map.indexOf('H264') > -1, + 'H.264 is supported'); + // TODO: Verify that one of the H.264 PTs supports constrained baseline + } + assert_true(video_section_found); +}, 'H.264 and VP8 should be supported in initial offer'); + +async function negotiateParameters() { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + let [track, streams] = await getTrackFromUserMedia('video'); + const sender = pc1.addTrack(track); + await exchangeOfferAnswer(pc1, pc2); + return sender.getParameters(); +} + +function parseFmtp(fmtp) { + const params = fmtp.split(';'); + return params.map(param => param.split('=')); +} +promise_test(async t => { + const params = await negotiateParameters(); + assert_true(!!params.codecs.find(codec => codec.mimeType === 'video/H264')); + assert_true(!!params.codecs.find(codec => codec.mimeType === 'video/VP8')); +}, 'H.264 and VP8 should be negotiated after handshake'); + +// TODO: Section 6: Recipients MUST be able to decode 320x240@20 fps +// TODO: Section 6.1: VP8 MUST support RFC 7741 payload formats +// TODO: Section 6.1: VP8 MUST respect max-fr/max-fs +// TODO: Section 6.1: VP8 MUST encode and decode square pixels +// TODO: Section 6.2: H.264 MUST support RFC 6184 payload formats +// TODO: Section 6.2: MUST support Constrained Baseline level 1.2 +// TODO: Section 6.2: SHOULD support Constrained High level 1.3 +// TODO: Section 6.2: MUST support packetization mode 1. +promise_test(async t => { + const params = await negotiateParameters(); + const h264 = params.codecs.filter(codec => codec.mimeType === 'video/H264'); + h264.map(codec => { + const codec_params = parseFmtp(codec.sdpFmtpLine); + assert_true(!!codec_params.find(x => x[0] === 'profile-level-id')); + }) +}, 'All H.264 codecs MUST include profile-level-id'); + +// TODO: Section 6.2: SHOULD interpret max-mbps, max-smbps, max-fs et al +// TODO: Section 6.2: MUST NOT include sprop-parameter-sets +// TODO: Section 6.2: MUST support SEI "filler payload" +// TODO: Section 6.2: MUST support SEI "full frame freeze" +// TODO: Section 6.2: MUST be prepared to receive User Data messages +// TODO: Section 6.2: MUST encode and decode square pixels unless signaled +</script> diff --git a/testing/web-platform/tests/webrtc/protocol/vp8-fmtp.html b/testing/web-platform/tests/webrtc/protocol/vp8-fmtp.html new file mode 100644 index 0000000000..16ea635949 --- /dev/null +++ b/testing/web-platform/tests/webrtc/protocol/vp8-fmtp.html @@ -0,0 +1,44 @@ +<!doctype html> +<meta charset=utf-8> +<meta name="timeout" content="long"> +<title>RTCPeerConnection Failed State</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script> +'use strict'; + +// Test support for +// https://tools.ietf.org/html/rfc7741#section-6.1 + +const sdp = `v=0 +o=- 0 3 IN IP4 127.0.0.1 +s=- +t=0 0 +a=fingerprint:sha-256 A7:24:72:CA:6E:02:55:39:BA:66:DF:6E:CC:4C:D8:B0:1A:BF:1A:56:65:7D:F4:03:AD:7E:77:43:2A:29:EC:93 +m=video 9 UDP/TLS/RTP/SAVPF 100 +c=IN IP4 0.0.0.0 +a=rtcp-mux +a=sendonly +a=mid:video +a=rtpmap:100 VP8/90000 +a=fmtp:100 max-fr=30;max-fs=3600 +a=setup:actpass +a=ice-ufrag:ETEn +a=ice-pwd:OtSK0WpNtpUjkY4+86js7Z/l +`; + +promise_test(async t => { + const pc = new RTCPeerConnection(); + t.add_cleanup(() => pc.close()); + + await pc.setRemoteDescription({type: 'offer', sdp}); + await pc.setLocalDescription(); + const receiver = pc.getReceivers()[0]; + const parameters = receiver.getParameters(); + const {sdpFmtpLine} = parameters.codecs[0]; + assert_true(!!sdpFmtpLine); + assert_true(sdpFmtpLine.split(';').includes('max-fr=30')); + assert_true(sdpFmtpLine.split(';').includes('max-fs=3600')); +}, 'setRemoteDescription parses max-fr and max-fs fmtp parameters'); +</script> diff --git a/testing/web-platform/tests/webrtc/receiver-track-live.https.html b/testing/web-platform/tests/webrtc/receiver-track-live.https.html new file mode 100644 index 0000000000..34569297a6 --- /dev/null +++ b/testing/web-platform/tests/webrtc/receiver-track-live.https.html @@ -0,0 +1,72 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Remote tracks should not get ended except for stop/close</title> +<script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script src='../mediacapture-streams/permission-helper.js'></script> + <script src="RTCPeerConnection-helper.js"></script> +</head> +<body> + <video id="video" controls autoplay playsinline></video> + <script> + let pc1, pc2; + let localTrack, remoteTrack; + promise_test(async (test) => { + await setMediaPermission("granted", ["microphone"]); + const localStream = await navigator.mediaDevices.getUserMedia({audio: true}); + localTrack = localStream.getAudioTracks()[0]; + + pc1 = new RTCPeerConnection(); + pc1.addTrack(localTrack, localStream); + pc2 = new RTCPeerConnection(); + + let trackPromise = new Promise(resolve => { + pc2.ontrack = e => resolve(e.track); + }); + + exchangeIceCandidates(pc1, pc2); + await exchangeOfferAnswer(pc1, pc2); + + remoteTrack = await trackPromise; + video.srcObject = new MediaStream([remoteTrack]); + await video.play(); + }, "Setup audio call"); + + promise_test(async (test) => { + pc1.getTransceivers()[0].direction = "inactive"; + + let offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + + // Let's remove ssrc lines + let sdpLines = offer.sdp.split("\r\n"); + offer.sdp = sdpLines.filter(line => line && !line.startsWith("a=ssrc")).join("\r\n") + "\r\n"; + + await pc2.setRemoteDescription(offer); + let answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); + + assert_equals(remoteTrack.readyState, "live"); + }, "Inactivate the audio transceiver"); + + promise_test(async (test) => { + pc1.getTransceivers()[0].direction = "sendonly"; + + await exchangeOfferAnswer(pc1, pc2); + + assert_equals(remoteTrack.readyState, "live"); + }, "Reactivate the audio transceiver"); + + promise_test(async (test) => { + pc1.close(); + pc2.close(); + localTrack.stop(); + }, "Clean-up"); + </script> +</body> +</html> diff --git a/testing/web-platform/tests/webrtc/recvonly-transceiver-can-become-sendrecv.https.html b/testing/web-platform/tests/webrtc/recvonly-transceiver-can-become-sendrecv.https.html new file mode 100644 index 0000000000..30bbec4f9f --- /dev/null +++ b/testing/web-platform/tests/webrtc/recvonly-transceiver-can-become-sendrecv.https.html @@ -0,0 +1,50 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +'use strict'; + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const audioTransceiver = pc1.addTransceiver('audio', {direction:'recvonly'}); + + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + + audioTransceiver.direction = 'sendrecv'; + + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); +}, '[audio] recvonly transceiver can become sendrecv'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const videoTransceiver = pc1.addTransceiver('video', {direction:'recvonly'}); + + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); + + videoTransceiver.direction = 'sendrecv'; + + await pc1.setLocalDescription(); + await pc2.setRemoteDescription(pc1.localDescription); + await pc2.setLocalDescription(); + await pc1.setRemoteDescription(pc2.localDescription); +}, '[video] recvonly transceiver can become sendrecv'); + +</script> diff --git a/testing/web-platform/tests/webrtc/resources/RTCCertificate-postMessage-iframe.html b/testing/web-platform/tests/webrtc/resources/RTCCertificate-postMessage-iframe.html new file mode 100644 index 0000000000..9e52ba0c88 --- /dev/null +++ b/testing/web-platform/tests/webrtc/resources/RTCCertificate-postMessage-iframe.html @@ -0,0 +1,9 @@ +<!doctype html> +<script> +window.onmessage = async (event) => { + let certificate = event.data; + if (!certificate) + certificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256'}); + event.source.postMessage(certificate, "*"); +} +</script> diff --git a/testing/web-platform/tests/webrtc/simplecall-no-ssrcs.https.html b/testing/web-platform/tests/webrtc/simplecall-no-ssrcs.https.html new file mode 100644 index 0000000000..f2e2084623 --- /dev/null +++ b/testing/web-platform/tests/webrtc/simplecall-no-ssrcs.https.html @@ -0,0 +1,118 @@ +<!doctype html> +<html> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title>RTCPeerConnection Connection Test</title> + <script src="RTCPeerConnection-helper.js"></script> +</head> +<body> + <div id="log"></div> + <div> + <video id="local-view" muted autoplay="autoplay"></video> + <video id="remote-view" muted autoplay="autoplay"/> + </video> + </div> + + <!-- These files are in place when executing on W3C. --> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script type="text/javascript"> + var test = async_test('Can set up a basic WebRTC call without announcing ssrcs.'); + + var gFirstConnection = null; + var gSecondConnection = null; + + // if the remote video gets video data that implies the negotiation + // as well as the ICE and DTLS connection are up. + document.getElementById('remote-view') + .addEventListener('loadedmetadata', function() { + // Call negotiated: done. + test.done(); + }); + + function getNoiseStreamOkCallback(localStream) { + gFirstConnection = new RTCPeerConnection(null); + test.add_cleanup(() => gFirstConnection.close()); + gFirstConnection.onicecandidate = onIceCandidateToFirst; + localStream.getTracks().forEach(function(track) { + gFirstConnection.addTrack(track, localStream); + }); + gFirstConnection.createOffer().then(onOfferCreated, failed('createOffer')); + + var videoTag = document.getElementById('local-view'); + videoTag.srcObject = localStream; + }; + + var onOfferCreated = test.step_func(function(offer) { + gFirstConnection.setLocalDescription(offer); + + // remove all a=ssrc: lines and the (obsolete) msid-semantic line. + var sdp = offer.sdp.replace(/^a=ssrc:.*$\r\n/gm, '') + .replace(/^a=msid-semantic.*$\r\n/gm, ''); + + // This would normally go across the application's signaling solution. + // In our case, the "signaling" is to call this function. + receiveCall(sdp); + }); + + function receiveCall(offerSdp) { + gSecondConnection = new RTCPeerConnection(null); + test.add_cleanup(() => gSecondConnection.close()); + gSecondConnection.onicecandidate = onIceCandidateToSecond; + gSecondConnection.ontrack = onRemoteTrack; + + var parsedOffer = new RTCSessionDescription({ type: 'offer', + sdp: offerSdp }); + gSecondConnection.setRemoteDescription(parsedOffer); + + gSecondConnection.createAnswer().then(onAnswerCreated, + failed('createAnswer')); + }; + + var onAnswerCreated = test.step_func(function(answer) { + gSecondConnection.setLocalDescription(answer); + + // remove all a=ssrc: lines, the msid-semantic line and any a=msid:. + var sdp = answer.sdp.replace(/^a=ssrc:.*$\r\n/gm, '') + .replace(/^a=msid-semantic.*$\r\n/gm, '') + .replace(/^a=msid:.*$\r\n/gm, ''); + + // Similarly, this would go over the application's signaling solution. + handleAnswer(sdp); + }); + + function handleAnswer(answerSdp) { + var parsedAnswer = new RTCSessionDescription({ type: 'answer', + sdp: answerSdp }); + gFirstConnection.setRemoteDescription(parsedAnswer); + }; + + var onIceCandidateToFirst = test.step_func(function(event) { + gSecondConnection.addIceCandidate(event.candidate); + }); + + var onIceCandidateToSecond = test.step_func(function(event) { + gFirstConnection.addIceCandidate(event.candidate); + }); + + var onRemoteTrack = test.step_func(function(event) { + var videoTag = document.getElementById('remote-view'); + if (!videoTag.srcObject) { + videoTag.srcObject = event.streams[0]; + } + }); + + // Returns a suitable error callback. + function failed(function_name) { + return test.unreached_func('WebRTC called error callback for ' + function_name); + } + + // This function starts the test. + test.step(function() { + getNoiseStream({ video: true, audio: true }) + .then(test.step_func(getNoiseStreamOkCallback), failed('getNoiseStream')); + }); +</script> + +</body> +</html> diff --git a/testing/web-platform/tests/webrtc/simplecall.https.html b/testing/web-platform/tests/webrtc/simplecall.https.html new file mode 100644 index 0000000000..dbf6b9a508 --- /dev/null +++ b/testing/web-platform/tests/webrtc/simplecall.https.html @@ -0,0 +1,109 @@ +<!doctype html> +<html> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title>RTCPeerConnection Connection Test</title> + <script src="RTCPeerConnection-helper.js"></script> +</head> +<body> + <div id="log"></div> + <div> + <video id="local-view" muted autoplay="autoplay"></video> + <video id="remote-view" muted autoplay="autoplay"/> + </video> + </div> + + <!-- These files are in place when executing on W3C. --> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script type="text/javascript"> + var test = async_test('Can set up a basic WebRTC call.'); + + var gFirstConnection = null; + var gSecondConnection = null; + + // if the remote video gets video data that implies the negotiation + // as well as the ICE and DTLS connection are up. + document.getElementById('remote-view') + .addEventListener('loadedmetadata', function() { + // Call negotiated: done. + test.done(); + }); + + function getNoiseStreamOkCallback(localStream) { + gFirstConnection = new RTCPeerConnection(null); + test.add_cleanup(() => gFirstConnection.close()); + gFirstConnection.onicecandidate = onIceCandidateToFirst; + localStream.getTracks().forEach(function(track) { + gFirstConnection.addTrack(track, localStream); + }); + gFirstConnection.createOffer().then(onOfferCreated, failed('createOffer')); + + var videoTag = document.getElementById('local-view'); + videoTag.srcObject = localStream; + }; + + var onOfferCreated = test.step_func(function(offer) { + gFirstConnection.setLocalDescription(offer); + + // This would normally go across the application's signaling solution. + // In our case, the "signaling" is to call this function. + receiveCall(offer.sdp); + }); + + function receiveCall(offerSdp) { + gSecondConnection = new RTCPeerConnection(null); + test.add_cleanup(() => gSecondConnection.close()); + gSecondConnection.onicecandidate = onIceCandidateToSecond; + gSecondConnection.ontrack = onRemoteTrack; + + var parsedOffer = new RTCSessionDescription({ type: 'offer', + sdp: offerSdp }); + gSecondConnection.setRemoteDescription(parsedOffer); + + gSecondConnection.createAnswer().then(onAnswerCreated, + failed('createAnswer')); + }; + + var onAnswerCreated = test.step_func(function(answer) { + gSecondConnection.setLocalDescription(answer); + + // Similarly, this would go over the application's signaling solution. + handleAnswer(answer.sdp); + }); + + function handleAnswer(answerSdp) { + var parsedAnswer = new RTCSessionDescription({ type: 'answer', + sdp: answerSdp }); + gFirstConnection.setRemoteDescription(parsedAnswer); + }; + + var onIceCandidateToFirst = test.step_func(function(event) { + gSecondConnection.addIceCandidate(event.candidate); + }); + + var onIceCandidateToSecond = test.step_func(function(event) { + gFirstConnection.addIceCandidate(event.candidate); + }); + + var onRemoteTrack = test.step_func(function(event) { + var videoTag = document.getElementById('remote-view'); + if (!videoTag.srcObject) { + videoTag.srcObject = event.streams[0]; + } + }); + + // Returns a suitable error callback. + function failed(function_name) { + return test.unreached_func('WebRTC called error callback for ' + function_name); + } + + // This function starts the test. + test.step(function() { + getNoiseStream({ video: true, audio: true }) + .then(test.step_func(getNoiseStreamOkCallback), failed('getNoiseStream')); + }); +</script> + +</body> +</html> diff --git a/testing/web-platform/tests/webrtc/simulcast/basic.https.html b/testing/web-platform/tests/webrtc/simulcast/basic.https.html new file mode 100644 index 0000000000..f7b9def762 --- /dev/null +++ b/testing/web-platform/tests/webrtc/simulcast/basic.https.html @@ -0,0 +1,23 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Simulcast Tests</title> +<meta name="timeout" content="long"> +<script src="../third_party/sdp/sdp.js"></script> +<script src="simulcast.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="../../mediacapture-streams/permission-helper.js"></script> +<script> +promise_test(async t => { + const rids = [0, 1]; + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + return negotiateSimulcastAndWaitForVideo(t, rids, pc1, pc2); +}, 'Basic simulcast setup with two spatial layers'); +</script> diff --git a/testing/web-platform/tests/webrtc/simulcast/getStats.https.html b/testing/web-platform/tests/webrtc/simulcast/getStats.https.html new file mode 100644 index 0000000000..b5a9e6eb28 --- /dev/null +++ b/testing/web-platform/tests/webrtc/simulcast/getStats.https.html @@ -0,0 +1,34 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Simulcast Tests - getStats</title> +<meta name="timeout" content="long"> +<script src="../third_party/sdp/sdp.js"></script> +<script src="simulcast.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="../../mediacapture-streams/permission-helper.js"></script> +<script> +promise_test(async t => { + const rids = [0, 1, 2]; + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + await negotiateSimulcastAndWaitForVideo(t, rids, pc1, pc2); + + const outboundStats = []; + const senderStats = await pc1.getSenders()[0].getStats(); + senderStats.forEach(stat => { + if (stat.type === 'outbound-rtp') { + outboundStats.push(stat); + } + }); + assert_equals(outboundStats.length, 3, "getStats result should contain three layers"); + const statsRids = outboundStats.map(stat => parseInt(stat.rid, 10)); + assert_array_equals(rids, statsRids.sort(), "getStats result should match the rids provided"); +}, 'Simulcast getStats results'); +</script> diff --git a/testing/web-platform/tests/webrtc/simulcast/h264.https.html b/testing/web-platform/tests/webrtc/simulcast/h264.https.html new file mode 100644 index 0000000000..038449aa6e --- /dev/null +++ b/testing/web-platform/tests/webrtc/simulcast/h264.https.html @@ -0,0 +1,31 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Simulcast Tests</title> +<meta name="timeout" content="long"> +<script src="../third_party/sdp/sdp.js"></script> +<script src="simulcast.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="../../mediacapture-streams/permission-helper.js"></script> +<script> +/* + * Chromium note: this requires build bots with H264 support. See + * https://bugs.chromium.org/p/chromium/issues/detail?id=840659 + * for details on how to enable support. + */ +promise_test(async t => { + assert_implements('getCapabilities' in RTCRtpSender, 'RTCRtpSender.getCapabilities not supported'); + assert_implements(RTCRtpSender.getCapabilities('video').codecs.find(c => c.mimeType === 'video/H264'), 'H264 not supported'); + + const rids = [0, 1]; + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + return negotiateSimulcastAndWaitForVideo(t, rids, pc1, pc2, {mimeType: 'video/H264'}); +}, 'H264 simulcast setup with two streams'); +</script> diff --git a/testing/web-platform/tests/webrtc/simulcast/negotiation-encodings.https.html b/testing/web-platform/tests/webrtc/simulcast/negotiation-encodings.https.html new file mode 100644 index 0000000000..c16e2674b0 --- /dev/null +++ b/testing/web-platform/tests/webrtc/simulcast/negotiation-encodings.https.html @@ -0,0 +1,534 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Simulcast Tests - negotiation/encodings</title> +<meta name="timeout" content="long"> +<script src="../third_party/sdp/sdp.js"></script> +<script src="simulcast.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="../../mediacapture-streams/permission-helper.js"></script> +<script> + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const sender = pc1.addTrack(stream.getTracks()[0]); + // pc1 is unicast right now + pc2.addTrack(stream.getTracks()[0]); + + await doOfferToRecvSimulcastAndAnswer(pc2, pc1, ["foo", "bar"]); + assert_equals(pc1.getTransceivers().length, 1); + const {encodings} = sender.getParameters(); + const rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); +}, 'addTrack, then sRD(simulcast recv offer) results in simulcast'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({audio: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const sender = pc1.addTrack(stream.getTracks()[0]); + // pc1 is unicast right now + pc2.addTrack(stream.getTracks()[0]); + + await doOfferToRecvSimulcastAndAnswer(pc2, pc1, ["foo", "bar"]); + assert_equals(pc1.getTransceivers().length, 1); + const {encodings} = sender.getParameters(); + const rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, [undefined]); +}, 'simulcast is not supported for audio'); + +// We do not have a test case for sRD(offer) narrowing a simulcast envelope +// from addTransceiver, since that transceiver cannot be paired up with a remote +// offer m-section +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + let {encodings} = sender.getParameters(); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo"]); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + const rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo"]); + const scaleDownByValues = encodings.map(({scaleResolutionDownBy}) => scaleResolutionDownBy); + assert_array_equals(scaleDownByValues, [2]); +}, 'sRD(recv simulcast answer) can narrow the simulcast envelope specified by addTransceiver'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + let {encodings} = sender.getParameters(); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo"]); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo"]); + const scaleDownByValues = encodings.map(({scaleResolutionDownBy}) => scaleResolutionDownBy); + assert_array_equals(scaleDownByValues, [2]); +}, 'sRD(recv simulcast answer) can narrow the simulcast envelope from a previous negotiation'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + let {encodings} = sender.getParameters(); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + // doAnswerToSendSimulcast causes pc2 to barf unless we set the direction to + // sendrecv + pc2.getTransceivers()[0].direction = "sendrecv"; + pc2.getTransceivers()[1].direction = "sendrecv"; + + await doOfferToRecvSimulcast(pc2, pc1, ["foo"]); + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"], "[[SendEncodings]] is not updated in have-remote-offer for reoffers"); + + await doAnswerToSendSimulcast(pc2, pc1); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo"]); + const scaleDownByValues = encodings.map(({scaleResolutionDownBy}) => scaleResolutionDownBy); + assert_array_equals(scaleDownByValues, [2]); +}, 'sRD(simulcast offer) can narrow the simulcast envelope from a previous negotiation'); + +// https://github.com/w3c/webrtc-pc/issues/2780 +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const sender = pc1.addTrack(stream.getTracks()[0]); + pc2.addTrack(stream.getTracks()[0]); + + await doOfferToRecvSimulcastAndAnswer(pc2, pc1, ["foo", "bar", "foo"]); + assert_equals(pc1.getTransceivers().length, 1); + let {encodings} = sender.getParameters(); + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + assert_true(pc1.remoteDescription.sdp.includes("a=simulcast:recv foo;bar;foo"), "Duplicate rids should be present in offer"); + assert_false(pc1.localDescription.sdp.includes("a=simulcast:send foo;bar;foo"), "Duplicate rids should not be present in answer"); + assert_true(pc1.localDescription.sdp.includes("a=simulcast:send foo;bar"), "Answer should use the correct rids"); + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); +}, 'Duplicate rids in sRD(offer) are ignored'); + +// https://github.com/w3c/webrtc-pc/issues/2769 +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const sender = pc1.addTrack(stream.getTracks()[0]); + pc2.addTrack(stream.getTracks()[0]); + + await doOfferToRecvSimulcastAndAnswer(pc2, pc1, ["foo,bar", "1,2"]); + assert_equals(pc1.getTransceivers().length, 1); + let {encodings} = sender.getParameters(); + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "1"]); + assert_true(pc1.remoteDescription.sdp.includes("a=simulcast:recv foo,bar;1,2"), "Choices of rids should be present in offer"); + assert_true(pc1.localDescription.sdp.includes("a=simulcast:send foo;1\r\n"), "Choices of rids should not be present in answer"); + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "1"]); +}, 'Choices in rids in sRD(offer) are ignored'); + +// https://github.com/w3c/webrtc-pc/issues/2764 +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const sender = pc1.addTrack(stream.getTracks()[0]); + pc2.addTrack(stream.getTracks()[0]); + + await doOfferToRecvSimulcast(pc2, pc1, ["foo", "bar"]); + assert_equals(pc1.getTransceivers().length, 1); + let {encodings} = sender.getParameters(); + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + await pc1.setRemoteDescription({sdp: "", type: "rollback"}); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, [undefined]); +}, 'addTrack, then rollback of sRD(simulcast offer), brings us back to having a single encoding without a rid'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + pc2.addTrack(stream.getTracks()[0]); + await doOfferToRecvSimulcast(pc2, pc1, ["foo", "bar"]); + const sender = pc1.addTrack(stream.getTracks()[0]); + assert_equals(pc1.getTransceivers().length, 1); + + assert_equals(pc1.getTransceivers().length, 1); + let {encodings} = sender.getParameters(); + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + await pc1.setRemoteDescription({sdp: "", type: "rollback"}); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, [undefined]); +}, 'sRD(simulcast offer), addTrack, then rollback brings us back to having a single encoding'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const {sender} = pc1.addTransceiver(stream.getTracks()[0], {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + + await doOfferToSendSimulcast(pc1, pc2); + await doAnswerToRecvSimulcast(pc1, pc2, ["bar", "foo"]); + assert_true(pc1.remoteDescription.sdp.includes("a=simulcast:recv bar;foo"), "Answer should have reordered rids"); + + assert_equals(pc1.getTransceivers().length, 1); + const {encodings} = sender.getParameters(); + const rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); +}, 'Reordering of rids in sRD(answer) is ignored'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const {sender} = pc1.addTransceiver(stream.getTracks()[0], {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + + assert_equals(pc1.getTransceivers().length, 1); + let {encodings} = sender.getParameters(); + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + await doOfferToSendSimulcast(pc1, pc2); + await doAnswerToRecvSimulcast(pc1, pc2, ["bar", "foo"]); + assert_true(pc1.remoteDescription.sdp.includes("a=simulcast:recv bar;foo"), "Answer should have reordered rids"); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); +}, 'Reordering of rids in sRD(reanswer) is ignored'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const {sender} = pc1.addTransceiver(stream.getTracks()[0], {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + + assert_equals(pc1.getTransceivers().length, 1); + let {encodings} = sender.getParameters(); + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + // doAnswerToSendSimulcast causes pc2 to barf unless we set the direction to + // sendrecv + pc2.getTransceivers()[0].direction = "sendrecv"; + pc2.getTransceivers()[1].direction = "sendrecv"; + + await doOfferToRecvSimulcast(pc2, pc1, ["bar", "foo"]); + await doAnswerToSendSimulcast(pc2, pc1); + assert_true(pc1.remoteDescription.sdp.includes("a=simulcast:recv bar;foo"), "Reoffer should have reordered rids"); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); +}, 'Reordering of rids in sRD(reoffer) is ignored'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const {sender} = pc1.addTransceiver(stream.getTracks()[0], {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + + assert_equals(pc1.getTransceivers().length, 1); + let encodings = sender.getParameters().encodings; + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + // doAnswerToSendSimulcast causes pc2 to barf unless we set the direction to + // sendrecv + pc2.getTransceivers()[0].direction = "sendrecv"; + pc2.getTransceivers()[1].direction = "sendrecv"; + + // Keep the second encoding! + await doOfferToRecvSimulcast(pc2, pc1, ["bar"]); + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + await pc1.setRemoteDescription({sdp: "", type: "rollback"}); + + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); +}, 'Rollback of sRD(reoffer) with a single rid results in all previous encodings'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + let {encodings} = sender.getParameters(); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["bar"]); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + const rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["bar"]); + const scaleDownByValues = encodings.map(({scaleResolutionDownBy}) => scaleResolutionDownBy); + assert_array_equals(scaleDownByValues, [1]); +}, 'sRD(recv simulcast answer) can narrow the simulcast envelope specified by addTransceiver by removing the first encoding'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + let {encodings} = sender.getParameters(); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["bar"]); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["bar"]); + const scaleDownByValues = encodings.map(({scaleResolutionDownBy}) => scaleResolutionDownBy); + assert_array_equals(scaleDownByValues, [1]); +}, 'sRD(recv simulcast answer) can narrow the simulcast envelope from a previous negotiation by removing the first encoding'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + let {encodings} = sender.getParameters(); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + // doAnswerToSendSimulcast causes pc2 to barf unless we set the direction to + // sendrecv + pc2.getTransceivers()[0].direction = "sendrecv"; + pc2.getTransceivers()[1].direction = "sendrecv"; + + await doOfferToRecvSimulcast(pc2, pc1, ["bar"]); + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"], "[[SendEncodings]] is not updated in have-remote-offer for reoffers"); + + await doAnswerToSendSimulcast(pc2, pc1); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["bar"]); + const scaleDownByValues = encodings.map(({scaleResolutionDownBy}) => scaleResolutionDownBy); + assert_array_equals(scaleDownByValues, [1]); +}, 'sRD(simulcast offer) can narrow the simulcast envelope from a previous negotiation by removing the first encoding'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + let {encodings} = sender.getParameters(); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + pc1.getTransceivers()[0].direction = "inactive"; + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); +}, 'sender renegotiation to inactive does not disable simulcast'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + let {encodings} = sender.getParameters(); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + pc1.getTransceivers()[0].direction = "recvonly"; + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); +}, 'sender renegotiation to recvonly does not disable simulcast'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + let {encodings} = sender.getParameters(); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + pc2.getTransceivers()[0].direction = "inactive"; + pc2.getTransceivers()[1].direction = "inactive"; + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); +}, 'receiver renegotiation to inactive does not disable simulcast'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + let {encodings} = sender.getParameters(); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + pc2.getTransceivers()[0].direction = "sendonly"; + pc2.getTransceivers()[1].direction = "sendonly"; + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + assert_equals(pc1.getTransceivers().length, 1); + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); +}, 'receiver renegotiation to sendonly does not disable simulcast'); + +</script> diff --git a/testing/web-platform/tests/webrtc/simulcast/rid-manipulation.html b/testing/web-platform/tests/webrtc/simulcast/rid-manipulation.html new file mode 100644 index 0000000000..a88506305a --- /dev/null +++ b/testing/web-platform/tests/webrtc/simulcast/rid-manipulation.html @@ -0,0 +1,39 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>RTCPeerConnection Simulcast Tests - RID manipulation</title> +<meta name="timeout" content="long"> +<script src="../third_party/sdp/sdp.js"></script> +<script src="simulcast.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="../../mediacapture-streams/permission-helper.js"></script> +<script> + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + const rids = [0, 1, 2]; + pc1.addTransceiver("video", {sendEncodings: rids.map(rid => ({rid}))}); + const [{sender}] = pc1.getTransceivers(); + + const negotiateSfuAnswer = async asimulcast => { + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + offer.sdp = swapRidAndMidExtensionsInSimulcastOffer(offer, rids); + await pc2.setRemoteDescription(offer); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + answer.sdp = swapRidAndMidExtensionsInSimulcastAnswer(answer,pc1.localDescription, rids); + answer.sdp = answer.sdp.replace('a=simulcast:recv 0;1;2', asimulcast); + return answer; + }; + await pc1.setRemoteDescription(await negotiateSfuAnswer('a=simulcast:recv foo;1;2')); + await pc1.setRemoteDescription(await negotiateSfuAnswer('a=simulcast:recv foo;bar;2')); +}, 'Remote reanswer altering rids does not throw an exception.'); + +</script> diff --git a/testing/web-platform/tests/webrtc/simulcast/setParameters-active.https.html b/testing/web-platform/tests/webrtc/simulcast/setParameters-active.https.html new file mode 100644 index 0000000000..dbe162c610 --- /dev/null +++ b/testing/web-platform/tests/webrtc/simulcast/setParameters-active.https.html @@ -0,0 +1,104 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Simulcast Tests - setParameters/active</title> +<meta name="timeout" content="long"> +<script src="../third_party/sdp/sdp.js"></script> +<script src="simulcast.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="../../mediacapture-streams/permission-helper.js"></script> +<script> +async function queryReceiverStats(pc) { + const inboundStats = []; + await Promise.all(pc.getReceivers().map(async receiver => { + const receiverStats = await receiver.getStats(); + receiverStats.forEach(stat => { + if (stat.type === 'inbound-rtp') { + inboundStats.push(stat); + } + }); + })); + return inboundStats.map(s => s.framesDecoded); +} + +promise_test(async t => { + const rids = [0, 1]; + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + await negotiateSimulcastAndWaitForVideo(t, rids, pc1, pc2); + + // Deactivate first sender. + const parameters = pc1.getSenders()[0].getParameters(); + parameters.encodings[0].active = false; + await pc1.getSenders()[0].setParameters(parameters); + + // Assert (almost) no new frames are received on the first encoding. + // Without any action we would expect to have received around 30fps. + await new Promise(resolve => t.step_timeout(resolve, 200)); // Wait a bit. + const initialStats = await queryReceiverStats(pc2); + await new Promise(resolve => t.step_timeout(resolve, 1000)); // Wait more. + const subsequentStats = await queryReceiverStats(pc2); + + assert_equals(subsequentStats[0], initialStats[0]); + assert_greater_than(subsequentStats[1], initialStats[1]); +}, 'Simulcast setParameters active=false on first encoding stops sending frames for that encoding'); + +promise_test(async t => { + const rids = [0, 1]; + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + await negotiateSimulcastAndWaitForVideo(t, rids, pc1, pc2); + + // Deactivate second sender. + const parameters = pc1.getSenders()[0].getParameters(); + parameters.encodings[1].active = false; + await pc1.getSenders()[0].setParameters(parameters); + + // Assert (almost) no new frames are received on the second encoding. + // Without any action we would expect to have received around 30fps. + await new Promise(resolve => t.step_timeout(resolve, 200)); // Wait a bit. + const initialStats = await queryReceiverStats(pc2); + await new Promise(resolve => t.step_timeout(resolve, 1000)); // Wait more. + const subsequentStats = await queryReceiverStats(pc2); + + assert_equals(subsequentStats[1], initialStats[1]); + assert_greater_than(subsequentStats[0], initialStats[0]); +}, 'Simulcast setParameters active=false on second encoding stops sending frames for that encoding'); + +promise_test(async t => { + const rids = [0, 1]; + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + await negotiateSimulcastAndWaitForVideo(t, rids, pc1, pc2); + + // Deactivate all senders. + const parameters = pc1.getSenders()[0].getParameters(); + parameters.encodings.forEach(e => { + e.active = false; + }); + await pc1.getSenders()[0].setParameters(parameters); + + // Assert (almost) no new frames are received. + // Without any action we would expect to have received around 30fps. + await new Promise(resolve => t.step_timeout(resolve, 200)); // Wait a bit. + const initialStats = await queryReceiverStats(pc2); + await new Promise(resolve => t.step_timeout(resolve, 1000)); // Wait more. + const subsequentStats = await queryReceiverStats(pc2); + + subsequentStats.forEach((framesDecoded, idx) => { + assert_equals(framesDecoded, initialStats[idx]); + }); +}, 'Simulcast setParameters active=false stops sending frames'); +</script> diff --git a/testing/web-platform/tests/webrtc/simulcast/setParameters-encodings.https.html b/testing/web-platform/tests/webrtc/simulcast/setParameters-encodings.https.html new file mode 100644 index 0000000000..ac04ca55fb --- /dev/null +++ b/testing/web-platform/tests/webrtc/simulcast/setParameters-encodings.https.html @@ -0,0 +1,462 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Simulcast Tests - setParameters/encodings</title> +<meta name="timeout" content="long"> +<script src="../third_party/sdp/sdp.js"></script> +<script src="simulcast.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="../../mediacapture-streams/permission-helper.js"></script> +<script> + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + + await doOfferToSendSimulcast(pc1, pc2); + + await pc2.setLocalDescription(); + const simulcastAnswer = midToRid(pc2.localDescription, pc1.localDescription, ["foo"]); + + const parameters = sender.getParameters(); + parameters.encodings[1].scaleResolutionDownBy = 3.3; + const answerDone = pc1.setRemoteDescription({type: "answer", sdp: simulcastAnswer}); + await sender.setParameters(parameters); + await answerDone; + + assert_equals(pc1.getTransceivers().length, 1); + const {encodings} = sender.getParameters(); + const rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo"]); +}, 'sRD(simulcast answer) can narrow the simulcast envelope when interrupted by a setParameters'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + + assert_equals(pc1.getTransceivers().length, 1); + let encodings = sender.getParameters().encodings; + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + const reoffer = await pc2.createOffer(); + const simulcastSdp = midToRid(reoffer, pc1.localDescription, ["foo"]); + + const parameters = sender.getParameters(); + parameters.encodings[1].scaleResolutionDownBy = 3.3; + const reofferDone = pc1.setRemoteDescription({type: "offer", sdp: simulcastSdp}); + await sender.setParameters(parameters); + await reofferDone; + await pc1.setLocalDescription(); + + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo"]); +}, 'sRD(simulcast offer) can narrow the simulcast envelope when interrupted by a setParameters'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + + const parameters = sender.getParameters(); + parameters.encodings[0].scaleResolutionDownBy = 2.3; + parameters.encodings[1].scaleResolutionDownBy = 3.3; + await sender.setParameters(parameters); + + await doOfferToSendSimulcast(pc1, pc2); + await doAnswerToRecvSimulcast(pc1, pc2, []); + + assert_equals(pc1.getTransceivers().length, 1); + const encodings = sender.getParameters().encodings; + const rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo"]); + assert_equals(encodings[0].scaleResolutionDownBy, 2.3); +}, 'a simulcast setParameters followed by a sRD(unicast answer) results in keeping the first encoding'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + await doOfferToSendSimulcast(pc1, pc2); + + await pc2.setLocalDescription(); + const unicastAnswer = midToRid(pc2.localDescription, pc1.localDescription, []); + + const parameters = sender.getParameters(); + parameters.encodings[0].scaleResolutionDownBy = 2.3; + parameters.encodings[1].scaleResolutionDownBy = 3.3; + const answerDone = pc1.setRemoteDescription({type: "answer", sdp: unicastAnswer}); + await sender.setParameters(parameters); + await answerDone; + + assert_equals(pc1.getTransceivers().length, 1); + const encodings = sender.getParameters().encodings; + const rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo"]); + assert_equals(encodings[0].scaleResolutionDownBy, 2.3); +}, 'sRD(unicast answer) interrupted by setParameters(simulcast) results in keeping the first encoding'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + assert_equals(pc1.getTransceivers().length, 1); + let encodings = sender.getParameters().encodings; + let rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + const reoffer = await pc2.createOffer(); + const unicastSdp = midToRid(reoffer, pc1.localDescription, []); + const parameters = sender.getParameters(); + parameters.encodings[0].scaleResolutionDownBy = 2.3; + parameters.encodings[1].scaleResolutionDownBy = 3.3; + const reofferDone = pc1.setRemoteDescription({type: "offer", sdp: unicastSdp}); + await sender.setParameters(parameters); + await reofferDone; + await pc1.setLocalDescription(); + + encodings = sender.getParameters().encodings; + rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo"]); + assert_equals(encodings[0].scaleResolutionDownBy, 2.3); +}, 'sRD(unicast reoffer) interrupted by setParameters(simulcast) results in keeping the first encoding'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const {sender} = pc1.addTransceiver("video", {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + + await doOfferToSendSimulcast(pc1, pc2); + await pc2.setLocalDescription(); + const simulcastAnswer = midToRid(pc2.localDescription, pc1.localDescription, ["foo"]); + const parameters = sender.getParameters(); + parameters.encodings[0].scaleResolutionDownBy = 3.3; + const answerDone = pc1.setRemoteDescription({type: "answer", sdp: simulcastAnswer}); + await sender.setParameters(parameters); + await answerDone; + + const {encodings} = sender.getParameters(); + assert_equals(encodings.length, 1); + assert_equals(encodings[0].scaleResolutionDownBy, 3.3); +}, 'sRD(simulcast answer) interrupted by a setParameters does not result in losing modifications from the setParameters to the encodings that remain'); + +const simulcastOffer = `v=0 +o=- 3840232462471583827 0 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE 0 +a=msid-semantic: WMS +m=video 9 UDP/TLS/RTP/SAVPF 96 +c=IN IP4 0.0.0.0 +a=rtcp:9 IN IP4 0.0.0.0 +a=ice-ufrag:Li6+ +a=ice-pwd:3C05CTZBRQVmGCAq7hVasHlT +a=ice-options:trickle +a=fingerprint:sha-256 5B:D3:8E:66:0E:7D:D3:F3:8E:E6:80:28:19:FC:55:AD:58:5D:B9:3D:A8:DE:45:4A:E7:87:02:F8:3C:0B:3B:B3 +a=setup:actpass +a=mid:0 +a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id +a=recvonly +a=rtcp-mux +a=rtpmap:96 VP8/90000 +a=rtcp-fb:96 goog-remb +a=rtcp-fb:96 transport-cc +a=rtcp-fb:96 ccm fir +a=rid:foo recv +a=rid:bar recv +a=simulcast:recv foo;bar +`; + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const sender = pc1.addTrack(stream.getTracks()[0]); + const parameters = sender.getParameters(); + parameters.encodings[0].scaleResolutionDownBy = 3.0; + await sender.setParameters(parameters); + + await pc1.setRemoteDescription({type: "offer", sdp: simulcastOffer}); + + const {encodings} = sender.getParameters(); + const rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + assert_equals(encodings[0].scaleResolutionDownBy, 2.0); + assert_equals(encodings[1].scaleResolutionDownBy, 1.0); +}, 'addTrack, then a unicast setParameters, then sRD(simulcast offer) results in simulcast without the settings from setParameters'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const sender = pc1.addTrack(stream.getTracks()[0]); + const parameters = sender.getParameters(); + parameters.encodings[0].scaleResolutionDownBy = 3.0; + + const offerDone = pc1.setRemoteDescription({type: "offer", sdp: simulcastOffer}); + await sender.setParameters(parameters); + await offerDone; + + const {encodings} = sender.getParameters(); + const rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + assert_equals(encodings[0].scaleResolutionDownBy, 2.0); + assert_equals(encodings[1].scaleResolutionDownBy, 1.0); +}, 'addTrack, then sRD(simulcast offer) interrupted by a unicast setParameters results in simulcast without the settings from setParameters'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const {sender} = pc1.addTransceiver(stream.getTracks()[0], {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + pc2.getTransceivers()[0].direction = "sendrecv"; + pc2.getTransceivers()[1].direction = "sendrecv"; + + await doOfferToRecvSimulcast(pc2, pc1, []); + // Race simulcast setParameters against sLD(unicast reanswer) + const answer = await pc1.createAnswer(); + const aTask = queueAWebrtcTask(); + // This also queues a task to clear [[LastReturnedParameters]] + const parameters = sender.getParameters(); + // This might or might not queue a task right away (it might do some + // microtask stuff first), but it doesn't really matter. + const sLDDone = pc1.setLocalDescription(answer); + await aTask; + // Task queue should now have the task that clears + // [[LastReturnedParameters]], _then_ the success task for sLD. + // setParameters should succeed because [[LastReturnedParameters]] has not + // yet been cleared, and the steps in the success task for sLD have not run + // either. + await sender.setParameters(parameters); + await sLDDone; + + assert_equals(pc1.getTransceivers().length, 1); + const {encodings} = sender.getParameters(); + const rids = encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo"]); +}, 'getParameters, then sLD(unicast answer) interrupted by a simulcast setParameters results in unicast'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const {sender} = pc1.addTransceiver(stream.getTracks()[0], {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + pc2.getTransceivers()[0].direction = "sendrecv"; + pc2.getTransceivers()[1].direction = "sendrecv"; + + await doOfferToRecvSimulcast(pc2, pc1, []); + const answer = await pc1.createAnswer(); + + // The timing on this is very difficult. We want to ensure that our + // getParameters call happens after the initial steps in sLD, but + // before the queued task that sLD runs when it completes. + const aTask = queueAWebrtcTask(); + const sLDDone = pc1.setLocalDescription(answer); + // We now have a queued task (aTask). We might also have the success task for + // sLD, but maybe not. Allowing aTask to finish gives us our best chance that + // the success task for sLD is queued, but not run yet. + await aTask; + const parameters = sender.getParameters(); + // Hopefully we now have the success task for sLD, followed by the + // success task for getParameters. + await sLDDone; + // Success task for getParameters should not have run yet. + await promise_rejects_dom(t, 'InvalidStateError', sender.setParameters(parameters)); +},'Success task for setLocalDescription(answer) clears [[LastReturnedParameters]]'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const {sender} = pc1.addTransceiver(stream.getTracks()[0], {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + pc2.getTransceivers()[0].direction = "sendrecv"; + pc2.getTransceivers()[1].direction = "sendrecv"; + + await pc2.setLocalDescription(); + const simulcastOffer = midToRid( + pc2.localDescription, + pc1.localDescription, + [] + ); + + // The timing on this is very difficult. We need to ensure that our + // getParameters call happens after the initial steps in sRD, but + // before the queued task that sRD runs when it completes. + const aTask = queueAWebrtcTask(); + const sRDDone = pc1.setRemoteDescription({ type: "offer", sdp: simulcastOffer }); + + await aTask; + const parameters = sender.getParameters(); + await sRDDone; + await promise_rejects_dom(t, 'InvalidStateError', sender.setParameters(parameters)); +},'Success task for setRemoteDescription(offer) clears [[LastReturnedParameters]]'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const {sender} = pc1.addTransceiver(stream.getTracks()[0], {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + pc2.getTransceivers()[0].direction = "sendrecv"; + pc2.getTransceivers()[1].direction = "sendrecv"; + + await doOfferToSendSimulcast(pc1, pc2); + await pc2.setLocalDescription(); + const simulcastAnswer = midToRid( + pc2.localDescription, + pc1.localDescription, + [] + ); + + // The timing on this is very difficult. We need to ensure that our + // getParameters call happens after the initial steps in sRD, but + // before the queued task that sRD runs when it completes. + const aTask = queueAWebrtcTask(); + const sRDDone = pc1.setRemoteDescription({ type: "answer", sdp: simulcastAnswer }); + await aTask; + + const parameters = sender.getParameters(); + await sRDDone; + await promise_rejects_dom(t, 'InvalidStateError', sender.setParameters(parameters)); +},'Success task for setRemoteDescription(answer) clears [[LastReturnedParameters]]'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const sender = pc1.addTrack(stream.getTracks()[0]); + pc2.addTrack(stream.getTracks()[0]); + + await doOfferToRecvSimulcast(pc2, pc1, ["foo", "bar"]); + let parameters = sender.getParameters(); + let rids = parameters.encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + parameters.encodings[0].scaleResolutionDownBy = 3; + parameters.encodings[1].scaleResolutionDownBy = 5; + await sender.setParameters(parameters); + + await pc1.setRemoteDescription({sdp: "", type: "rollback"}); + + parameters = sender.getParameters(); + rids = parameters.encodings.map(({rid}) => rid); + assert_array_equals(rids, [undefined]); + assert_equals(parameters.encodings[0].scaleResolutionDownBy, 1); +}, 'addTrack, then rollback of sRD(simulcast offer), brings us back to having a single encoding without any previously set parameters'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const {sender} = pc1.addTransceiver(stream.getTracks()[0], {sendEncodings: [{rid: "foo"}, {rid: "bar"}]}); + + await doOfferToSendSimulcastAndAnswer(pc1, pc2, ["foo", "bar"]); + let parameters = sender.getParameters(); + let rids = parameters.encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + parameters.encodings[0].scaleResolutionDownBy = 3; + parameters.encodings[1].scaleResolutionDownBy = 5; + await sender.setParameters(parameters); + + await doOfferToRecvSimulcast(pc2, pc1, []); + parameters = sender.getParameters(); + rids = parameters.encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + + await pc1.setRemoteDescription({sdp: "", type: "rollback"}); + parameters = sender.getParameters(); + rids = parameters.encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + assert_equals(parameters.encodings[0].scaleResolutionDownBy, 3); + assert_equals(parameters.encodings[1].scaleResolutionDownBy, 5); +}, 'rollback of a remote offer that disabled a previously negotiated simulcast should restore simulcast along with any previously set parameters'); + +promise_test(async t => { + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + const stream = await getNoiseStream({video: true}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const sender = pc1.addTrack(stream.getTracks()[0]); + pc2.addTrack(stream.getTracks()[0]); + + await doOfferToRecvSimulcast(pc2, pc1, ["foo", "bar"]); + const aTask = queueAWebrtcTask(); + let parameters = sender.getParameters(); + let rids = parameters.encodings.map(({rid}) => rid); + assert_array_equals(rids, ["foo", "bar"]); + parameters.encodings[0].scaleResolutionDownBy = 3; + parameters.encodings[1].scaleResolutionDownBy = 5; + + const rollbackDone = pc1.setRemoteDescription({sdp: "", type: "rollback"}); + await aTask; + await sender.setParameters(parameters); + await rollbackDone; + + parameters = sender.getParameters(); + rids = parameters.encodings.map(({rid}) => rid); + assert_array_equals(rids, [undefined]); + assert_equals(parameters.encodings[0].scaleResolutionDownBy, 1); +}, 'rollback of sRD(simulcast offer) interrupted by setParameters(simulcast) brings us back to having a single encoding without any previously set parameters'); + +</script> diff --git a/testing/web-platform/tests/webrtc/simulcast/simulcast.js b/testing/web-platform/tests/webrtc/simulcast/simulcast.js new file mode 100644 index 0000000000..4682729233 --- /dev/null +++ b/testing/web-platform/tests/webrtc/simulcast/simulcast.js @@ -0,0 +1,254 @@ +'use strict'; +/* Helper functions to munge SDP and split the sending track into + * separate tracks on the receiving end. This can be done in a number + * of ways, the one used here uses the fact that the MID and RID header + * extensions which are used for packet routing share the same wire + * format. The receiver interprets the rids from the sender as mids + * which allows receiving the different spatial resolutions on separate + * m-lines and tracks. + */ + +const ridExtensions = [ + "urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id", + "urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id", +]; + +function ridToMid(description, rids) { + const sections = SDPUtils.splitSections(description.sdp); + const dtls = SDPUtils.getDtlsParameters(sections[1], sections[0]); + const ice = SDPUtils.getIceParameters(sections[1], sections[0]); + const rtpParameters = SDPUtils.parseRtpParameters(sections[1]); + const setupValue = description.sdp.match(/a=setup:(.*)/)[1]; + const directionValue = + sections[1].match(/a=sendrecv|a=sendonly|a=recvonly|a=inactive/)[0]; + const mline = SDPUtils.parseMLine(sections[1]); + + // Skip mid extension; we are replacing it with the rid extmap + rtpParameters.headerExtensions = rtpParameters.headerExtensions.filter( + ext => ext.uri != "urn:ietf:params:rtp-hdrext:sdes:mid" + ); + + for (const ext of rtpParameters.headerExtensions) { + if (ext.uri == "urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id") { + ext.uri = "urn:ietf:params:rtp-hdrext:sdes:mid"; + } + } + + // Filter rtx as we have no way to (re)interpret rrid. + // Not doing this makes probing use RTX, it's not understood and ramp-up is slower. + rtpParameters.codecs = rtpParameters.codecs.filter(c => c.name.toUpperCase() !== 'RTX'); + + if (!rids) { + rids = Array.from(description.sdp.matchAll(/a=rid:(.*) send/g)).map(r => r[1]); + } + + let sdp = SDPUtils.writeSessionBoilerplate() + + SDPUtils.writeDtlsParameters(dtls, setupValue) + + SDPUtils.writeIceParameters(ice) + + 'a=group:BUNDLE ' + rids.join(' ') + '\r\n'; + const baseRtpDescription = SDPUtils.writeRtpDescription(mline.kind, rtpParameters); + for (const rid of rids) { + sdp += baseRtpDescription + + 'a=mid:' + rid + '\r\n' + + 'a=msid:rid-' + rid + ' rid-' + rid + '\r\n'; + sdp += directionValue + "\r\n"; + } + return sdp; +} + +function midToRid(description, localDescription, rids) { + const sections = SDPUtils.splitSections(description.sdp); + const dtls = SDPUtils.getDtlsParameters(sections[1], sections[0]); + const ice = SDPUtils.getIceParameters(sections[1], sections[0]); + const rtpParameters = SDPUtils.parseRtpParameters(sections[1]); + const setupValue = description.sdp.match(/a=setup:(.*)/)[1]; + const directionValue = + sections[1].match(/a=sendrecv|a=sendonly|a=recvonly|a=inactive/)[0]; + const mline = SDPUtils.parseMLine(sections[1]); + + // Skip rid extensions; we are replacing them with the mid extmap + rtpParameters.headerExtensions = rtpParameters.headerExtensions.filter( + ext => !ridExtensions.includes(ext.uri) + ); + + for (const ext of rtpParameters.headerExtensions) { + if (ext.uri == "urn:ietf:params:rtp-hdrext:sdes:mid") { + ext.uri = "urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id"; + } + } + + const localMid = localDescription ? SDPUtils.getMid(SDPUtils.splitSections(localDescription.sdp)[1]) : "0"; + + if (!rids) { + rids = []; + for (let i = 1; i < sections.length; i++) { + rids.push(SDPUtils.getMid(sections[i])); + } + } + + let sdp = SDPUtils.writeSessionBoilerplate() + + SDPUtils.writeDtlsParameters(dtls, setupValue) + + SDPUtils.writeIceParameters(ice) + + 'a=group:BUNDLE ' + localMid + '\r\n'; + sdp += SDPUtils.writeRtpDescription(mline.kind, rtpParameters); + // Although we are converting mids to rids, we still need a mid. + // The first one will be consistent with trickle ICE candidates. + sdp += 'a=mid:' + localMid + '\r\n'; + sdp += directionValue + "\r\n"; + + for (const rid of rids) { + const stringrid = String(rid); // allow integers + const choices = stringrid.split(","); + choices.forEach(choice => { + sdp += 'a=rid:' + choice + ' recv\r\n'; + }); + } + if (rids.length) { + sdp += 'a=simulcast:recv ' + rids.join(';') + '\r\n'; + } + + return sdp; +} + +async function doOfferToSendSimulcast(offerer, answerer) { + await offerer.setLocalDescription(); + + // Is this a renegotiation? If so, we cannot remove (or reorder!) any mids, + // even if some rids have been removed or reordered. + let mids = []; + if (answerer.localDescription) { + // Renegotiation. Mids must be the same as before, because renegotiation + // can never remove or reorder mids, nor can it expand the simulcast + // envelope. + mids = [...answerer.localDescription.sdp.matchAll(/a=mid:(.*)/g)].map( + e => e[1] + ); + } else { + // First negotiation; the mids will be exactly the same as the rids + const simulcastAttr = offerer.localDescription.sdp.match( + /a=simulcast:send (.*)/ + ); + if (simulcastAttr) { + mids = simulcastAttr[1].split(";"); + } + } + + const nonSimulcastOffer = ridToMid(offerer.localDescription, mids); + await answerer.setRemoteDescription({ + type: "offer", + sdp: nonSimulcastOffer, + }); +} + +async function doAnswerToRecvSimulcast(offerer, answerer, rids) { + await answerer.setLocalDescription(); + const simulcastAnswer = midToRid( + answerer.localDescription, + offerer.localDescription, + rids + ); + await offerer.setRemoteDescription({ type: "answer", sdp: simulcastAnswer }); +} + +async function doOfferToRecvSimulcast(offerer, answerer, rids) { + await offerer.setLocalDescription(); + const simulcastOffer = midToRid( + offerer.localDescription, + answerer.localDescription, + rids + ); + await answerer.setRemoteDescription({ type: "offer", sdp: simulcastOffer }); +} + +async function doAnswerToSendSimulcast(offerer, answerer) { + await answerer.setLocalDescription(); + + // See which mids the offerer had; it will barf if we remove or reorder them + const mids = [...offerer.localDescription.sdp.matchAll(/a=mid:(.*)/g)].map( + e => e[1] + ); + + const nonSimulcastAnswer = ridToMid(answerer.localDescription, mids); + await offerer.setRemoteDescription({ + type: "answer", + sdp: nonSimulcastAnswer, + }); +} + +async function doOfferToSendSimulcastAndAnswer(offerer, answerer, rids) { + await doOfferToSendSimulcast(offerer, answerer); + await doAnswerToRecvSimulcast(offerer, answerer, rids); +} + +async function doOfferToRecvSimulcastAndAnswer(offerer, answerer, rids) { + await doOfferToRecvSimulcast(offerer, answerer, rids); + await doAnswerToSendSimulcast(offerer, answerer); +} + +function swapRidAndMidExtensionsInSimulcastOffer(offer, rids) { + return ridToMid(offer, rids); +} + +function swapRidAndMidExtensionsInSimulcastAnswer(answer, localDescription, rids) { + return midToRid(answer, localDescription, rids); +} + +async function negotiateSimulcastAndWaitForVideo( + t, rids, pc1, pc2, codec, scalabilityMode = undefined) { + exchangeIceCandidates(pc1, pc2); + + const metadataToBeLoaded = []; + pc2.ontrack = (e) => { + const stream = e.streams[0]; + const v = document.createElement('video'); + v.autoplay = true; + v.srcObject = stream; + v.id = stream.id + metadataToBeLoaded.push(new Promise((resolve) => { + v.addEventListener('loadedmetadata', () => { + resolve(); + }); + })); + }; + + const sendEncodings = rids.map(rid => ({rid})); + // Use a 2X downscale factor between each layer. To improve ramp-up time, the + // top layer is scaled down by a factor 2. Smaller layer comes first. For + // example if MediaStreamTrack is 720p and we want to send three layers we'll + // get {90p, 180p, 360p}. + let scaleResolutionDownBy = 2; + for (let i = sendEncodings.length - 1; i >= 0; --i) { + if (scalabilityMode) { + sendEncodings[i].scalabilityMode = scalabilityMode; + } + sendEncodings[i].scaleResolutionDownBy = scaleResolutionDownBy; + scaleResolutionDownBy *= 2; + } + + // Use getUserMedia as getNoiseStream does not have enough entropy to ramp-up. + await setMediaPermission(); + const stream = await navigator.mediaDevices.getUserMedia({video: {width: 1280, height: 720}}); + t.add_cleanup(() => stream.getTracks().forEach(track => track.stop())); + const transceiver = pc1.addTransceiver(stream.getVideoTracks()[0], { + streams: [stream], + sendEncodings: sendEncodings, + }); + if (codec) { + preferCodec(transceiver, codec.mimeType, codec.sdpFmtpLine); + } + + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer), + await pc2.setRemoteDescription({ + type: 'offer', + sdp: swapRidAndMidExtensionsInSimulcastOffer(offer, rids), + }); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription({ + type: 'answer', + sdp: swapRidAndMidExtensionsInSimulcastAnswer(answer, pc1.localDescription, rids), + }); + assert_equals(metadataToBeLoaded.length, rids.length); + return Promise.all(metadataToBeLoaded); +} diff --git a/testing/web-platform/tests/webrtc/simulcast/vp8.https.html b/testing/web-platform/tests/webrtc/simulcast/vp8.https.html new file mode 100644 index 0000000000..3d04bc7172 --- /dev/null +++ b/testing/web-platform/tests/webrtc/simulcast/vp8.https.html @@ -0,0 +1,26 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Simulcast Tests</title> +<meta name="timeout" content="long"> +<script src="../third_party/sdp/sdp.js"></script> +<script src="simulcast.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="../../mediacapture-streams/permission-helper.js"></script> +<script> +promise_test(async t => { + assert_implements('getCapabilities' in RTCRtpSender, 'RTCRtpSender.getCapabilities not supported'); + assert_implements(RTCRtpSender.getCapabilities('video').codecs.find(c => c.mimeType === 'video/VP8'), 'VP8 not supported'); + + const rids = [0, 1]; + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + return negotiateSimulcastAndWaitForVideo(t, rids, pc1, pc2, {mimeType: 'video/VP8'}); +}, 'VP8 simulcast setup with two streams'); +</script> diff --git a/testing/web-platform/tests/webrtc/simulcast/vp9-scalability-mode.https.html b/testing/web-platform/tests/webrtc/simulcast/vp9-scalability-mode.https.html new file mode 100644 index 0000000000..9dc8a3103d --- /dev/null +++ b/testing/web-platform/tests/webrtc/simulcast/vp9-scalability-mode.https.html @@ -0,0 +1,35 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Simulcast Tests</title> +<meta name="timeout" content="long"> +<script src="../third_party/sdp/sdp.js"></script> +<script src="simulcast.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="../../mediacapture-streams/permission-helper.js"></script> +<script> +promise_test(async t => { + assert_implements('getCapabilities' in RTCRtpSender, 'RTCRtpSender.getCapabilities not supported'); + assert_implements(RTCRtpSender.getCapabilities('video').codecs.find(c => c.mimeType === 'video/VP9'), 'VP9 not supported'); + + const rids = [0, 1]; + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + // This is not a scalability mode test (see wpt/webrtc-svc/ for those) but a + // VP9 simulcast test. Setting `scalabilityMode` should not be needed, however + // many browsers interprets multiple VP9 encodings to mean multiple spatial + // layers by default. During a transition period, Chromium-based browsers + // requires explicitly specifying the scalability mode as a way to opt-in to + // spec-compliant simulcast. See also wpt/webrtc/simulcast/vp9.https.html for + // a version of this test that does not set the scalability mode. + const scalabilityMode = 'L1T2'; + return negotiateSimulcastAndWaitForVideo( + t, rids, pc1, pc2, {mimeType: 'video/VP9'}, scalabilityMode); +}, 'VP9 simulcast setup with two streams and L1T2 set'); +</script> diff --git a/testing/web-platform/tests/webrtc/simulcast/vp9.https.html b/testing/web-platform/tests/webrtc/simulcast/vp9.https.html new file mode 100644 index 0000000000..a033dab477 --- /dev/null +++ b/testing/web-platform/tests/webrtc/simulcast/vp9.https.html @@ -0,0 +1,26 @@ +<!doctype html> +<meta charset=utf-8> +<title>RTCPeerConnection Simulcast Tests</title> +<meta name="timeout" content="long"> +<script src="../third_party/sdp/sdp.js"></script> +<script src="simulcast.js"></script> +<script src="../RTCPeerConnection-helper.js"></script> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="../../mediacapture-streams/permission-helper.js"></script> +<script> +promise_test(async t => { + assert_implements('getCapabilities' in RTCRtpSender, 'RTCRtpSender.getCapabilities not supported'); + assert_implements(RTCRtpSender.getCapabilities('video').codecs.find(c => c.mimeType === 'video/VP9'), 'VP9 not supported'); + + const rids = [0, 1]; + const pc1 = new RTCPeerConnection(); + t.add_cleanup(() => pc1.close()); + const pc2 = new RTCPeerConnection(); + t.add_cleanup(() => pc2.close()); + + return negotiateSimulcastAndWaitForVideo(t, rids, pc1, pc2, {mimeType: 'video/VP9'}); +}, 'VP9 simulcast setup with two streams'); +</script> diff --git a/testing/web-platform/tests/webrtc/third_party/README.md b/testing/web-platform/tests/webrtc/third_party/README.md new file mode 100644 index 0000000000..56a2295dd1 --- /dev/null +++ b/testing/web-platform/tests/webrtc/third_party/README.md @@ -0,0 +1,5 @@ +## sdp +Third-party SDP module from + https://www.npmjs.com/package/sdp +without tests or dependencies. See the commit message for version +and commit information diff --git a/testing/web-platform/tests/webrtc/third_party/sdp/LICENSE b/testing/web-platform/tests/webrtc/third_party/sdp/LICENSE new file mode 100644 index 0000000000..09502ec0a1 --- /dev/null +++ b/testing/web-platform/tests/webrtc/third_party/sdp/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2017 Philipp Hancke + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/testing/web-platform/tests/webrtc/third_party/sdp/sdp.js b/testing/web-platform/tests/webrtc/third_party/sdp/sdp.js new file mode 100644 index 0000000000..a7538a671e --- /dev/null +++ b/testing/web-platform/tests/webrtc/third_party/sdp/sdp.js @@ -0,0 +1,825 @@ +/* eslint-env node */ +'use strict'; + +// SDP helpers. +var SDPUtils = {}; + +// Generate an alphanumeric identifier for cname or mids. +// TODO: use UUIDs instead? https://gist.github.com/jed/982883 +SDPUtils.generateIdentifier = function() { + return Math.random().toString(36).substr(2, 10); +}; + +// The RTCP CNAME used by all peerconnections from the same JS. +SDPUtils.localCName = SDPUtils.generateIdentifier(); + +// Splits SDP into lines, dealing with both CRLF and LF. +SDPUtils.splitLines = function(blob) { + return blob.trim().split('\n').map(function(line) { + return line.trim(); + }); +}; +// Splits SDP into sessionpart and mediasections. Ensures CRLF. +SDPUtils.splitSections = function(blob) { + var parts = blob.split('\nm='); + return parts.map(function(part, index) { + return (index > 0 ? 'm=' + part : part).trim() + '\r\n'; + }); +}; + +// returns the session description. +SDPUtils.getDescription = function(blob) { + var sections = SDPUtils.splitSections(blob); + return sections && sections[0]; +}; + +// returns the individual media sections. +SDPUtils.getMediaSections = function(blob) { + var sections = SDPUtils.splitSections(blob); + sections.shift(); + return sections; +}; + +// Returns lines that start with a certain prefix. +SDPUtils.matchPrefix = function(blob, prefix) { + return SDPUtils.splitLines(blob).filter(function(line) { + return line.indexOf(prefix) === 0; + }); +}; + +// Parses an ICE candidate line. Sample input: +// candidate:702786350 2 udp 41819902 8.8.8.8 60769 typ relay raddr 8.8.8.8 +// rport 55996" +SDPUtils.parseCandidate = function(line) { + var parts; + // Parse both variants. + if (line.indexOf('a=candidate:') === 0) { + parts = line.substring(12).split(' '); + } else { + parts = line.substring(10).split(' '); + } + + var candidate = { + foundation: parts[0], + component: parseInt(parts[1], 10), + protocol: parts[2].toLowerCase(), + priority: parseInt(parts[3], 10), + ip: parts[4], + address: parts[4], // address is an alias for ip. + port: parseInt(parts[5], 10), + // skip parts[6] == 'typ' + type: parts[7] + }; + + for (var i = 8; i < parts.length; i += 2) { + switch (parts[i]) { + case 'raddr': + candidate.relatedAddress = parts[i + 1]; + break; + case 'rport': + candidate.relatedPort = parseInt(parts[i + 1], 10); + break; + case 'tcptype': + candidate.tcpType = parts[i + 1]; + break; + case 'ufrag': + candidate.ufrag = parts[i + 1]; // for backward compability. + candidate.usernameFragment = parts[i + 1]; + break; + default: // extension handling, in particular ufrag + candidate[parts[i]] = parts[i + 1]; + break; + } + } + return candidate; +}; + +// Translates a candidate object into SDP candidate attribute. +SDPUtils.writeCandidate = function(candidate) { + var sdp = []; + sdp.push(candidate.foundation); + sdp.push(candidate.component); + sdp.push(candidate.protocol.toUpperCase()); + sdp.push(candidate.priority); + sdp.push(candidate.address || candidate.ip); + sdp.push(candidate.port); + + var type = candidate.type; + sdp.push('typ'); + sdp.push(type); + if (type !== 'host' && candidate.relatedAddress && + candidate.relatedPort) { + sdp.push('raddr'); + sdp.push(candidate.relatedAddress); + sdp.push('rport'); + sdp.push(candidate.relatedPort); + } + if (candidate.tcpType && candidate.protocol.toLowerCase() === 'tcp') { + sdp.push('tcptype'); + sdp.push(candidate.tcpType); + } + if (candidate.usernameFragment || candidate.ufrag) { + sdp.push('ufrag'); + sdp.push(candidate.usernameFragment || candidate.ufrag); + } + return 'candidate:' + sdp.join(' '); +}; + +// Parses an ice-options line, returns an array of option tags. +// a=ice-options:foo bar +SDPUtils.parseIceOptions = function(line) { + return line.substr(14).split(' '); +}; + +// Parses an rtpmap line, returns RTCRtpCoddecParameters. Sample input: +// a=rtpmap:111 opus/48000/2 +SDPUtils.parseRtpMap = function(line) { + var parts = line.substr(9).split(' '); + var parsed = { + payloadType: parseInt(parts.shift(), 10) // was: id + }; + + parts = parts[0].split('/'); + + parsed.name = parts[0]; + parsed.clockRate = parseInt(parts[1], 10); // was: clockrate + parsed.channels = parts.length === 3 ? parseInt(parts[2], 10) : 1; + // legacy alias, got renamed back to channels in ORTC. + parsed.numChannels = parsed.channels; + return parsed; +}; + +// Generate an a=rtpmap line from RTCRtpCodecCapability or +// RTCRtpCodecParameters. +SDPUtils.writeRtpMap = function(codec) { + var pt = codec.payloadType; + if (codec.preferredPayloadType !== undefined) { + pt = codec.preferredPayloadType; + } + var channels = codec.channels || codec.numChannels || 1; + return 'a=rtpmap:' + pt + ' ' + codec.name + '/' + codec.clockRate + + (channels !== 1 ? '/' + channels : '') + '\r\n'; +}; + +// Parses an a=extmap line (headerextension from RFC 5285). Sample input: +// a=extmap:2 urn:ietf:params:rtp-hdrext:toffset +// a=extmap:2/sendonly urn:ietf:params:rtp-hdrext:toffset +SDPUtils.parseExtmap = function(line) { + var parts = line.substr(9).split(' '); + return { + id: parseInt(parts[0], 10), + direction: parts[0].indexOf('/') > 0 ? parts[0].split('/')[1] : 'sendrecv', + uri: parts[1] + }; +}; + +// Generates a=extmap line from RTCRtpHeaderExtensionParameters or +// RTCRtpHeaderExtension. +SDPUtils.writeExtmap = function(headerExtension) { + return 'a=extmap:' + (headerExtension.id || headerExtension.preferredId) + + (headerExtension.direction && headerExtension.direction !== 'sendrecv' + ? '/' + headerExtension.direction + : '') + + ' ' + headerExtension.uri + '\r\n'; +}; + +// Parses an ftmp line, returns dictionary. Sample input: +// a=fmtp:96 vbr=on;cng=on +// Also deals with vbr=on; cng=on +SDPUtils.parseFmtp = function(line) { + var parsed = {}; + var kv; + var parts = line.substr(line.indexOf(' ') + 1).split(';'); + for (var j = 0; j < parts.length; j++) { + kv = parts[j].trim().split('='); + parsed[kv[0].trim()] = kv[1]; + } + return parsed; +}; + +// Generates an a=ftmp line from RTCRtpCodecCapability or RTCRtpCodecParameters. +SDPUtils.writeFmtp = function(codec) { + var line = ''; + var pt = codec.payloadType; + if (codec.preferredPayloadType !== undefined) { + pt = codec.preferredPayloadType; + } + if (codec.parameters && Object.keys(codec.parameters).length) { + var params = []; + Object.keys(codec.parameters).forEach(function(param) { + if (codec.parameters[param]) { + params.push(param + '=' + codec.parameters[param]); + } else { + params.push(param); + } + }); + line += 'a=fmtp:' + pt + ' ' + params.join(';') + '\r\n'; + } + return line; +}; + +// Parses an rtcp-fb line, returns RTCPRtcpFeedback object. Sample input: +// a=rtcp-fb:98 nack rpsi +SDPUtils.parseRtcpFb = function(line) { + var parts = line.substr(line.indexOf(' ') + 1).split(' '); + return { + type: parts.shift(), + parameter: parts.join(' ') + }; +}; +// Generate a=rtcp-fb lines from RTCRtpCodecCapability or RTCRtpCodecParameters. +SDPUtils.writeRtcpFb = function(codec) { + var lines = ''; + var pt = codec.payloadType; + if (codec.preferredPayloadType !== undefined) { + pt = codec.preferredPayloadType; + } + if (codec.rtcpFeedback && codec.rtcpFeedback.length) { + // FIXME: special handling for trr-int? + codec.rtcpFeedback.forEach(function(fb) { + lines += 'a=rtcp-fb:' + pt + ' ' + fb.type + + (fb.parameter && fb.parameter.length ? ' ' + fb.parameter : '') + + '\r\n'; + }); + } + return lines; +}; + +// Parses an RFC 5576 ssrc media attribute. Sample input: +// a=ssrc:3735928559 cname:something +SDPUtils.parseSsrcMedia = function(line) { + var sp = line.indexOf(' '); + var parts = { + ssrc: parseInt(line.substr(7, sp - 7), 10) + }; + var colon = line.indexOf(':', sp); + if (colon > -1) { + parts.attribute = line.substr(sp + 1, colon - sp - 1); + parts.value = line.substr(colon + 1); + } else { + parts.attribute = line.substr(sp + 1); + } + return parts; +}; + +SDPUtils.parseSsrcGroup = function(line) { + var parts = line.substr(13).split(' '); + return { + semantics: parts.shift(), + ssrcs: parts.map(function(ssrc) { + return parseInt(ssrc, 10); + }) + }; +}; + +// Extracts the MID (RFC 5888) from a media section. +// returns the MID or undefined if no mid line was found. +SDPUtils.getMid = function(mediaSection) { + var mid = SDPUtils.matchPrefix(mediaSection, 'a=mid:')[0]; + if (mid) { + return mid.substr(6); + } +}; + +SDPUtils.parseFingerprint = function(line) { + var parts = line.substr(14).split(' '); + return { + algorithm: parts[0].toLowerCase(), // algorithm is case-sensitive in Edge. + value: parts[1] + }; +}; + +// Extracts DTLS parameters from SDP media section or sessionpart. +// FIXME: for consistency with other functions this should only +// get the fingerprint line as input. See also getIceParameters. +SDPUtils.getDtlsParameters = function(mediaSection, sessionpart) { + var lines = SDPUtils.matchPrefix(mediaSection + sessionpart, + 'a=fingerprint:'); + // Note: a=setup line is ignored since we use the 'auto' role. + // Note2: 'algorithm' is not case sensitive except in Edge. + return { + role: 'auto', + fingerprints: lines.map(SDPUtils.parseFingerprint) + }; +}; + +// Serializes DTLS parameters to SDP. +SDPUtils.writeDtlsParameters = function(params, setupType) { + var sdp = 'a=setup:' + setupType + '\r\n'; + params.fingerprints.forEach(function(fp) { + sdp += 'a=fingerprint:' + fp.algorithm + ' ' + fp.value + '\r\n'; + }); + return sdp; +}; + +// Parses a=crypto lines into +// https://rawgit.com/aboba/edgertc/master/msortc-rs4.html#dictionary-rtcsrtpsdesparameters-members +SDPUtils.parseCryptoLine = function(line) { + var parts = line.substr(9).split(' '); + return { + tag: parseInt(parts[0], 10), + cryptoSuite: parts[1], + keyParams: parts[2], + sessionParams: parts.slice(3), + }; +}; + +SDPUtils.writeCryptoLine = function(parameters) { + return 'a=crypto:' + parameters.tag + ' ' + + parameters.cryptoSuite + ' ' + + (typeof parameters.keyParams === 'object' + ? SDPUtils.writeCryptoKeyParams(parameters.keyParams) + : parameters.keyParams) + + (parameters.sessionParams ? ' ' + parameters.sessionParams.join(' ') : '') + + '\r\n'; +}; + +// Parses the crypto key parameters into +// https://rawgit.com/aboba/edgertc/master/msortc-rs4.html#rtcsrtpkeyparam* +SDPUtils.parseCryptoKeyParams = function(keyParams) { + if (keyParams.indexOf('inline:') !== 0) { + return null; + } + var parts = keyParams.substr(7).split('|'); + return { + keyMethod: 'inline', + keySalt: parts[0], + lifeTime: parts[1], + mkiValue: parts[2] ? parts[2].split(':')[0] : undefined, + mkiLength: parts[2] ? parts[2].split(':')[1] : undefined, + }; +}; + +SDPUtils.writeCryptoKeyParams = function(keyParams) { + return keyParams.keyMethod + ':' + + keyParams.keySalt + + (keyParams.lifeTime ? '|' + keyParams.lifeTime : '') + + (keyParams.mkiValue && keyParams.mkiLength + ? '|' + keyParams.mkiValue + ':' + keyParams.mkiLength + : ''); +}; + +// Extracts all SDES paramters. +SDPUtils.getCryptoParameters = function(mediaSection, sessionpart) { + var lines = SDPUtils.matchPrefix(mediaSection + sessionpart, + 'a=crypto:'); + return lines.map(SDPUtils.parseCryptoLine); +}; + +// Parses ICE information from SDP media section or sessionpart. +// FIXME: for consistency with other functions this should only +// get the ice-ufrag and ice-pwd lines as input. +SDPUtils.getIceParameters = function(mediaSection, sessionpart) { + var ufrag = SDPUtils.matchPrefix(mediaSection + sessionpart, + 'a=ice-ufrag:')[0]; + var pwd = SDPUtils.matchPrefix(mediaSection + sessionpart, + 'a=ice-pwd:')[0]; + if (!(ufrag && pwd)) { + return null; + } + return { + usernameFragment: ufrag.substr(12), + password: pwd.substr(10), + }; +}; + +// Serializes ICE parameters to SDP. +SDPUtils.writeIceParameters = function(params) { + return 'a=ice-ufrag:' + params.usernameFragment + '\r\n' + + 'a=ice-pwd:' + params.password + '\r\n'; +}; + +// Parses the SDP media section and returns RTCRtpParameters. +SDPUtils.parseRtpParameters = function(mediaSection) { + var description = { + codecs: [], + headerExtensions: [], + fecMechanisms: [], + rtcp: [] + }; + var lines = SDPUtils.splitLines(mediaSection); + var mline = lines[0].split(' '); + for (var i = 3; i < mline.length; i++) { // find all codecs from mline[3..] + var pt = mline[i]; + var rtpmapline = SDPUtils.matchPrefix( + mediaSection, 'a=rtpmap:' + pt + ' ')[0]; + if (rtpmapline) { + var codec = SDPUtils.parseRtpMap(rtpmapline); + var fmtps = SDPUtils.matchPrefix( + mediaSection, 'a=fmtp:' + pt + ' '); + // Only the first a=fmtp:<pt> is considered. + codec.parameters = fmtps.length ? SDPUtils.parseFmtp(fmtps[0]) : {}; + codec.rtcpFeedback = SDPUtils.matchPrefix( + mediaSection, 'a=rtcp-fb:' + pt + ' ') + .map(SDPUtils.parseRtcpFb); + description.codecs.push(codec); + // parse FEC mechanisms from rtpmap lines. + switch (codec.name.toUpperCase()) { + case 'RED': + case 'ULPFEC': + description.fecMechanisms.push(codec.name.toUpperCase()); + break; + default: // only RED and ULPFEC are recognized as FEC mechanisms. + break; + } + } + } + SDPUtils.matchPrefix(mediaSection, 'a=extmap:').forEach(function(line) { + description.headerExtensions.push(SDPUtils.parseExtmap(line)); + }); + // FIXME: parse rtcp. + return description; +}; + +// Generates parts of the SDP media section describing the capabilities / +// parameters. +SDPUtils.writeRtpDescription = function(kind, caps) { + var sdp = ''; + + // Build the mline. + sdp += 'm=' + kind + ' '; + sdp += caps.codecs.length > 0 ? '9' : '0'; // reject if no codecs. + sdp += ' UDP/TLS/RTP/SAVPF '; + sdp += caps.codecs.map(function(codec) { + if (codec.preferredPayloadType !== undefined) { + return codec.preferredPayloadType; + } + return codec.payloadType; + }).join(' ') + '\r\n'; + + sdp += 'c=IN IP4 0.0.0.0\r\n'; + sdp += 'a=rtcp:9 IN IP4 0.0.0.0\r\n'; + + // Add a=rtpmap lines for each codec. Also fmtp and rtcp-fb. + caps.codecs.forEach(function(codec) { + sdp += SDPUtils.writeRtpMap(codec); + sdp += SDPUtils.writeFmtp(codec); + sdp += SDPUtils.writeRtcpFb(codec); + }); + var maxptime = 0; + caps.codecs.forEach(function(codec) { + if (codec.maxptime > maxptime) { + maxptime = codec.maxptime; + } + }); + if (maxptime > 0) { + sdp += 'a=maxptime:' + maxptime + '\r\n'; + } + sdp += 'a=rtcp-mux\r\n'; + + if (caps.headerExtensions) { + caps.headerExtensions.forEach(function(extension) { + sdp += SDPUtils.writeExtmap(extension); + }); + } + // FIXME: write fecMechanisms. + return sdp; +}; + +// Parses the SDP media section and returns an array of +// RTCRtpEncodingParameters. +SDPUtils.parseRtpEncodingParameters = function(mediaSection) { + var encodingParameters = []; + var description = SDPUtils.parseRtpParameters(mediaSection); + var hasRed = description.fecMechanisms.indexOf('RED') !== -1; + var hasUlpfec = description.fecMechanisms.indexOf('ULPFEC') !== -1; + + // filter a=ssrc:... cname:, ignore PlanB-msid + var ssrcs = SDPUtils.matchPrefix(mediaSection, 'a=ssrc:') + .map(function(line) { + return SDPUtils.parseSsrcMedia(line); + }) + .filter(function(parts) { + return parts.attribute === 'cname'; + }); + var primarySsrc = ssrcs.length > 0 && ssrcs[0].ssrc; + var secondarySsrc; + + var flows = SDPUtils.matchPrefix(mediaSection, 'a=ssrc-group:FID') + .map(function(line) { + var parts = line.substr(17).split(' '); + return parts.map(function(part) { + return parseInt(part, 10); + }); + }); + if (flows.length > 0 && flows[0].length > 1 && flows[0][0] === primarySsrc) { + secondarySsrc = flows[0][1]; + } + + description.codecs.forEach(function(codec) { + if (codec.name.toUpperCase() === 'RTX' && codec.parameters.apt) { + var encParam = { + ssrc: primarySsrc, + codecPayloadType: parseInt(codec.parameters.apt, 10) + }; + if (primarySsrc && secondarySsrc) { + encParam.rtx = {ssrc: secondarySsrc}; + } + encodingParameters.push(encParam); + if (hasRed) { + encParam = JSON.parse(JSON.stringify(encParam)); + encParam.fec = { + ssrc: primarySsrc, + mechanism: hasUlpfec ? 'red+ulpfec' : 'red' + }; + encodingParameters.push(encParam); + } + } + }); + if (encodingParameters.length === 0 && primarySsrc) { + encodingParameters.push({ + ssrc: primarySsrc + }); + } + + // we support both b=AS and b=TIAS but interpret AS as TIAS. + var bandwidth = SDPUtils.matchPrefix(mediaSection, 'b='); + if (bandwidth.length) { + if (bandwidth[0].indexOf('b=TIAS:') === 0) { + bandwidth = parseInt(bandwidth[0].substr(7), 10); + } else if (bandwidth[0].indexOf('b=AS:') === 0) { + // use formula from JSEP to convert b=AS to TIAS value. + bandwidth = parseInt(bandwidth[0].substr(5), 10) * 1000 * 0.95 + - (50 * 40 * 8); + } else { + bandwidth = undefined; + } + encodingParameters.forEach(function(params) { + params.maxBitrate = bandwidth; + }); + } + return encodingParameters; +}; + +// parses http://draft.ortc.org/#rtcrtcpparameters* +SDPUtils.parseRtcpParameters = function(mediaSection) { + var rtcpParameters = {}; + + // Gets the first SSRC. Note tha with RTX there might be multiple + // SSRCs. + var remoteSsrc = SDPUtils.matchPrefix(mediaSection, 'a=ssrc:') + .map(function(line) { + return SDPUtils.parseSsrcMedia(line); + }) + .filter(function(obj) { + return obj.attribute === 'cname'; + })[0]; + if (remoteSsrc) { + rtcpParameters.cname = remoteSsrc.value; + rtcpParameters.ssrc = remoteSsrc.ssrc; + } + + // Edge uses the compound attribute instead of reducedSize + // compound is !reducedSize + var rsize = SDPUtils.matchPrefix(mediaSection, 'a=rtcp-rsize'); + rtcpParameters.reducedSize = rsize.length > 0; + rtcpParameters.compound = rsize.length === 0; + + // parses the rtcp-mux attrŅbute. + // Note that Edge does not support unmuxed RTCP. + var mux = SDPUtils.matchPrefix(mediaSection, 'a=rtcp-mux'); + rtcpParameters.mux = mux.length > 0; + + return rtcpParameters; +}; + +// parses either a=msid: or a=ssrc:... msid lines and returns +// the id of the MediaStream and MediaStreamTrack. +SDPUtils.parseMsid = function(mediaSection) { + var parts; + var spec = SDPUtils.matchPrefix(mediaSection, 'a=msid:'); + if (spec.length === 1) { + parts = spec[0].substr(7).split(' '); + return {stream: parts[0], track: parts[1]}; + } + var planB = SDPUtils.matchPrefix(mediaSection, 'a=ssrc:') + .map(function(line) { + return SDPUtils.parseSsrcMedia(line); + }) + .filter(function(msidParts) { + return msidParts.attribute === 'msid'; + }); + if (planB.length > 0) { + parts = planB[0].value.split(' '); + return {stream: parts[0], track: parts[1]}; + } +}; + +// SCTP +// parses draft-ietf-mmusic-sctp-sdp-26 first and falls back +// to draft-ietf-mmusic-sctp-sdp-05 +SDPUtils.parseSctpDescription = function(mediaSection) { + var mline = SDPUtils.parseMLine(mediaSection); + var maxSizeLine = SDPUtils.matchPrefix(mediaSection, 'a=max-message-size:'); + var maxMessageSize; + if (maxSizeLine.length > 0) { + maxMessageSize = parseInt(maxSizeLine[0].substr(19), 10); + } + if (isNaN(maxMessageSize)) { + maxMessageSize = 65536; + } + var sctpPort = SDPUtils.matchPrefix(mediaSection, 'a=sctp-port:'); + if (sctpPort.length > 0) { + return { + port: parseInt(sctpPort[0].substr(12), 10), + protocol: mline.fmt, + maxMessageSize: maxMessageSize + }; + } + var sctpMapLines = SDPUtils.matchPrefix(mediaSection, 'a=sctpmap:'); + if (sctpMapLines.length > 0) { + var parts = SDPUtils.matchPrefix(mediaSection, 'a=sctpmap:')[0] + .substr(10) + .split(' '); + return { + port: parseInt(parts[0], 10), + protocol: parts[1], + maxMessageSize: maxMessageSize + }; + } +}; + +// SCTP +// outputs the draft-ietf-mmusic-sctp-sdp-26 version that all browsers +// support by now receiving in this format, unless we originally parsed +// as the draft-ietf-mmusic-sctp-sdp-05 format (indicated by the m-line +// protocol of DTLS/SCTP -- without UDP/ or TCP/) +SDPUtils.writeSctpDescription = function(media, sctp) { + var output = []; + if (media.protocol !== 'DTLS/SCTP') { + output = [ + 'm=' + media.kind + ' 9 ' + media.protocol + ' ' + sctp.protocol + '\r\n', + 'c=IN IP4 0.0.0.0\r\n', + 'a=sctp-port:' + sctp.port + '\r\n' + ]; + } else { + output = [ + 'm=' + media.kind + ' 9 ' + media.protocol + ' ' + sctp.port + '\r\n', + 'c=IN IP4 0.0.0.0\r\n', + 'a=sctpmap:' + sctp.port + ' ' + sctp.protocol + ' 65535\r\n' + ]; + } + if (sctp.maxMessageSize !== undefined) { + output.push('a=max-message-size:' + sctp.maxMessageSize + '\r\n'); + } + return output.join(''); +}; + +// Generate a session ID for SDP. +// https://tools.ietf.org/html/draft-ietf-rtcweb-jsep-20#section-5.2.1 +// recommends using a cryptographically random +ve 64-bit value +// but right now this should be acceptable and within the right range +SDPUtils.generateSessionId = function() { + return Math.floor((Math.random() * 4294967296) + 1); +}; + +// Write boilder plate for start of SDP +// sessId argument is optional - if not supplied it will +// be generated randomly +// sessVersion is optional and defaults to 2 +// sessUser is optional and defaults to 'thisisadapterortc' +SDPUtils.writeSessionBoilerplate = function(sessId, sessVer, sessUser) { + var sessionId; + var version = sessVer !== undefined ? sessVer : 2; + if (sessId) { + sessionId = sessId; + } else { + sessionId = SDPUtils.generateSessionId(); + } + var user = sessUser || 'thisisadapterortc'; + // FIXME: sess-id should be an NTP timestamp. + return 'v=0\r\n' + + 'o=' + user + ' ' + sessionId + ' ' + version + + ' IN IP4 127.0.0.1\r\n' + + 's=-\r\n' + + 't=0 0\r\n'; +}; + +SDPUtils.writeMediaSection = function(transceiver, caps, type, stream) { + var sdp = SDPUtils.writeRtpDescription(transceiver.kind, caps); + + // Map ICE parameters (ufrag, pwd) to SDP. + sdp += SDPUtils.writeIceParameters( + transceiver.iceGatherer.getLocalParameters()); + + // Map DTLS parameters to SDP. + sdp += SDPUtils.writeDtlsParameters( + transceiver.dtlsTransport.getLocalParameters(), + type === 'offer' ? 'actpass' : 'active'); + + sdp += 'a=mid:' + transceiver.mid + '\r\n'; + + if (transceiver.direction) { + sdp += 'a=' + transceiver.direction + '\r\n'; + } else if (transceiver.rtpSender && transceiver.rtpReceiver) { + sdp += 'a=sendrecv\r\n'; + } else if (transceiver.rtpSender) { + sdp += 'a=sendonly\r\n'; + } else if (transceiver.rtpReceiver) { + sdp += 'a=recvonly\r\n'; + } else { + sdp += 'a=inactive\r\n'; + } + + if (transceiver.rtpSender) { + // spec. + var msid = 'msid:' + stream.id + ' ' + + transceiver.rtpSender.track.id + '\r\n'; + sdp += 'a=' + msid; + + // for Chrome. + sdp += 'a=ssrc:' + transceiver.sendEncodingParameters[0].ssrc + + ' ' + msid; + if (transceiver.sendEncodingParameters[0].rtx) { + sdp += 'a=ssrc:' + transceiver.sendEncodingParameters[0].rtx.ssrc + + ' ' + msid; + sdp += 'a=ssrc-group:FID ' + + transceiver.sendEncodingParameters[0].ssrc + ' ' + + transceiver.sendEncodingParameters[0].rtx.ssrc + + '\r\n'; + } + } + // FIXME: this should be written by writeRtpDescription. + sdp += 'a=ssrc:' + transceiver.sendEncodingParameters[0].ssrc + + ' cname:' + SDPUtils.localCName + '\r\n'; + if (transceiver.rtpSender && transceiver.sendEncodingParameters[0].rtx) { + sdp += 'a=ssrc:' + transceiver.sendEncodingParameters[0].rtx.ssrc + + ' cname:' + SDPUtils.localCName + '\r\n'; + } + return sdp; +}; + +// Gets the direction from the mediaSection or the sessionpart. +SDPUtils.getDirection = function(mediaSection, sessionpart) { + // Look for sendrecv, sendonly, recvonly, inactive, default to sendrecv. + var lines = SDPUtils.splitLines(mediaSection); + for (var i = 0; i < lines.length; i++) { + switch (lines[i]) { + case 'a=sendrecv': + case 'a=sendonly': + case 'a=recvonly': + case 'a=inactive': + return lines[i].substr(2); + default: + // FIXME: What should happen here? + } + } + if (sessionpart) { + return SDPUtils.getDirection(sessionpart); + } + return 'sendrecv'; +}; + +SDPUtils.getKind = function(mediaSection) { + var lines = SDPUtils.splitLines(mediaSection); + var mline = lines[0].split(' '); + return mline[0].substr(2); +}; + +SDPUtils.isRejected = function(mediaSection) { + return mediaSection.split(' ', 2)[1] === '0'; +}; + +SDPUtils.parseMLine = function(mediaSection) { + var lines = SDPUtils.splitLines(mediaSection); + var parts = lines[0].substr(2).split(' '); + return { + kind: parts[0], + port: parseInt(parts[1], 10), + protocol: parts[2], + fmt: parts.slice(3).join(' ') + }; +}; + +SDPUtils.parseOLine = function(mediaSection) { + var line = SDPUtils.matchPrefix(mediaSection, 'o=')[0]; + var parts = line.substr(2).split(' '); + return { + username: parts[0], + sessionId: parts[1], + sessionVersion: parseInt(parts[2], 10), + netType: parts[3], + addressType: parts[4], + address: parts[5] + }; +}; + +// a very naive interpretation of a valid SDP. +SDPUtils.isValidSDP = function(blob) { + if (typeof blob !== 'string' || blob.length === 0) { + return false; + } + var lines = SDPUtils.splitLines(blob); + for (var i = 0; i < lines.length; i++) { + if (lines[i].length < 2 || lines[i].charAt(1) !== '=') { + return false; + } + // TODO: check the modifier a bit more. + } + return true; +}; + +// Expose public methods. +if (typeof module === 'object') { + module.exports = SDPUtils; +} diff --git a/testing/web-platform/tests/webrtc/toJSON.html b/testing/web-platform/tests/webrtc/toJSON.html new file mode 100644 index 0000000000..8d71353425 --- /dev/null +++ b/testing/web-platform/tests/webrtc/toJSON.html @@ -0,0 +1,48 @@ +<!doctype html> +<title>WebRTC objects toJSON() methods</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +'use strict'; +// The tests for +// * RTCSessionDescription.toJSON() +// * RTCIceCandidate.toJSON() +// are kept in a single file since they are similar and typically +// would need to be changed together. +test(t => { + const desc = new RTCSessionDescription({ + type: 'offer', + sdp: 'bogus sdp', + }); + const json = desc.toJSON(); + + // Assert that candidates which should be serialized are present. + assert_equals(json.type, desc.type); + assert_equals(json.sdp, desc.sdp); + + // Assert that no other attributes are present by checking the size. + assert_equals(Object.keys(json).length, 2); + +}, 'RTCSessionDescription.toJSON serializes only specific attributes'); + +test(t => { + const candidate = new RTCIceCandidate({ + sdpMLineIndex: 0, + sdpMid: '0', + candidate: 'candidate:1905690388 1 udp 2113937151 192.168.0.1 58041 typ host', + usernameFragment: 'test' + }); + const json = candidate.toJSON(); + + // Assert that candidates which should be serialized are present. + assert_equals(json.sdpMLineIndex, candidate.sdpMLineIndex); + assert_equals(json.sdpMid, candidate.sdpMid); + assert_equals(json.candidate, candidate.candidate); + assert_equals(json.usernameFragment, candidate.usernameFragment); + + // Assert that no other attributes are present by checking the size. + assert_equals(Object.keys(json).length, 4); + +}, 'RTCIceCandidate.toJSON serializes only specific attributes'); + +</script> diff --git a/testing/web-platform/tests/webrtc/tools/.eslintrc.js b/testing/web-platform/tests/webrtc/tools/.eslintrc.js new file mode 100644 index 0000000000..321f8e9a25 --- /dev/null +++ b/testing/web-platform/tests/webrtc/tools/.eslintrc.js @@ -0,0 +1,154 @@ +module.exports = { + rules: { + 'no-undef': 1, + 'no-unused-vars': 0 + }, + plugins: [ + 'html' + ], + env: { + browser: true, + es6: true + }, + globals: { + // testharness globals + test: true, + async_test: true, + promise_test: true, + IdlArray: true, + assert_true: true, + assert_false: true, + assert_equals: true, + assert_not_equals: true, + assert_array_equals: true, + assert_in_array: true, + assert_unreached: true, + assert_idl_attribute: true, + assert_own_property: true, + assert_greater_than: true, + assert_less_than: true, + assert_greater_than_equal: true, + assert_less_than_equal: true, + assert_approx_equals: true, + + + // WebRTC globals + RTCPeerConnection: true, + RTCRtpSender: true, + RTCRtpReceiver: true, + RTCRtpTransceiver: true, + RTCIceTransport: true, + RTCDtlsTransport: true, + RTCSctpTransport: true, + RTCDataChannel: true, + RTCCertificate: true, + RTCDTMFSender: true, + RTCError: true, + RTCTrackEvent: true, + RTCPeerConnectionIceEvent: true, + RTCDTMFToneChangeEvent: true, + RTCDataChannelEvent: true, + RTCRtpContributingSource: true, + RTCRtpSynchronizationSource: true, + + // dictionary-helper.js + assert_unsigned_int_field: true, + assert_int_field: true, + assert_string_field: true, + assert_number_field: true, + assert_boolean_field: true, + assert_array_field: true, + assert_dict_field: true, + assert_enum_field: true, + + assert_optional_unsigned_int_field: true, + assert_optional_int_field: true, + assert_optional_string_field: true, + assert_optional_number_field: true, + assert_optional_boolean_field: true, + assert_optional_array_field: true, + assert_optional_dict_field: true, + assert_optional_enum_field: true, + + // identity-helper.sub.js + parseAssertionResult: true, + getIdpDomains: true, + assert_rtcerror_rejection: true, + hostString: true, + + // RTCConfiguration-helper.js + config_test: true, + + // RTCDTMFSender-helper.js + createDtmfSender: true, + test_tone_change_events: true, + getTransceiver: true, + + // RTCPeerConnection-helper.js + countLine: true, + countAudioLine: true, + countVideoLine: true, + countApplicationLine: true, + similarMediaDescriptions: true, + assert_is_session_description: true, + isSimilarSessionDescription: true, + assert_session_desc_equals: true, + assert_session_desc_not_equals: true, + generateOffer: true, + generateAnswer: true, + test_state_change_event: true, + test_never_resolve: true, + exchangeIceCandidates: true, + exchangeOfferAnswer: true, + createDataChannelPair: true, + awaitMessage: true, + blobToArrayBuffer: true, + assert_equals_typed_array: true, + generateMediaStreamTrack: true, + getTrackFromUserMedia: true, + getUserMediaTracksAndStreams: true, + performOffer: true, + Resolver: true, + + // RTCRtpCapabilities-helper.js + validateRtpCapabilities: true, + validateCodecCapability: true, + validateHeaderExtensionCapability: true, + + // RTCRtpParameters-helper.js + validateSenderRtpParameters: true, + validateReceiverRtpParameters: true, + validateRtpParameters: true, + validateEncodingParameters: true, + validateRtcpParameters: true, + validateHeaderExtensionParameters: true, + validateCodecParameters: true, + + // RTCStats-helper.js + validateStatsReport: true, + assert_stats_report_has_stats: true, + findStatsFromReport: true, + getRequiredStats: true, + getStatsById: true, + validateIdField: true, + validateOptionalIdField: true, + validateRtcStats: true, + validateRtpStreamStats: true, + validateCodecStats: true, + validateReceivedRtpStreamStats: true, + validateInboundRtpStreamStats: true, + validateRemoteInboundRtpStreamStats: true, + validateSentRtpStreamStats: true, + validateOutboundRtpStreamStats: true, + validateRemoteOutboundRtpStreamStats: true, + validateContributingSourceStats: true, + validatePeerConnectionStats: true, + validateMediaStreamStats: true, + validateMediaStreamTrackStats: true, + validateDataChannelStats: true, + validateTransportStats: true, + validateIceCandidateStats: true, + validateIceCandidatePairStats: true, + validateCertificateStats: true, + } +} diff --git a/testing/web-platform/tests/webrtc/tools/README.md b/testing/web-platform/tests/webrtc/tools/README.md new file mode 100644 index 0000000000..68bc284fdf --- /dev/null +++ b/testing/web-platform/tests/webrtc/tools/README.md @@ -0,0 +1,14 @@ +WebRTC Tools +============ + +This directory contains a simple Node.js project to aid the development of +WebRTC tests. + +## Lint + +```bash +npm run lint +``` + +Does basic linting of the JavaScript code. Mainly for catching usage of +undefined variables. diff --git a/testing/web-platform/tests/webrtc/tools/codemod-peerconnection-addcleanup b/testing/web-platform/tests/webrtc/tools/codemod-peerconnection-addcleanup new file mode 100644 index 0000000000..920921d2e4 --- /dev/null +++ b/testing/web-platform/tests/webrtc/tools/codemod-peerconnection-addcleanup @@ -0,0 +1,58 @@ +/* a codemod for ensuring RTCPeerConnection is cleaned up in tests. + * For each `new RTCPeerConnection` add a + * `test.add_cleanup(() => pc.close())` + * Only applies in promise_tests if there is no add_cleanup in the + * test function body. + */ +export default function transformer(file, api) { + const j = api.jscodeshift; + return j(file.source) + // find each RTCPeerConnection constructor + .find(j.NewExpression, {callee: {type: 'Identifier', name: 'RTCPeerConnection'}}) + + // check it is inside a promise_test + .filter(path => { + // iterate parentPath until you find a CallExpression + let nextPath = path.parentPath; + while (nextPath && nextPath.value.type !== 'CallExpression') { + nextPath = nextPath.parentPath; + } + return nextPath && nextPath.value.callee.name === 'promise_test'; + }) + // check there is no add_cleanup in the function body + .filter(path => { + let nextPath = path.parentPath; + while (nextPath && nextPath.value.type !== 'CallExpression') { + nextPath = nextPath.parentPath; + } + const body = nextPath.value.arguments[0].body; + return j(body).find(j.Identifier, {name: 'add_cleanup'}).length === 0; + }) + .forEach(path => { + // iterate parentPath until you find a CallExpression + let nextPath = path.parentPath; + while (nextPath && nextPath.value.type !== 'CallExpression') { + nextPath = nextPath.parentPath; + } + const declaration = path.parentPath.parentPath.parentPath; + const pc = path.parentPath.value.id; + + declaration.insertAfter( + j.expressionStatement( + j.callExpression( + j.memberExpression( + nextPath.node.arguments[0].params[0], + j.identifier('add_cleanup') + ), + [j.arrowFunctionExpression([], + j.callExpression( + j.memberExpression(pc, j.identifier('close'), false), + [] + ) + )] + ) + ) + ); + }) + .toSource(); +}; diff --git a/testing/web-platform/tests/webrtc/tools/html-codemod.js b/testing/web-platform/tests/webrtc/tools/html-codemod.js new file mode 100644 index 0000000000..6a31e8c4c6 --- /dev/null +++ b/testing/web-platform/tests/webrtc/tools/html-codemod.js @@ -0,0 +1,34 @@ +/* + * extract script content from a series of html files, run a + * jscodeshift codemod on them and overwrite the original file. + * + * Usage: node html-codemod.js codemod-file list of files to process + */ +const { JSDOM } = require('jsdom'); +const fs = require('fs'); +const {execFileSync} = require('child_process'); + +const codemod = process.argv[2]; +const filenames = process.argv.slice(3); +filenames.forEach((filename) => { + const originalContent = fs.readFileSync(filename, 'utf-8'); + const dom = new JSDOM(originalContent); + const document = dom.window.document; + const scriptTags = document.querySelectorAll('script'); + const lastTag = scriptTags[scriptTags.length - 1]; + const script = lastTag.innerHTML; + if (!script) { + console.log('NO SCRIPT FOUND', filename); + return; + } + const scriptFilename = filename + '.codemod.js'; + const scriptFile = fs.writeFileSync(scriptFilename, script); + // exec jscodeshift + const output = execFileSync('./node_modules/.bin/jscodeshift', ['-t', codemod, scriptFilename]); + console.log(filename, output.toString()); // output jscodeshift output. + // read back file, resubstitute + const newScript = fs.readFileSync(scriptFilename, 'utf-8').toString(); + const modifiedContent = originalContent.split(script).join(newScript); + fs.writeFileSync(filename, modifiedContent); + fs.unlinkSync(scriptFilename); +}); diff --git a/testing/web-platform/tests/webrtc/tools/package.json b/testing/web-platform/tests/webrtc/tools/package.json new file mode 100644 index 0000000000..f26cfcc142 --- /dev/null +++ b/testing/web-platform/tests/webrtc/tools/package.json @@ -0,0 +1,16 @@ +{ + "name": "webrtc-testing-tools", + "version": "1.0.0", + "description": "Tools for WebRTC testing", + "scripts": { + "lint": "eslint -c .eslintrc.js ../*.html ../*.js" + }, + "devDependencies": { + "eslint": "^7.24.0", + "eslint-plugin-html": "^4.0.0", + "jscodeshift": "^0.5.1", + "jsdom": "^16.5.3" + }, + "license": "BSD", + "private": true +} |