<!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>
