chromium/chrome/test/data/webrtc/sub_capture_helpers.js

/**
 * Copyright 2022 The Chromium Authors
 * Use of this source code is governed by a BSD-style license that can be
 * found in the LICENSE file.
 */

"use strict";

///////////////////////////
// Test page management. //
///////////////////////////

let track;
let trackClone;
let otherCaptureTrack;

let role;
function setRole(value) {
  role = value;
}

// Given a URL, set embedded_frame's source to that URL.
// Return "embedding-done" after embedding is done.
async function startEmbeddingFrame(url) {
  const waiter = waitForMessage('embedding-done');
  document.getElementById("embedded_frame").src = url;
  const message = await waiter;
  return message.messageType;
}

// Called by the embedded pages participating in the test.
// Registers listeners for messages from the top-level page.
function registerEmbeddedListeners() {
  window.addEventListener("message", (event) => {
    const type = event.data.messageType;
    const reply = (message) => {
      event.source.postMessage(message, "*");
    };
    if (type == "setup-mailman") {
      setUpMailman(event.data.url).then(reply);
    } else if (type == "start-capture") {
      startCapture().then((result) => {
        reply({messageType: "start-capture-complete", result});
      });
    } else if (type == "produce-sub-target") {
      subCaptureTargetFromElement(event.data.targetType, "embedded",
                                  event.data.element)
        .then((result) => {
          reply({messageType: "produce-sub-target-complete", result});
        });
    } else if (type == "sub-capture-application") {
      applySubCapture(event.data.target, event.data.targetType,
                      event.data.targetFrame, event.data.targetTrackStr)
        .then((result) => {
          reply({messageType: "sub-capture-application-complete", result});
        });
    } else if (type == 'create-new-element') {
      createNewElement(event.data.targetFrame, event.data.tag, event.data.id)
        .then((result) => {
          reply({messageType: "create-new-element-complete", result});
        });
    } else if (type == 'start-second-capture') {
      startSecondCapture(event.data.targetFrame)
        .then((result) => {
          reply({messageType: "start-second-capture-complete", result});
        });
    } else if (type == 'stop-capture') {
      stopCapture(event.data.targetFrame, event.data.targetTrack)
        .then((result) => {
          reply({messageType: "stop-complete", result});
        });
    }
  });
}

// Allows creating new elements for which new crop-targets may be created.
//
// Parameters:
// * targetFrame: Either "top-level" or "embedded". Determines in which
//   page the element should be created.
// * tag: The type of element to be created.
// * id: The ID which the new element should be assigned.
//
// Returns "create-new-element-complete" once done.
async function createNewElement(targetFrame, tag, id) {
  if (role == targetFrame) {
    const newElement = document.createElement(tag);
    newElement.id = id;
    document.body.appendChild(newElement);
    return `${role}-new-element-success`;
  } else {
    if (role != "top-level") {
      return `${role}-new-element-error`;
    }
    const embedded_frame = document.getElementById("embedded_frame");
    const waiter = waitForMessage("create-new-element-complete");
    embedded_frame.contentWindow.postMessage(
        {
          messageType: 'create-new-element',
          targetFrame: targetFrame,
          tag: tag,
          id: id
        },
        '*');
    const message = await waiter;
    return message.result;
  }
}

// Produces visual updates in the page, ensuring new frames
// will be emitted and reducing test flakiness.
function animate(element) {
  const animationCallback = function() {
    element.innerHTML = parseInt(element.innerHTML) + 1;
  };
  setInterval(animationCallback, 20);
}

/////////////////////////////////////////
// Main actions from C++ test fixture. //
/////////////////////////////////////////

// Starts the main capture session.
async function startCapture() {
  if (track || trackClone) {
    return "error-multiple-captures";
  }

  try {
    const stream = await navigator.mediaDevices.getDisplayMedia({
      video: true,
      selfBrowserSurface: "include"
    });
    [track] = stream.getVideoTracks();
    return `${role}-capture-success`;
  } catch (e) {
    return `${role}-capture-failure`;
  }
}

// Starts a second capture, associated with `otherCaptureTrack`,
// in the indicated target-frame (top-level / embedded).
// Returns `${role}-second-capture-success` upon success,
// `${role}-second-capture-failure` if unsuccessful.
async function startSecondCapture(targetFrame) {
  if (targetFrame != `${role}`) {
    if (`${role}` != "top-level") {
      throw "error";
    }

    const embedded_frame = document.getElementById("embedded_frame");
    const waiter = waitForMessage("start-second-capture-complete");
    embedded_frame.contentWindow.postMessage({
        messageType: "start-second-capture",
        targetFrame: targetFrame
      }, "*");
    const message = await waiter;
    return message.result;
  }

  try {
    const stream = await navigator.mediaDevices.getDisplayMedia({
      video: true,
      selfBrowserSurface: "include"
    });
    [otherCaptureTrack] = stream.getVideoTracks();
    return `${role}-second-capture-success`;
  } catch (e) {
    return `${role}-second-capture-failure`;
  }
}

