summaryrefslogtreecommitdiffstats
path: root/toolkit/components/pictureinpicture/tests/head.js
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/pictureinpicture/tests/head.js
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/pictureinpicture/tests/head.js')
-rw-r--r--toolkit/components/pictureinpicture/tests/head.js1153
1 files changed, 1153 insertions, 0 deletions
diff --git a/toolkit/components/pictureinpicture/tests/head.js b/toolkit/components/pictureinpicture/tests/head.js
new file mode 100644
index 0000000000..7e792b2e9e
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/head.js
@@ -0,0 +1,1153 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TOGGLE_POLICIES } = ChromeUtils.importESModule(
+ "resource://gre/modules/PictureInPictureControls.sys.mjs"
+);
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+const TEST_ROOT_2 = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.org"
+);
+const TEST_PAGE = TEST_ROOT + "test-page.html";
+const TEST_PAGE_2 = TEST_ROOT_2 + "test-page.html";
+const TEST_PAGE_WITH_IFRAME = TEST_ROOT_2 + "test-page-with-iframe.html";
+const TEST_PAGE_WITH_SOUND = TEST_ROOT + "test-page-with-sound.html";
+const TEST_PAGE_WITHOUT_AUDIO = TEST_ROOT + "test-page-without-audio.html";
+const TEST_PAGE_WITH_NAN_VIDEO_DURATION =
+ TEST_ROOT + "test-page-with-nan-video-duration.html";
+const TEST_PAGE_WITH_WEBVTT = TEST_ROOT + "test-page-with-webvtt.html";
+const TEST_PAGE_MULTIPLE_CONTEXTS =
+ TEST_ROOT + "test-page-multiple-contexts.html";
+const TEST_PAGE_TRANSPARENT_NESTED_IFRAMES =
+ TEST_ROOT + "test-transparent-nested-iframes.html";
+const TEST_PAGE_PIP_DISABLED = TEST_ROOT + "test-page-pipDisabled.html";
+const WINDOW_TYPE = "Toolkit:PictureInPicture";
+const TOGGLE_POSITION_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.position";
+/* As of Bug 1811312, 80% toggle opacity is for the PiP toggle experiment control. */
+const DEFAULT_TOGGLE_OPACITY = 0.8;
+const HAS_USED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+const SHARED_DATA_KEY = "PictureInPicture:SiteOverrides";
+// Used for clearing the size and location of the PiP window
+const PLAYER_URI = "chrome://global/content/pictureinpicture/player.xhtml";
+const ACCEPTABLE_DIFFERENCE = 2;
+
+/**
+ * We currently ship with a few different variations of the
+ * Picture-in-Picture toggle. The tests for Picture-in-Picture include tests
+ * that check the style rules of various parts of the toggle. Since each toggle
+ * variation has different style rules, we introduce a structure here to
+ * describe the appearance of the toggle at different stages for the tests.
+ *
+ * The top-level structure looks like this:
+ *
+ * {
+ * rootID (String): The ID of the root element of the toggle.
+ * stages (Object): An Object representing the styles of the toggle at
+ * different stages of its use. Each property represents a different
+ * stage that can be tested. Right now, those stages are:
+ *
+ * hoverVideo:
+ * When the mouse is hovering the video but not the toggle.
+ *
+ * hoverToggle:
+ * When the mouse is hovering both the video and the toggle.
+ *
+ * Both stages must be assigned an Object with the following properties:
+ *
+ * opacities:
+ * This should be set to an Object where the key is a CSS selector for
+ * an element, and the value is a double for what the eventual opacity
+ * of that element should be set to.
+ *
+ * hidden:
+ * This should be set to an Array of CSS selector strings for elements
+ * that should be hidden during a particular stage.
+ * }
+ *
+ * DEFAULT_TOGGLE_STYLES is the set of styles for the default variation of the
+ * toggle.
+ */
+const DEFAULT_TOGGLE_STYLES = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-wrapper": DEFAULT_TOGGLE_OPACITY,
+ },
+ hidden: [".pip-expanded"],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [".pip-expanded"],
+ },
+ },
+};
+
+/**
+ * Given a browser and the ID for a <video> element, triggers
+ * Picture-in-Picture for that <video>, and resolves with the
+ * Picture-in-Picture window once it is ready to be used.
+ *
+ * If triggerFn is not specified, then open using the
+ * MozTogglePictureInPicture event.
+ *
+ * @param {Element,BrowsingContext} browser The <xul:browser> or
+ * BrowsingContext hosting the <video>
+ *
+ * @param {String} videoID The ID of the video to trigger
+ * Picture-in-Picture on.
+ *
+ * @param {boolean} triggerFn Use the given function to open the pip window,
+ * which runs in the parent process.
+ *
+ * @return Promise
+ * @resolves With the Picture-in-Picture window when ready.
+ */
+async function triggerPictureInPicture(browser, videoID, triggerFn) {
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+
+ let videoReady = null;
+ if (triggerFn) {
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ video.focus();
+ });
+
+ triggerFn();
+
+ videoReady = SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ });
+ } else {
+ videoReady = SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ let event = new content.CustomEvent("MozTogglePictureInPicture", {
+ bubbles: true,
+ });
+ video.dispatchEvent(event);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ });
+ }
+ let win = await domWindowOpened;
+ await Promise.all([
+ SimpleTest.promiseFocus(win),
+ win.promiseDocumentFlushed(() => {}),
+ videoReady,
+ ]);
+ return win;
+}
+
+/**
+ * Given a browser and the ID for a <video> element, checks that the
+ * video is showing the "This video is playing in Picture-in-Picture mode."
+ * status message overlay.
+ *
+ * @param {Element,BrowsingContext} browser The <xul:browser> or
+ * BrowsingContext hosting the <video>
+ *
+ * @param {String} videoID The ID of the video to trigger
+ * Picture-in-Picture on.
+ *
+ * @param {bool} expected True if we expect the message to be showing.
+ *
+ * @return Promise
+ * @resolves When the checks have completed.
+ */
+async function assertShowingMessage(browser, videoID, expected) {
+ let showing = await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let pipOverlay = shadowRoot.querySelector(".pictureInPictureOverlay");
+ Assert.ok(pipOverlay, "Should be able to find Picture-in-Picture overlay.");
+
+ let rect = pipOverlay.getBoundingClientRect();
+ return rect.height > 0 && rect.width > 0;
+ });
+ Assert.equal(
+ showing,
+ expected,
+ "Video should be showing the expected state."
+ );
+}
+
+/**
+ * Tests if a video is currently being cloned for a given content browser. Provides a
+ * good indicator for answering if this video is currently open in PiP.
+ *
+ * @param {Browser} browser
+ * The content browser or browsing contect that the video lives in
+ * @param {string} videoId
+ * The id associated with the video
+ *
+ * @returns {bool}
+ * Whether the video is currently being cloned (And is most likely open in PiP)
+ */
+function assertVideoIsBeingCloned(browser, selector) {
+ return SpecialPowers.spawn(browser, [selector], async slctr => {
+ let video = content.document.querySelector(slctr);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ });
+}
+
+/**
+ * Ensures that each of the videos loaded inside of a document in a
+ * <browser> have reached the HAVE_ENOUGH_DATA readyState.
+ *
+ * @param {Element} browser The <xul:browser> hosting the <video>(s) or the browsing context
+ *
+ * @return Promise
+ * @resolves When each <video> is in the HAVE_ENOUGH_DATA readyState.
+ */
+async function ensureVideosReady(browser) {
+ // PictureInPictureToggleChild waits for videos to fire their "canplay"
+ // event before considering them for the toggle, so we start by making
+ // sure each <video> has done this.
+ info(`Waiting for videos to be ready`);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let videos = this.content.document.querySelectorAll("video");
+ for (let video of videos) {
+ video.currentTime = 0;
+ if (video.readyState < content.HTMLMediaElement.HAVE_ENOUGH_DATA) {
+ info(`Waiting for 'canplaythrough' for '${video.id}'`);
+ await ContentTaskUtils.waitForEvent(video, "canplaythrough");
+ }
+ }
+ });
+}
+
+/**
+ * Tests that the toggle opacity reaches or exceeds a certain threshold within
+ * a reasonable time.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> in it.
+ * @param {String} videoID The ID of the video element that we expect the toggle
+ * to appear on.
+ * @param {String} stage The stage for which the opacity is going to change. This
+ * should be one of "hoverVideo" or "hoverToggle".
+ * @param {Object} toggleStyles Optional argument. See the documentation for the
+ * DEFAULT_TOGGLE_STYLES object for a sense of what styleRules is expected to be.
+ *
+ * @return Promise
+ * @resolves When the check has completed.
+ */
+async function toggleOpacityReachesThreshold(
+ browser,
+ videoID,
+ stage,
+ toggleStyles = DEFAULT_TOGGLE_STYLES
+) {
+ let togglePosition = Services.prefs.getStringPref(
+ TOGGLE_POSITION_PREF,
+ "right"
+ );
+ let hasUsed = Services.prefs.getBoolPref(HAS_USED_PREF, false);
+ let toggleStylesForStage = toggleStyles.stages[stage];
+ info(
+ `Testing toggle for stage ${stage} ` +
+ `in position ${togglePosition}, has used: ${hasUsed}`
+ );
+
+ let args = { videoID, toggleStylesForStage, togglePosition, hasUsed };
+ await SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID, toggleStylesForStage } = args;
+
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+
+ for (let hiddenElement of toggleStylesForStage.hidden) {
+ let el = shadowRoot.querySelector(hiddenElement);
+ ok(
+ ContentTaskUtils.isHidden(el),
+ `Expected ${hiddenElement} to be hidden.`
+ );
+ }
+
+ for (let opacityElement in toggleStylesForStage.opacities) {
+ let opacityThreshold = toggleStylesForStage.opacities[opacityElement];
+ let el = shadowRoot.querySelector(opacityElement);
+
+ await ContentTaskUtils.waitForCondition(
+ () => {
+ let opacity = parseFloat(this.content.getComputedStyle(el).opacity);
+ return opacity >= opacityThreshold;
+ },
+ `Toggle element ${opacityElement} should have eventually reached ` +
+ `target opacity ${opacityThreshold}`,
+ 100,
+ 100
+ );
+ }
+
+ ok(true, "Toggle reached target opacity.");
+ });
+}
+
+/**
+ * Tests that the toggle has the correct policy attribute set. This should be called
+ * either when the toggle is visible, or events have been queued such that the toggle
+ * will soon be visible.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> in it.
+ * @param {String} videoID The ID of the video element that we expect the toggle
+ * to appear on.
+ * @param {Number} policy Optional argument. If policy is defined, then it should
+ * be one of the values in the TOGGLE_POLICIES from PictureInPictureControls.sys.mjs.
+ * If undefined, this function will ensure no policy attribute is set.
+ *
+ * @return Promise
+ * @resolves When the check has completed.
+ */
+async function assertTogglePolicy(
+ browser,
+ videoID,
+ policy,
+ toggleStyles = DEFAULT_TOGGLE_STYLES
+) {
+ let toggleID = toggleStyles.rootID;
+ let args = { videoID, toggleID, policy };
+ await SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID, toggleID, policy } = args;
+
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
+ let toggle = shadowRoot.getElementById(toggleID);
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return controlsOverlay.classList.contains("hovering");
+ }, "Waiting for the hovering state to be set on the video.");
+
+ if (policy) {
+ const { TOGGLE_POLICY_STRINGS } = ChromeUtils.importESModule(
+ "resource://gre/modules/PictureInPictureControls.sys.mjs"
+ );
+ let policyAttr = toggle.getAttribute("policy");
+ Assert.equal(
+ policyAttr,
+ TOGGLE_POLICY_STRINGS[policy],
+ "The correct toggle policy is set."
+ );
+ } else {
+ Assert.ok(
+ !toggle.hasAttribute("policy"),
+ "No toggle policy should be set."
+ );
+ }
+ });
+}
+
+/**
+ * Tests that either all or none of the expected mousebutton events
+ * fire in web content when clicking on the page.
+ *
+ * Note: This function will only work on pages that load the
+ * click-event-helper.js script.
+ *
+ * @param {Element} browser The <xul:browser> that will receive the mouse
+ * events.
+ * @param {bool} isExpectingEvents True if we expect all of the normal
+ * mouse button events to fire. False if we expect none of them to fire.
+ * @param {bool} isExpectingClick True if the mouse events should include the
+ * "click" event, which is only included when the primary mouse button is pressed.
+ * @return Promise
+ * @resolves When the check has completed.
+ */
+async function assertSawMouseEvents(
+ browser,
+ isExpectingEvents,
+ isExpectingClick = true
+) {
+ const MOUSE_BUTTON_EVENTS = [
+ "pointerdown",
+ "mousedown",
+ "pointerup",
+ "mouseup",
+ ];
+
+ if (isExpectingClick) {
+ MOUSE_BUTTON_EVENTS.push("click");
+ }
+
+ let mouseEvents = await SpecialPowers.spawn(browser, [], async () => {
+ return this.content.wrappedJSObject.getRecordedEvents();
+ });
+
+ let expectedEvents = isExpectingEvents ? MOUSE_BUTTON_EVENTS : [];
+ Assert.deepEqual(
+ mouseEvents,
+ expectedEvents,
+ "Expected to get the right mouse events."
+ );
+}
+
+/**
+ * Tests that a click event is fire in web content when clicking on the page.
+ *
+ * Note: This function will only work on pages that load the
+ * click-event-helper.js script.
+ *
+ * @param {Element} browser The <xul:browser> that will receive the mouse
+ * events.
+ * @return Promise
+ * @resolves When the check has completed.
+ */
+async function assertSawClickEventOnly(browser) {
+ let mouseEvents = await SpecialPowers.spawn(browser, [], async () => {
+ return this.content.wrappedJSObject.getRecordedEvents();
+ });
+ Assert.deepEqual(
+ mouseEvents,
+ ["click"],
+ "Expected to get the right mouse events."
+ );
+}
+
+/**
+ * Ensures that a <video> inside of a <browser> is scrolled into view,
+ * and then returns the coordinates of its Picture-in-Picture toggle as well
+ * as whether or not the <video> element is showing the built-in controls.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video that has the toggle.
+ *
+ * @return Promise
+ * @resolves With the following Object structure:
+ * {
+ * controls: <Boolean>,
+ * }
+ *
+ * Where controls represents whether or not the video has the default control set
+ * displayed.
+ */
+async function prepareForToggleClick(browser, videoID) {
+ // Synthesize a mouse move just outside of the video to ensure that
+ // the video is in a non-hovering state. We'll go 5 pixels to the
+ // left and above the top-left corner.
+ await BrowserTestUtils.synthesizeMouse(
+ `#${videoID}`,
+ -5,
+ -5,
+ {
+ type: "mousemove",
+ },
+ browser,
+ false
+ );
+
+ // For each video, make sure it's scrolled into view, and get the rect for
+ // the toggle while we're at it.
+ let args = { videoID };
+ return SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID } = args;
+
+ let video = content.document.getElementById(videoID);
+ video.scrollIntoView({ behaviour: "instant" });
+
+ if (!video.controls) {
+ // For no-controls <video> elements, an IntersectionObserver is used
+ // to know when we the PictureInPictureChild should begin tracking
+ // mousemove events. We don't exactly know when that IntersectionObserver
+ // will fire, so we poll a special testing function that will tell us when
+ // the video that we care about is being tracked.
+ let { PictureInPictureToggleChild } = ChromeUtils.importESModule(
+ "resource://gre/actors/PictureInPictureChild.sys.mjs"
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => {
+ return PictureInPictureToggleChild.isTracking(video);
+ },
+ "Waiting for PictureInPictureToggleChild to be tracking the video.",
+ 100,
+ 100
+ );
+ }
+
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
+ await ContentTaskUtils.waitForCondition(
+ () => {
+ return !controlsOverlay.classList.contains("hovering");
+ },
+ "Waiting for the video to not be hovered.",
+ 100,
+ 100
+ );
+
+ return {
+ controls: video.controls,
+ };
+ });
+}
+
+/**
+ * Returns client rect info for the toggle if it's supposed to be visible
+ * on hover. Otherwise, returns client rect info for the video with the
+ * associated ID.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video that has the toggle.
+ *
+ * @return Promise
+ * @resolves With the following Object structure:
+ * {
+ * top: <Number>,
+ * left: <Number>,
+ * width: <Number>,
+ * height: <Number>,
+ * }
+ */
+async function getToggleClientRect(
+ browser,
+ videoID,
+ toggleStyles = DEFAULT_TOGGLE_STYLES
+) {
+ let args = { videoID, toggleID: toggleStyles.rootID };
+ return ContentTask.spawn(browser, args, async args => {
+ const { Rect } = ChromeUtils.importESModule(
+ "resource://gre/modules/Geometry.sys.mjs"
+ );
+
+ let { videoID, toggleID } = args;
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let toggle = shadowRoot.getElementById(toggleID);
+ let rect = Rect.fromRect(toggle.getBoundingClientRect());
+
+ let clickableChildren = toggle.querySelectorAll(".clickable");
+ for (let child of clickableChildren) {
+ let childRect = Rect.fromRect(child.getBoundingClientRect());
+ rect.expandToContain(childRect);
+ }
+
+ if (!rect.width && !rect.height) {
+ rect = video.getBoundingClientRect();
+ }
+
+ return {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ };
+ });
+}
+
+/**
+ * This function will hover over the middle of the video and then
+ * hover over the toggle.
+ * @param browser The current browser
+ * @param videoID The video element id
+ */
+async function hoverToggle(browser, videoID) {
+ await prepareForToggleClick(browser, videoID);
+
+ // Hover the mouse over the video to reveal the toggle.
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mousemove",
+ },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ info("Checking toggle policy");
+ await assertTogglePolicy(browser, videoID, null);
+
+ let toggleClientRect = await getToggleClientRect(browser, videoID);
+
+ info("Hovering the toggle rect now.");
+ let toggleCenterX = toggleClientRect.left + toggleClientRect.width / 2;
+ let toggleCenterY = toggleClientRect.top + toggleClientRect.height / 2;
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {
+ type: "mousemove",
+ },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+}
+
+/**
+ * Test helper for the Picture-in-Picture toggle. Loads a page, and then
+ * tests the provided video elements for the toggle both appearing and
+ * opening the Picture-in-Picture window in the expected cases.
+ *
+ * @param {String} testURL The URL of the page with the <video> elements.
+ * @param {Object} expectations An object with the following schema:
+ * <video-element-id>: {
+ * canToggle: {Boolean}
+ * policy: {Number} (optional)
+ * styleRules: {Object} (optional)
+ * }
+ * If canToggle is true, then it's expected that moving the mouse over the
+ * video and then clicking in the toggle region should open a
+ * Picture-in-Picture window. If canToggle is false, we expect that a click
+ * in this region will not result in the window opening.
+ *
+ * If policy is defined, then it should be one of the values in the
+ * TOGGLE_POLICIES from PictureInPictureControls.sys.mjs.
+ *
+ * See the documentation for the DEFAULT_TOGGLE_STYLES object for a sense
+ * of what styleRules is expected to be. If left undefined, styleRules will
+ * default to DEFAULT_TOGGLE_STYLES.
+ *
+ * @param {async Function} prepFn An optional asynchronous function to run
+ * before running the toggle test. The function is passed the opened
+ * <xul:browser> as its only argument once the testURL has finished loading.
+ *
+ * @return Promise
+ * @resolves When the test is complete and the tab with the loaded page is
+ * removed.
+ */
+async function testToggle(testURL, expectations, prepFn = async () => {}) {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: testURL,
+ },
+ async browser => {
+ await prepFn(browser);
+ await ensureVideosReady(browser);
+
+ for (let [
+ videoID,
+ { canToggle, policy, toggleStyles, shouldSeeClickEventAfterToggle },
+ ] of Object.entries(expectations)) {
+ await SimpleTest.promiseFocus(browser);
+ info(`Testing video with id: ${videoID}`);
+
+ await testToggleHelper(
+ browser,
+ videoID,
+ canToggle,
+ policy,
+ toggleStyles,
+ shouldSeeClickEventAfterToggle
+ );
+ }
+ }
+ );
+}
+
+/**
+ * Test helper for the Picture-in-Picture toggle. Given a loaded page with some
+ * videos on it, tests that the toggle behaves as expected when interacted
+ * with by the mouse.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video that has the toggle.
+ * @param {Boolean} canToggle True if we expect the toggle to be visible and
+ * clickable by the mouse for the associated video.
+ * @param {Number} policy Optional argument. If policy is defined, then it should
+ * be one of the values in the TOGGLE_POLICIES from PictureInPictureControls.sys.mjs.
+ * @param {Object} toggleStyles Optional argument. See the documentation for the
+ * DEFAULT_TOGGLE_STYLES object for a sense of what styleRules is expected to be.
+ *
+ * @return Promise
+ * @resolves When the check for the toggle is complete.
+ */
+async function testToggleHelper(
+ browser,
+ videoID,
+ canToggle,
+ policy,
+ toggleStyles,
+ shouldSeeClickEventAfterToggle
+) {
+ let { controls } = await prepareForToggleClick(browser, videoID);
+
+ // Hover the mouse over the video to reveal the toggle.
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mousemove",
+ },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ info("Checking toggle policy");
+ await assertTogglePolicy(browser, videoID, policy, toggleStyles);
+
+ if (canToggle) {
+ info("Waiting for toggle to become visible");
+ await toggleOpacityReachesThreshold(
+ browser,
+ videoID,
+ "hoverVideo",
+ toggleStyles
+ );
+ }
+
+ let toggleClientRect = await getToggleClientRect(
+ browser,
+ videoID,
+ toggleStyles
+ );
+
+ info("Hovering the toggle rect now.");
+ let toggleCenterX = toggleClientRect.left + toggleClientRect.width / 2;
+ let toggleCenterY = toggleClientRect.top + toggleClientRect.height / 2;
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {
+ type: "mousemove",
+ },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ if (canToggle) {
+ info("Waiting for toggle to reach full opacity");
+ await toggleOpacityReachesThreshold(
+ browser,
+ videoID,
+ "hoverToggle",
+ toggleStyles
+ );
+ }
+
+ // First, ensure that a non-primary mouse click is ignored.
+ info("Right-clicking on toggle.");
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ { button: 2 },
+ browser
+ );
+
+ // For videos without the built-in controls, we expect that all mouse events
+ // should have fired - otherwise, the events are all suppressed. For videos
+ // with controls, none of the events should be fired, as the controls overlay
+ // absorbs them all.
+ //
+ // Note that the right-click does not result in a "click" event firing.
+ await assertSawMouseEvents(browser, !controls, false);
+
+ // The message to open the Picture-in-Picture window would normally be sent
+ // immediately before this Promise resolved, so the window should have opened
+ // by now if it was going to happen.
+ for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) {
+ if (!win.closed) {
+ ok(false, "Found a Picture-in-Picture window unexpectedly.");
+ return;
+ }
+ }
+
+ ok(true, "No Picture-in-Picture window found.");
+
+ // Okay, now test with the primary mouse button.
+
+ if (canToggle) {
+ info(
+ "Clicking on toggle, and expecting a Picture-in-Picture window to open"
+ );
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {},
+ browser
+ );
+ let win = await domWindowOpened;
+ ok(win, "A Picture-in-Picture window opened.");
+
+ await assertVideoIsBeingCloned(browser, "#" + videoID);
+
+ await BrowserTestUtils.closeWindow(win);
+
+ // We do get a "Click" sometimes, it depends on many
+ // factors such as whether the video has control and
+ // the style of the toggle.
+ if (shouldSeeClickEventAfterToggle) {
+ await assertSawClickEventOnly(browser);
+ } else {
+ // Make sure that clicking on the toggle resulted in no mouse button events
+ // being fired in content.
+ await assertSawMouseEvents(browser, false);
+ }
+ } else {
+ info(
+ "Clicking on toggle, and expecting no Picture-in-Picture window opens"
+ );
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {},
+ browser
+ );
+
+ // If we aren't showing the toggle, we expect all mouse events to be seen.
+ await assertSawMouseEvents(browser, !controls);
+
+ // The message to open the Picture-in-Picture window would normally be sent
+ // immediately before this Promise resolved, so the window should have opened
+ // by now if it was going to happen.
+ for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) {
+ if (!win.closed) {
+ ok(false, "Found a Picture-in-Picture window unexpectedly.");
+ return;
+ }
+ }
+
+ ok(true, "No Picture-in-Picture window found.");
+ }
+
+ // Click on the very top-left pixel of the document and ensure that we
+ // see all of the mouse events for it.
+ await BrowserTestUtils.synthesizeMouseAtPoint(1, 1, {}, browser);
+ await assertSawMouseEvents(browser, true);
+}
+
+/**
+ * Helper function that ensures that a provided async function
+ * causes a window to fully enter fullscreen mode.
+ *
+ * @param window (DOM Window)
+ * The window that is expected to enter fullscreen mode.
+ * @param asyncFn (Async Function)
+ * The async function to run to trigger the fullscreen switch.
+ * @return Promise
+ * @resolves When the fullscreen entering transition completes.
+ */
+async function promiseFullscreenEntered(window, asyncFn) {
+ let entered = BrowserTestUtils.waitForEvent(
+ window,
+ "MozDOMFullscreen:Entered"
+ );
+
+ await asyncFn();
+
+ await entered;
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return !TelemetryStopwatch.running("FULLSCREEN_CHANGE_MS");
+ });
+
+ if (AppConstants.platform == "macosx") {
+ // On macOS, the fullscreen transition takes some extra time
+ // to complete, and we don't receive events for it. We need to
+ // wait for it to complete or else input events in the next test
+ // might get eaten up. This is the best we can currently do.
+ //
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ dump(`BJW promiseFullscreenEntered: waiting for 2 second timeout.\n`);
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ }
+}
+
+/**
+ * Helper function that ensures that a provided async function
+ * causes a window to fully exit fullscreen mode.
+ *
+ * @param window (DOM Window)
+ * The window that is expected to exit fullscreen mode.
+ * @param asyncFn (Async Function)
+ * The async function to run to trigger the fullscreen switch.
+ * @return Promise
+ * @resolves When the fullscreen exiting transition completes.
+ */
+async function promiseFullscreenExited(window, asyncFn) {
+ let exited = BrowserTestUtils.waitForEvent(window, "MozDOMFullscreen:Exited");
+
+ await asyncFn();
+
+ await exited;
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return !TelemetryStopwatch.running("FULLSCREEN_CHANGE_MS");
+ });
+
+ if (AppConstants.platform == "macosx") {
+ // On macOS, the fullscreen transition takes some extra time
+ // to complete, and we don't receive events for it. We need to
+ // wait for it to complete or else input events in the next test
+ // might get eaten up. This is the best we can currently do.
+ //
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ }
+}
+
+/**
+ * Helper function that ensures that the "This video is
+ * playing in Picture-in-Picture mode" message works,
+ * then closes the player window
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video that has the toggle.
+ * @param {Element} pipWin The Picture-in-Picture window that was opened
+ * @param {Boolean} iframe True if the test is on an Iframe, which modifies
+ * the test behavior
+ */
+async function ensureMessageAndClosePiP(browser, videoID, pipWin, isIframe) {
+ try {
+ await assertShowingMessage(browser, videoID, true);
+ } finally {
+ let uaWidgetUpdate = null;
+ if (isIframe) {
+ uaWidgetUpdate = SpecialPowers.spawn(browser, [], async () => {
+ await ContentTaskUtils.waitForEvent(
+ content.windowRoot,
+ "UAWidgetSetupOrChange",
+ true /* capture */
+ );
+ });
+ } else {
+ uaWidgetUpdate = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "UAWidgetSetupOrChange",
+ true /* capture */
+ );
+ }
+ await BrowserTestUtils.closeWindow(pipWin);
+ await uaWidgetUpdate;
+ }
+}
+
+/**
+ * Helper function that returns True if the specified video is paused
+ * and False if the specified video is not paused.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video to check.
+ */
+async function isVideoPaused(browser, videoID) {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).paused;
+ });
+}
+
+/**
+ * Helper function that returns True if the specified video is muted
+ * and False if the specified video is not muted.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video to check.
+ */
+async function isVideoMuted(browser, videoID) {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).muted;
+ });
+}
+
+/**
+ * Initializes videos and text tracks for the current test case.
+ * First track is the default track to be loaded onto the video.
+ * Once initialization is done, play then pause the requested video.
+ * so that text tracks are loaded.
+ * @param {Element} browser The <xul:browser> hosting the <video>
+ * @param {String} videoID The ID of the video being checked
+ * @param {Integer} defaultTrackIndex The index of the track to be loaded, or none if -1
+ * @param {String} trackMode the mode that the video's textTracks should be set to
+ */
+async function prepareVideosAndWebVTTTracks(
+ browser,
+ videoID,
+ defaultTrackIndex = 0,
+ trackMode = "showing"
+) {
+ info("Preparing video and initial text tracks");
+ await ensureVideosReady(browser);
+ await SpecialPowers.spawn(
+ browser,
+ [{ videoID, defaultTrackIndex, trackMode }],
+ async args => {
+ let video = content.document.getElementById(args.videoID);
+ let tracks = video.textTracks;
+
+ is(tracks.length, 5, "Number of tracks loaded should be 5");
+
+ // Enable track for originating video
+ if (args.defaultTrackIndex >= 0) {
+ info(`Loading track ${args.defaultTrackIndex + 1}`);
+ let track = tracks[args.defaultTrackIndex];
+ tracks.mode = args.trackMode;
+ track.mode = args.trackMode;
+ }
+
+ // Briefly play the video to load text tracks onto the pip window.
+ info("Playing video to load text tracks");
+ video.play();
+ info("Pausing video");
+ video.pause();
+ ok(video.paused, "Video should be paused before proceeding with test");
+ }
+ );
+}
+
+/**
+ * Plays originating video until the next cue is loaded.
+ * Once the next cue is loaded, pause the video.
+ * @param {Element} browser The <xul:browser> hosting the <video>
+ * @param {String} videoID The ID of the video being checked
+ * @param {Integer} textTrackIndex The index of the track to be loaded, or none if -1
+ */
+async function waitForNextCue(browser, videoID, textTrackIndex = 0) {
+ if (textTrackIndex < 0) {
+ ok(false, "Cannot wait for next cue with invalid track index");
+ }
+
+ await SpecialPowers.spawn(
+ browser,
+ [{ videoID, textTrackIndex }],
+ async args => {
+ let video = content.document.getElementById(args.videoID);
+ info("Playing video to activate next cue");
+ video.play();
+ ok(!video.paused, "Video is playing");
+
+ info("Waiting until cuechange is called");
+ await ContentTaskUtils.waitForEvent(
+ video.textTracks[args.textTrackIndex],
+ "cuechange"
+ );
+
+ info("Pausing video to read text track");
+ video.pause();
+ ok(video.paused, "Video is paused");
+ }
+ );
+}
+
+/**
+ * The PiP window saves the positon when closed and sometimes we don't want
+ * this information to persist to other tests. This function will clear the
+ * position so the PiP window will open in the default position.
+ */
+function clearSavedPosition() {
+ let xulStore = Services.xulStore;
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "left", NaN);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "top", NaN);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "width", NaN);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "height", NaN);
+}
+
+function overrideSavedPosition(left, top, width, height) {
+ let xulStore = Services.xulStore;
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "left", left);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "top", top);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "width", width);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "height", height);
+}
+
+/**
+ * Function used to filter events when waiting for the correct number
+ * telemetry events.
+ * @param {String} expected The expected string or undefined
+ * @param {String} actual The actual string
+ * @returns true if the expected is undefined or if expected matches actual
+ */
+function matches(expected, actual) {
+ if (expected === undefined) {
+ return true;
+ }
+ return expected === actual;
+}
+
+/**
+ * Function that waits for the expected number of events aftering filtering.
+ * @param {Object} filter An object containing optional filters
+ * {
+ * category: (optional) The category of the event. Ex. "pictureinpicture"
+ * method: (optional) The method of the event. Ex. "create"
+ * object: (optional) The object of the event. Ex. "player"
+ * }
+ * @param {Number} length The number of events to wait for
+ * @param {String} process Should be "content" or "parent" depending on the event
+ */
+async function waitForTelemeryEvents(filter, length, process) {
+ let {
+ category: filterCategory,
+ method: filterMethod,
+ object: filterObject,
+ } = filter;
+
+ let events = [];
+ await TestUtils.waitForCondition(
+ () => {
+ events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ )[process];
+ if (!events) {
+ return false;
+ }
+
+ let filtered = events
+ .map(([, /* timestamp */ category, method, object, value, extra]) => {
+ // We don't care about the `timestamp` value.
+ // Tests that examine that value should use `snapshotEvents` directly.
+ return [category, method, object, value, extra];
+ })
+ .filter(([category, method, object]) => {
+ return (
+ matches(filterCategory, category) &&
+ matches(filterMethod, method) &&
+ matches(filterObject, object)
+ );
+ });
+ info(JSON.stringify(filtered, null, 2));
+ return filtered && filtered.length >= length;
+ },
+ `Waiting for ${length} pictureinpicture telemetry event(s) with filter ${JSON.stringify(
+ filter,
+ null,
+ 2
+ )}`,
+ 200,
+ 100
+ );
+}