// Stops a specific capture in a specific frame.
// * targetFrame: Either "top-level" or "embedded".
// * targetTrack: "original", "clone" or "second", referencing
//   `track`, `trackClone` and `otherCaptureTrack`, respectively.
async function stopCapture(targetFrame, targetTrack) {
  if (targetFrame != `${role}`) {
    if (`${role}` != "top-level") {
      throw "error";
    }

    const embedded_frame = document.getElementById("embedded_frame");
    const waiter = waitForMessage("stop-capture-complete");
    embedded_frame.contentWindow.postMessage({
        messageType: "stop-capture",
        targetFrame: targetFrame,
        targetTrack: targetTrack
      }, "*");
    const message = await waiter;
    return message.result;
  }

  if (targetTrack == "original") {
    track.stop();
    track = undefined;
  } else if (targetTrack == "clone") {
    trackClone.stop();
    trackClone = undefined;
  } else if (targetTrack == "second") {
    otherCaptureTrack.stop();
    otherCaptureTrack = undefined
  } else {
    return "error";
  }

  return `${role}-stop-success`;
}

// Produce a token of type `targetType` for the element
// whose ID is `elementId`.
async function mintToken(targetType, elementId) {
  const element = document.getElementById(elementId);
  if (!element) {
    throw "unknown-element-error";
  }

  if (targetType == "crop-target") {
    return CropTarget.fromElement(element);
  } else if (targetType == "restriction-target") {
    return RestrictionTarget.fromElement(element);
  } else {
    throw "unknown-target-type-error";
  }
}

// Produce a token for a given element in the indicated frame.
//
// Parameters:
// * targetType: The type of token (crop-target / restriction-target).
// * targetFrame: The frame (top-level / embedded).
// * elementId: The ID of the element for which the token should be produced.
//
// The token is stored in subCaptureTargets and its index is returned.
async function subCaptureTargetFromElement(targetType, targetFrame, elementId) {
  const resultPrefix = `${role}-produce-${targetType}`;
  if (role == targetFrame) {
    try {
      const token = await mintToken(targetType, elementId);
      return makeSubCaptureTargetProxy(token);
    } catch (e) {
      return resultPrefix + "-error";
    }
  } else {
    const embedded_frame = document.getElementById("embedded_frame");
    const waiter = waitForMessage("produce-sub-target-complete");
    embedded_frame.contentWindow.postMessage({
        messageType: "produce-sub-target",
        element: elementId,
        targetType
      }, "*");
    const message = await waiter;
    return message.result;
  }
}

// Applies sub-capture; that is, calls cropTo() or restrictTo().
//
// Parameters:
// * target: Indicates the target of cropping/restricting. (Note that this
//   might be `undefined`.)
// * targetType: Indicates which type of sub-target `target` represents.
//   This parameter is necessary when `target` is `undefined`; otherwise, it
//   is surmisable from the target.
// * targetFrame: Indicates the targeted frame (top-level / embedded).
// * targetTrackStr: Indicates the targeted track, in case the capturing
//   frame has multiple concurrent captures.
//
// Returns `${role}-${targetType}-success` upon success.
async function applySubCapture(target, targetType, targetFrame,
                               targetTrackStr) {
  // `targetType` is explicitly indicated for the case where `target`
  // is `undefined`.
  // Otherwise, the type can be derived from `target`'s actual type.
  const isValid =
    target == undefined ||
    (targetType == "crop-target" &&
      target.__proto__.constructor.name == "CropTarget") ||
    (targetType == "restriction-target" &&
      target.__proto__.constructor.name == "RestrictionTarget");
  if (!isValid) {
    throw "error-invalid-type";
  }


  if (targetFrame != `${role}`) {
    if (`${role}` != "top-level") {
      throw "unrecognized-target-frame-error";
    }

    const embedded_frame = document.getElementById("embedded_frame");
    const waiter = waitForMessage("sub-capture-application-complete");
    embedded_frame.contentWindow.postMessage(
        {
          messageType: 'sub-capture-application',
          target: target,
          targetType: targetType,
          targetFrame: targetFrame,
          targetTrackStr: targetTrackStr
        },
        '*');
    const message = await waiter;
    return message.result;
  }

  const targetTrack = (targetTrackStr == "original" ? track :
                       targetTrackStr == "clone" ? trackClone :
                       targetTrackStr == "second" ? otherCaptureTrack :
                       undefined);
  if (!targetTrack) {
    throw "no-target-track-error";
  }

  try {
    if (targetType == "crop-target") {
      await targetTrack.cropTo(target);
    } else if (targetType == "restriction-target") {
      await targetTrack.restrictTo(target);
    } else {
      throw "unknown-type-error";
    }
    return `${role}-${targetType}-success`;
  } catch (e) {
    return `${role}-${targetType}-error`;
  }
}

////////////////////////////////////////////////////////////
// Communication with other pages belonging to this page. //
////////////////////////////////////////////////////////////

// To facilitate cross-origin communication, an embedded "mailman frame"
// is used. This "mailman frame" relays messages between (1) the frame which
// embeds the "mailman" and (2) the main frame of the other tab, which is
// same-origin with the mailman frame.
async function setUpMailman(url) {
  const mailman_frame = document.getElementById("mailman_frame");
  mailman_frame.src = url;

  window.addEventListener("message", (event) => {
    if (event.data.messageType == "announce-sub-capture-target") {
      subCaptureTargets.push(event.data.target);
      broadcast({
        messageType: "ack-sub-capture-target",
        index: event.data.index
      });
    }
  });

  await waitForMessage('mailman-embedded');
  if (role == "top-level") {
    const waiter = waitForMessage('mailman-ready');
    document.getElementById("embedded_frame").contentWindow.postMessage({
        messageType: "setup-mailman",
        url: url
      }, "*");
    const message = await waiter;
    return message.messageType;
  }
  return {messageType: "mailman-ready"};
}

// Return a Promise that will remain pending until a specific event
// is received, then resolve with the data associated with
// that event's data field.
function waitForMessage(expectedMessageType) {
  return new Promise(resolve => {
    const listener = (event) => {
      if (event.data.messageType == expectedMessageType) {
        window.removeEventListener("message", listener);
        resolve(event.data);
      }
    };
    window.addEventListener("message", listener);
  });
}

// Send a message to all other pages participating in this test.
function broadcast(msg) {
  const mailman_frame = document.getElementById("mailman_frame");
  mailman_frame.contentWindow.postMessage(msg, "*");
}


//////////////////////////////////////////////////////////////////
// Maintenance of a shared CropTarget database so that any page //
// could be tested in cropping to a target in any other page.   //
//////////////////////////////////////////////////////////////////

// Because CropTargets and RestrictionTargets are not stringifiable,
// we cache them here and give the C++ test fixture their index.
// When the C++ test fixture hands back the index as input to an action,
// we use the relevant token stored here.
//
// When a page mints a CropTarget/RestrictionTarget, it propagates it
// to all other pages. Each page acks it back, and when the minter
// receives the expected number of acks, it returns control to
// the C++ fixture.

// Contains all of the tokens this page knows of.
const subCaptureTargets = [];

const EXPECTED_ACKS = 3;

// Given a sub-capture token, announce it to all pages participating
// in the test, and return a promise which will resolve once all
// of those pages have acknowledged reception of the token.
async function makeSubCaptureTargetProxy(subCaptureTarget) {
  subCaptureTargets.push(subCaptureTarget);

  let ackCount = 0;
  let expectedAckIndex = subCaptureTargets.length - 1;

  const acksCompletedPromise = new Promise(resolve => {
    let ackListener = (event) => {
      if (event.data.messageType == "ack-sub-capture-target") {
        if (event.data.index != expectedAckIndex) {
          return;
        }

        if (++ackCount != EXPECTED_ACKS) {
          return;
        }

        window.removeEventListener("message", ackListener);
        resolve(`${subCaptureTargets.length - 1}`);
      }
    };
    window.addEventListener("message", ackListener);
  });

  broadcast({
    messageType: "announce-sub-capture-target",
    target: subCaptureTarget,
    index: expectedAckIndex
  });

  return acksCompletedPromise;
}

// The C++ fixture can only pass strings, not the actual JS objects.
// Therefore, whenever it wishes to refer to a specific sub-capture
// token, it references it via its index out of all sub-capture tokens
// produced by the test. This helper converts between the two.
// For convenience's sake, the special value `"undefined"` is allowed
// as well, and is converted to `undefined`.
function getSubCaptureTarget(subCaptureTargetIndex) {
  if (subCaptureTargetIndex == "undefined") {
    return undefined;
  }
  if (subCaptureTargetIndex >= subCaptureTargets.length) {
    throw "out-of-bounds-error";
  }
  return subCaptureTargets[subCaptureTargetIndex];
}