summaryrefslogtreecommitdiffstats
path: root/toolkit/components/pictureinpicture/tests
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/components/pictureinpicture/tests/.eslintrc.js5
-rw-r--r--toolkit/components/pictureinpicture/tests/browser.ini87
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js32
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_closePipPause.js68
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_closePlayer.js48
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_closeTab.js25
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_contextMenu.js238
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js271
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js90
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_durationChange.js61
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_flipIconWithRTL.js66
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_fullscreen.js142
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js83
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_mediaStreamVideos.js59
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_mouseButtonVariation.js97
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_multiPip.js232
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js45
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_playerControls.js91
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js84
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_rerequestPiP.js31
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_resizeVideo.js263
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_reversePiP.js145
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js362
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_shortcutsAfterFocus.js67
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_showMessage.js29
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_smallVideoLayout.js210
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_stripVideoStyles.js49
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js90
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js47
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js58
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js17
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js202
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleOnInsertedVideo.js42
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleOpaqueOverlay.js16
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_togglePointerEventsNone.js16
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_togglePolicies.js126
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleSimple.js18
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-1.js33
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-2.js33
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_videoSelection.js106
-rw-r--r--toolkit/components/pictureinpicture/tests/click-event-helper.js26
-rw-r--r--toolkit/components/pictureinpicture/tests/head.js835
-rw-r--r--toolkit/components/pictureinpicture/tests/short.mp4bin0 -> 38713 bytes
-rw-r--r--toolkit/components/pictureinpicture/tests/test-button-overlay.html81
-rw-r--r--toolkit/components/pictureinpicture/tests/test-media-stream.html25
-rw-r--r--toolkit/components/pictureinpicture/tests/test-opaque-overlay.html51
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-with-iframe.html27
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-with-sound.html20
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page.html30
-rw-r--r--toolkit/components/pictureinpicture/tests/test-pointer-events-none.html21
-rw-r--r--toolkit/components/pictureinpicture/tests/test-reversed.html19
-rw-r--r--toolkit/components/pictureinpicture/tests/test-transparent-nested-iframes.html51
-rw-r--r--toolkit/components/pictureinpicture/tests/test-transparent-overlay-1.html46
-rw-r--r--toolkit/components/pictureinpicture/tests/test-transparent-overlay-2.html46
-rw-r--r--toolkit/components/pictureinpicture/tests/test-video-cropped.mp4bin0 -> 36502 bytes
-rw-r--r--toolkit/components/pictureinpicture/tests/test-video-long.mp4bin0 -> 344085 bytes
-rw-r--r--toolkit/components/pictureinpicture/tests/test-video-selection.html22
-rw-r--r--toolkit/components/pictureinpicture/tests/test-video-vertical.mp4bin0 -> 36502 bytes
-rw-r--r--toolkit/components/pictureinpicture/tests/test-video.mp4bin0 -> 242969 bytes
59 files changed, 5084 insertions, 0 deletions
diff --git a/toolkit/components/pictureinpicture/tests/.eslintrc.js b/toolkit/components/pictureinpicture/tests/.eslintrc.js
new file mode 100644
index 0000000000..1779fd7f1c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/.eslintrc.js
@@ -0,0 +1,5 @@
+"use strict";
+
+module.exports = {
+ extends: ["plugin:mozilla/browser-test"],
+};
diff --git a/toolkit/components/pictureinpicture/tests/browser.ini b/toolkit/components/pictureinpicture/tests/browser.ini
new file mode 100644
index 0000000000..a11f4c7c5b
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser.ini
@@ -0,0 +1,87 @@
+[DEFAULT]
+support-files =
+ click-event-helper.js
+ head.js
+ test-button-overlay.html
+ test-opaque-overlay.html
+ test-page.html
+ test-page-with-iframe.html
+ test-page-with-sound.html
+ test-pointer-events-none.html
+ test-transparent-overlay-1.html
+ test-transparent-overlay-2.html
+ test-video-selection.html
+ test-reversed.html
+ test-media-stream.html
+ test-video.mp4
+ test-video-cropped.mp4
+ test-video-vertical.mp4
+ test-video-long.mp4
+ short.mp4
+ ../../../../dom/media/test/gizmo.mp4
+ ../../../../dom/media/test/owl.mp3
+
+prefs =
+ media.videocontrols.picture-in-picture.enabled=true
+ media.videocontrols.picture-in-picture.allow-multiple=false
+ media.videocontrols.picture-in-picture.video-toggle.enabled=true
+ media.videocontrols.picture-in-picture.video-toggle.testing=true
+ media.videocontrols.picture-in-picture.video-toggle.always-show=true
+ media.videocontrols.picture-in-picture.video-toggle.has-used=true
+
+[browser_cannotTriggerFromContent.js]
+[browser_closePipPause.js]
+[browser_contextMenu.js]
+skip-if = os == "linux" && bits == 64 && os_version == "18.04" # Bug 1569205
+[browser_cornerSnapping.js]
+run-if = os == "mac"
+[browser_closePlayer.js]
+[browser_closeTab.js]
+[browser_dblclickFullscreen.js]
+[browser_flipIconWithRTL.js]
+[browser_mediaStreamVideos.js]
+[browser_durationChange.js]
+[browser_fullscreen.js]
+skip-if = (os == "mac" && debug) || os == "linux" #Bug 1566173, Bug 1664667
+[browser_keyboardShortcut.js]
+[browser_mouseButtonVariation.js]
+skip-if = debug
+[browser_noToggleOnAudio.js]
+[browser_playerControls.js]
+[browser_multiPip.js]
+[browser_removeVideoElement.js]
+[browser_rerequestPiP.js]
+[browser_resizeVideo.js]
+skip-if = os == 'linux' # Bug 1594223
+[browser_reversePiP.js]
+[browser_saveLastPiPLoc.js]
+skip-if =
+ os == "linux" # Bug 1673465
+ os == "win" && bits == 64 && debug # Bug 1683002
+[browser_shortcutsAfterFocus.js]
+skip-if = os == "win" && bits == 64 && debug # Bug 1683002
+[browser_showMessage.js]
+[browser_smallVideoLayout.js]
+skip-if = os == "win" && bits == 64 && debug # Bug 1683002
+[browser_stripVideoStyles.js]
+[browser_tabIconOverlayPiP.js]
+[browser_thirdPartyIframe.js]
+[browser_toggleAfterTabTearOutIn.js]
+skip-if = (os == 'linux' && bits == 64) || (os == 'mac' && !asan && !debug) # Bug 1605546
+[browser_toggleButtonOverlay.js]
+skip-if = true # Bug 1546455
+[browser_toggleMode_2.js]
+skip-if = (os == 'linux') # Bug 1654971
+[browser_toggleOnInsertedVideo.js]
+[browser_toggleOpaqueOverlay.js]
+skip-if = true # Bug 1546455
+[browser_togglePointerEventsNone.js]
+skip-if = true # Bug 1664920, Bug 1628777
+[browser_togglePolicies.js]
+skip-if = os == "linux" && bits == 64 # Bug 1605565
+[browser_toggleSimple.js]
+skip-if = os == 'linux' # Bug 1546455
+[browser_toggleTransparentOverlay-1.js]
+[browser_toggleTransparentOverlay-2.js]
+skip-if = os == 'linux' && (debug || asan) # Bug 1546930
+[browser_videoSelection.js]
diff --git a/toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js b/toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js
new file mode 100644
index 0000000000..4c3d32b474
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js
@@ -0,0 +1,32 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the MozTogglePictureInPicture event is ignored if
+ * fired by unprivileged web content.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ // For now, the easiest way to ensure that this didn't happen is to fail
+ // if we receive the PictureInPicture:Request message.
+ const MESSAGE = "PictureInPicture:Request";
+ let sawMessage = false;
+ let listener = msg => {
+ sawMessage = true;
+ };
+ browser.messageManager.addMessageListener(MESSAGE, listener);
+ await SpecialPowers.spawn(browser, [], async () => {
+ content.wrappedJSObject.fireEvents();
+ });
+ browser.messageManager.removeMessageListener(MESSAGE, listener);
+ ok(!sawMessage, "Got PictureInPicture:Request message unexpectedly.");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_closePipPause.js b/toolkit/components/pictureinpicture/tests/browser_closePipPause.js
new file mode 100644
index 0000000000..b87888e411
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_closePipPause.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test checks that MediaStream videos are not paused when closing
+ * the PiP window.
+ */
+add_task(async function test_close_mediaStreamVideos() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_ROOT + "test-media-stream.html",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ // Construct a new video element, and capture a stream from it
+ // to redirect to both testing videos
+ let newVideo = content.document.createElement("video");
+ newVideo.src = "test-video.mp4";
+ newVideo.id = "media-stream-video";
+ content.document.body.appendChild(newVideo);
+ newVideo.loop = true;
+ });
+ await ensureVideosReady(browser);
+
+ // Modify both the "with-controls" and "no-controls" videos so that they mirror
+ // the new video that we just added via MediaStream.
+ await SpecialPowers.spawn(browser, [], async () => {
+ let newVideo = content.document.getElementById("media-stream-video");
+ newVideo.play();
+
+ for (let videoID of ["with-controls", "no-controls"]) {
+ let testedVideo = content.document.createElement("video");
+ testedVideo.id = videoID;
+ testedVideo.srcObject = newVideo.mozCaptureStream().clone();
+ content.document.body.prepend(testedVideo);
+ if (
+ testedVideo.readyState < content.HTMLMediaElement.HAVE_ENOUGH_DATA
+ ) {
+ info(`Waiting for 'canplaythrough' for '${testedVideo.id}'`);
+ await ContentTaskUtils.waitForEvent(testedVideo, "canplaythrough");
+ }
+ testedVideo.play();
+ }
+ });
+
+ for (let videoID of ["with-controls", "no-controls"]) {
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ ok(
+ !(await isVideoPaused(browser, videoID)),
+ "The video is not paused in PiP window."
+ );
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ ok(
+ !(await isVideoPaused(browser, videoID)),
+ "The video is not paused after closing PiP window."
+ );
+ }
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_closePlayer.js b/toolkit/components/pictureinpicture/tests/browser_closePlayer.js
new file mode 100644
index 0000000000..9b1b0a0047
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_closePlayer.js
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that closing with unpip leaves the video playing but the close button
+ * will pause the video.
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ let playVideo = () => {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).play();
+ });
+ };
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ let browser = tab.linkedBrowser;
+ await playVideo();
+
+ // Try the unpip button.
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused");
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let unpipButton = pipWin.document.getElementById("unpip");
+ EventUtils.synthesizeMouseAtCenter(unpipButton, {}, pipWin);
+ await pipClosed;
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused");
+
+ // Try the close button.
+ pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused");
+
+ pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ ok(await isVideoPaused(browser, videoID), "The video is paused");
+
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_closeTab.js b/toolkit/components/pictureinpicture/tests/browser_closeTab.js
new file mode 100644
index 0000000000..e8c9f59d82
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_closeTab.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that if the tab that's hosting a <video> that's opened in a
+ * Picture-in-Picture window is closed, that the Picture-in-Picture
+ * window is also closed.
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ let browser = tab.linkedBrowser;
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ BrowserTestUtils.removeTab(tab);
+ await pipClosed;
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_contextMenu.js b/toolkit/components/pictureinpicture/tests/browser_contextMenu.js
new file mode 100644
index 0000000000..b762049844
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_contextMenu.js
@@ -0,0 +1,238 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Opens up the content area context menu on a video loaded in a
+ * browser.
+ *
+ * @param {Element} browser The <xul:browser> hosting the <video>
+ *
+ * @param {String} videoID The ID of the video to open the context
+ * menu with.
+ *
+ * @returns Promise
+ * @resolves With the context menu DOM node once opened.
+ */
+async function openContextMenu(browser, videoID) {
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#" + videoID,
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await popupShownPromise;
+ return contextMenu;
+}
+
+/**
+ * Closes the content area context menu.
+ *
+ * @param {Element} contextMenu The content area context menu opened with
+ * openContextMenu.
+ *
+ * @returns Promise
+ * @resolves With undefined
+ */
+async function closeContextMenu(contextMenu) {
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.hidePopup();
+ await popupHiddenPromise;
+}
+
+/**
+ * Tests that Picture-in-Picture can be opened and closed through the
+ * context menu
+ */
+add_task(async () => {
+ for (const videoId of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoId} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await openContextMenu(browser, videoId);
+
+ info("Context menu is open.");
+
+ const pipMenuItemId = "context-video-pictureinpicture";
+ let menuItem = document.getElementById(pipMenuItemId);
+
+ Assert.ok(
+ !menuItem.hidden,
+ "Should show Picture-in-Picture menu item."
+ );
+ Assert.equal(
+ menuItem.getAttribute("checked"),
+ "false",
+ "Picture-in-Picture should be unchecked."
+ );
+
+ await EventUtils.synthesizeMouseAtCenter(menuItem, {});
+
+ await SpecialPowers.spawn(browser, [videoId], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video has started being cloned.");
+ });
+
+ info("PiP player is now open.");
+
+ await openContextMenu(browser, videoId);
+
+ info("Context menu is open again.");
+
+ await EventUtils.synthesizeMouseAtCenter(menuItem, {});
+
+ await SpecialPowers.spawn(browser, [videoId], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return !video.isCloningElementVisually;
+ }, "Video has stopped being cloned.");
+ });
+ }
+ );
+ }
+});
+
+/**
+ * Tests that the Picture-in-Picture context menu is correctly updated
+ * based on the Picture-in-Picture state of the video.
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let menuItem = document.getElementById(
+ "context-video-pictureinpicture"
+ );
+ let menu = await openContextMenu(browser, videoID);
+ Assert.ok(
+ !menuItem.hidden,
+ "Should show Picture-in-Picture menu item."
+ );
+ Assert.equal(
+ menuItem.getAttribute("checked"),
+ "false",
+ "Picture-in-Picture should be unchecked."
+ );
+ await closeContextMenu(menu);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video has started being cloned.");
+ });
+
+ menu = await openContextMenu(browser, videoID);
+ Assert.ok(
+ !menuItem.hidden,
+ "Should show Picture-in-Picture menu item."
+ );
+ Assert.equal(
+ menuItem.getAttribute("checked"),
+ "true",
+ "Picture-in-Picture should be checked."
+ );
+ await closeContextMenu(menu);
+
+ let videoNotCloning = SpecialPowers.spawn(
+ browser,
+ [videoID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return !video.isCloningElementVisually;
+ }, "Video has stopped being cloned.");
+ }
+ );
+ pipWin.close();
+ await videoNotCloning;
+
+ menu = await openContextMenu(browser, videoID);
+ Assert.ok(
+ !menuItem.hidden,
+ "Should show Picture-in-Picture menu item."
+ );
+ Assert.equal(
+ menuItem.getAttribute("checked"),
+ "false",
+ "Picture-in-Picture should be unchecked."
+ );
+ await closeContextMenu(menu);
+
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ // Construct a new video element, and capture a stream from it
+ // to redirect to the video that we're testing.
+ let newVideo = content.document.createElement("video");
+ content.document.body.appendChild(newVideo);
+
+ let testedVideo = content.document.getElementById(videoID);
+ newVideo.src = testedVideo.src;
+
+ testedVideo.srcObject = newVideo.mozCaptureStream();
+ await newVideo.play();
+ await testedVideo.play();
+
+ await newVideo.pause();
+ await testedVideo.pause();
+ });
+ menu = await openContextMenu(browser, videoID);
+ Assert.ok(
+ !menuItem.hidden,
+ "Should be showing Picture-in-Picture menu item."
+ );
+ Assert.equal(
+ menuItem.getAttribute("checked"),
+ "false",
+ "Picture-in-Picture should be unchecked."
+ );
+ await closeContextMenu(menu);
+
+ pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video has started being cloned.");
+ });
+
+ menu = await openContextMenu(browser, videoID);
+ Assert.ok(
+ !menuItem.hidden,
+ "Should show Picture-in-Picture menu item."
+ );
+ Assert.equal(
+ menuItem.getAttribute("checked"),
+ "true",
+ "Picture-in-Picture should be checked."
+ );
+ await closeContextMenu(menu);
+ }
+ );
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js b/toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js
new file mode 100644
index 0000000000..7ebfafc721
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js
@@ -0,0 +1,271 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+const FLOAT_OFFSET = 50;
+const CHANGE_OFFSET = 30;
+const DECREASE_OFFSET = FLOAT_OFFSET - CHANGE_OFFSET;
+const INCREASE_OFFSET = FLOAT_OFFSET + CHANGE_OFFSET;
+/**
+ * This function tests the PiP corner snapping feature.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, "no-controls");
+ let controls = pipWin.document.getElementById("controls");
+
+ /**
+ * pipWin floating in top left corner(quadrant 2), dragged left
+ * should snap into top left corner
+ */
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop + FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin);
+ pipWin.moveTo(
+ pipWin.screen.availLeft + DECREASE_OFFSET,
+ pipWin.screen.availTop + FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent(
+ {
+ type: "mouseup",
+ metaKey: true,
+ },
+ controls,
+ pipWin
+ );
+ Assert.equal(
+ pipWin.screenX,
+ pipWin.screen.availLeft,
+ "Window should be on the left"
+ );
+ Assert.equal(
+ pipWin.screenY,
+ pipWin.screen.availTop,
+ "Window should be on the top"
+ );
+
+ /**
+ * pipWin floating in top left corner(quadrant 2), dragged up
+ * should snap into top left corner
+ */
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop + FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin);
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop + DECREASE_OFFSET
+ );
+ EventUtils.sendMouseEvent(
+ {
+ type: "mouseup",
+ metaKey: true,
+ },
+ controls,
+ pipWin
+ );
+ Assert.equal(
+ pipWin.screenX,
+ pipWin.screen.availLeft,
+ "Window should be on the left"
+ );
+ Assert.equal(
+ pipWin.screenY,
+ pipWin.screen.availTop,
+ "Window should be on the top"
+ );
+
+ /**
+ * pipWin floating in top left corner(quadrant 2), dragged right
+ * should snap into top right corner
+ */
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop + FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin);
+ pipWin.moveTo(
+ pipWin.screen.availLeft + INCREASE_OFFSET,
+ pipWin.screen.availTop + FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent(
+ {
+ type: "mouseup",
+ metaKey: true,
+ },
+ controls,
+ pipWin
+ );
+ Assert.equal(
+ pipWin.screenX,
+ pipWin.screen.availLeft + pipWin.screen.availWidth - pipWin.innerWidth,
+ "Window should be on the right"
+ );
+ Assert.equal(
+ pipWin.screenY,
+ pipWin.screen.availTop,
+ "Window should be on the top"
+ );
+
+ /**
+ * pipWin floating in top left corner(quadrant 2), dragged down
+ * should snap into bottom left corner
+ */
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop + FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin);
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop + INCREASE_OFFSET
+ );
+ EventUtils.sendMouseEvent(
+ {
+ type: "mouseup",
+ metaKey: true,
+ },
+ controls,
+ pipWin
+ );
+ Assert.equal(
+ pipWin.screenX,
+ pipWin.screen.availLeft,
+ "Window should be on the left"
+ );
+ Assert.equal(
+ pipWin.screenY,
+ pipWin.screen.availTop + pipWin.screen.availHeight - pipWin.innerHeight,
+ "Window should be on the bottom"
+ );
+
+ /**
+ * pipWin floating in top right corner(quadrant 1), dragged down
+ * should snap into bottom right corner
+ */
+ pipWin.moveTo(
+ pipWin.screen.availLeft +
+ pipWin.screen.availWidth -
+ pipWin.innerWidth -
+ FLOAT_OFFSET,
+ pipWin.screen.availTop + FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin);
+ pipWin.moveTo(
+ pipWin.screen.availLeft +
+ pipWin.screen.availWidth -
+ pipWin.innerWidth -
+ FLOAT_OFFSET,
+ pipWin.screen.availTop + INCREASE_OFFSET
+ );
+ EventUtils.sendMouseEvent(
+ {
+ type: "mouseup",
+ metaKey: true,
+ },
+ controls,
+ pipWin
+ );
+ Assert.equal(
+ pipWin.screenX,
+ pipWin.screen.availLeft + pipWin.screen.availWidth - pipWin.innerWidth,
+ "Window should be on the right"
+ );
+ Assert.equal(
+ pipWin.screenY,
+ pipWin.screen.availTop + pipWin.screen.availHeight - pipWin.innerHeight,
+ "Window should be on the bottom"
+ );
+
+ /**
+ * pipWin floating in top left corner(quadrant 4), dragged left
+ * should snap into bottom left corner
+ */
+ pipWin.moveTo(
+ pipWin.screen.availLeft +
+ pipWin.screen.availWidth -
+ pipWin.innerWidth -
+ FLOAT_OFFSET,
+ pipWin.screen.availTop +
+ pipWin.screen.availHeight -
+ pipWin.innerHeight -
+ FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin);
+ pipWin.moveTo(
+ pipWin.screen.availLeft +
+ pipWin.screen.availWidth -
+ pipWin.innerWidth -
+ INCREASE_OFFSET,
+ pipWin.screen.availTop +
+ pipWin.screen.availHeight -
+ pipWin.innerHeight -
+ FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent(
+ {
+ type: "mouseup",
+ metaKey: true,
+ },
+ controls,
+ pipWin
+ );
+ Assert.equal(
+ pipWin.screenX,
+ pipWin.screen.availLeft,
+ "Window should be on the left"
+ );
+ Assert.equal(
+ pipWin.screenY,
+ pipWin.screen.availTop + pipWin.screen.availHeight - pipWin.innerHeight,
+ "Window should be on the bottom"
+ );
+
+ /**
+ * pipWin floating in top left corner(quadrant 3), dragged up
+ * should snap into top left corner
+ */
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop +
+ pipWin.screen.availHeight -
+ pipWin.innerHeight -
+ FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin);
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop +
+ pipWin.screen.availHeight -
+ pipWin.innerHeight -
+ INCREASE_OFFSET
+ );
+ EventUtils.sendMouseEvent(
+ {
+ type: "mouseup",
+ metaKey: true,
+ },
+ controls,
+ pipWin
+ );
+ Assert.equal(
+ pipWin.screenX,
+ pipWin.screen.availLeft,
+ "Window should be on the left"
+ );
+ Assert.equal(
+ pipWin.screenY,
+ pipWin.screen.availTop,
+ "Window should be on the top"
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js b/toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js
new file mode 100644
index 0000000000..c915a6f0e7
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that double-clicking on the Picture-in-Picture player window
+ * causes it to fullscreen, and that pressing Escape allows us to exit
+ * fullscreen.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, "no-controls");
+ let controls = pipWin.document.getElementById("controls");
+
+ await promiseFullscreenEntered(pipWin, async () => {
+ await EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "Double-click caused us to enter fullscreen."
+ );
+
+ // First, we'll test exiting fullscreen by double-clicking again
+ // on the document body.
+
+ await promiseFullscreenExited(pipWin, async () => {
+ await EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.ok(
+ !pipWin.document.fullscreenElement,
+ "Double-click caused us to exit fullscreen."
+ );
+
+ // Now we double-click to re-enter fullscreen.
+
+ await promiseFullscreenEntered(pipWin, async () => {
+ await EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "Double-click caused us to re-enter fullscreen."
+ );
+
+ // Finally, we check that hitting Escape lets the user leave
+ // fullscreen.
+
+ await promiseFullscreenExited(pipWin, async () => {
+ EventUtils.synthesizeKey("KEY_Escape", {}, pipWin);
+ });
+
+ Assert.ok(
+ !pipWin.document.fullscreenElement,
+ "Pressing Escape caused us to exit fullscreen."
+ );
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ pipWin.close();
+ await pipClosed;
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_durationChange.js b/toolkit/components/pictureinpicture/tests/browser_durationChange.js
new file mode 100644
index 0000000000..69397d8959
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_durationChange.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the visibility of the toggle will be
+ * recomputed after durationchange events fire.
+ */
+add_task(async function test_durationChange() {
+ // Most of the Picture-in-Picture tests run with the always-show
+ // preference set to true to avoid the toggle visibility heuristics.
+ // Since this test actually exercises those heuristics, we have
+ // to temporarily disable that pref.
+ //
+ // We also reduce the minimum video length for displaying the toggle
+ // to 5 seconds to avoid having to include or generate a 45 second long
+ // video (which is the default minimum length).
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.video-toggle.always-show",
+ false,
+ ],
+ ["media.videocontrols.picture-in-picture.video-toggle.min-video-secs", 5],
+ ],
+ });
+
+ // First, ensure that the toggle doesn't show up for these
+ // short videos by default.
+ await testToggle(TEST_PAGE, {
+ "with-controls": { canToggle: false },
+ "no-controls": { canToggle: false },
+ });
+
+ // Now cause the video to change sources, which should fire a
+ // durationchange event. The longer video should qualify us for
+ // displaying the toggle.
+ await testToggle(
+ TEST_PAGE,
+ {
+ "with-controls": { canToggle: true },
+ "no-controls": { canToggle: true },
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ let video = content.document.getElementById(videoID);
+ video.src = "gizmo.mp4";
+ let durationChangePromise = ContentTaskUtils.waitForEvent(
+ video,
+ "durationchange"
+ );
+
+ video.load();
+ await durationChangePromise;
+ }
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_flipIconWithRTL.js b/toolkit/components/pictureinpicture/tests/browser_flipIconWithRTL.js
new file mode 100644
index 0000000000..cfb91440f5
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_flipIconWithRTL.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * The goal of this test is to check that the icon on the PiP button mirrors
+ * and the explainer text that shows up before the first time PiP is used
+ * right aligns when the browser is set to a RtL mode
+ *
+ * The browser will create a tab and open a video using PiP
+ * then the tests check that the components change their appearance accordingly
+ *
+ */
+
+/**
+ * This test ensures that the default ltr is working as intended
+ */
+add_task(async function test_ltr_toggle() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ for (let videoId of ["with-controls", "no-controls"]) {
+ let localeDir = await SpecialPowers.spawn(browser, [videoId], id => {
+ let video = content.document.getElementById(id);
+ let videocontrols = video.openOrClosedShadowRoot.firstChild;
+ return videocontrols.getAttribute("localedir");
+ });
+
+ Assert.equal(localeDir, "ltr", "Got the right localedir");
+ }
+ }
+ );
+});
+
+/**
+ * This test ensures that the components flip correctly when rtl is set
+ */
+add_task(async function test_rtl_toggle() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["intl.l10n.pseudo", "bidi"]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ for (let videoId of ["with-controls", "no-controls"]) {
+ let localeDir = await SpecialPowers.spawn(browser, [videoId], id => {
+ let video = content.document.getElementById(id);
+ let videocontrols = video.openOrClosedShadowRoot.firstChild;
+ return videocontrols.getAttribute("localedir");
+ });
+
+ Assert.equal(localeDir, "rtl", "Got the right localedir");
+ }
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_fullscreen.js b/toolkit/components/pictureinpicture/tests/browser_fullscreen.js
new file mode 100644
index 0000000000..5f80c56307
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_fullscreen.js
@@ -0,0 +1,142 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const VIDEOS = ["with-controls", "no-controls"];
+
+/**
+ * Tests that the Picture-in-Picture toggle is hidden when
+ * a video with or without controls is made fullscreen.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ for (let videoID of VIDEOS) {
+ await promiseFullscreenEntered(window, async () => {
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = this.content.document.getElementById(videoID);
+ video.requestFullscreen();
+ });
+ });
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ let args = { videoID, toggleID: DEFAULT_TOGGLE_STYLES.rootID };
+
+ await promiseFullscreenExited(window, async () => {
+ await SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID, toggleID } = args;
+ let video = this.content.document.getElementById(videoID);
+ let toggle = video.openOrClosedShadowRoot.getElementById(toggleID);
+ ok(
+ ContentTaskUtils.is_hidden(toggle),
+ "Toggle should be hidden in fullscreen mode."
+ );
+ this.content.document.exitFullscreen();
+ });
+ });
+ }
+ }
+ );
+});
+
+/**
+ * Tests that the Picture-in-Picture toggle is hidden if an
+ * ancestor of a video (in this case, the document body) is made
+ * to be the fullscreen element.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await promiseFullscreenEntered(window, async () => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ this.content.document.body.requestFullscreen();
+ });
+ });
+
+ for (let videoID of VIDEOS) {
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ let args = { videoID, toggleID: DEFAULT_TOGGLE_STYLES.rootID };
+
+ await SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID, toggleID } = args;
+ let video = this.content.document.getElementById(videoID);
+ let toggle = video.openOrClosedShadowRoot.getElementById(toggleID);
+ ok(
+ ContentTaskUtils.is_hidden(toggle),
+ "Toggle should be hidden in fullscreen mode."
+ );
+ });
+ }
+
+ await promiseFullscreenExited(window, async () => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ this.content.document.exitFullscreen();
+ });
+ });
+ }
+ );
+});
+
+/**
+ * Tests that the Picture-In-Picture window is closed when something
+ * is fullscreened
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ for (let videoId of VIDEOS) {
+ let pipWin = await triggerPictureInPicture(browser, videoId);
+ ok(pipWin, "Got Picture-In-Picture window.");
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+
+ // need to focus first, since fullscreen request will be blocked otherwise
+ await SimpleTest.promiseFocus(window);
+
+ await promiseFullscreenEntered(window, async () => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ this.content.document.body.requestFullscreen();
+ });
+ });
+
+ await pipClosed;
+ ok(pipWin.closed, "Picture-In-Picture successfully closed.");
+
+ await promiseFullscreenExited(window, async () => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ this.content.document.exitFullscreen();
+ });
+ });
+ }
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js
new file mode 100644
index 0000000000..b2f83205fa
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that if the user keys in the keyboard shortcut for
+ * Picture-in-Picture, then the first video on the currently
+ * focused page will be opened in the player window.
+ */
+add_task(async function test_pip_keyboard_shortcut() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ // In test-page.html, the "with-controls" video is the first one that
+ // appears in the DOM, so this is what we expect to open via the keyboard
+ // shortcut.
+ const VIDEO_ID = "with-controls";
+
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ let videoReady = SpecialPowers.spawn(
+ browser,
+ [VIDEO_ID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ }
+ );
+
+ if (AppConstants.platform == "macosx") {
+ EventUtils.synthesizeKey("]", {
+ accelKey: true,
+ shiftKey: true,
+ altKey: true,
+ });
+ } else {
+ EventUtils.synthesizeKey("]", { accelKey: true, shiftKey: true });
+ }
+
+ let pipWin = await domWindowOpened;
+ await videoReady;
+
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await ensureMessageAndClosePiP(browser, VIDEO_ID, pipWin, false);
+
+ // Reopen PiP Window
+ pipWin = await triggerPictureInPicture(browser, VIDEO_ID);
+ await videoReady;
+
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ if (AppConstants.platform == "macosx") {
+ EventUtils.synthesizeKey(
+ "]",
+ {
+ accelKey: true,
+ shiftKey: true,
+ altKey: true,
+ },
+ pipWin
+ );
+ } else {
+ EventUtils.synthesizeKey(
+ "]",
+ { accelKey: true, shiftKey: true },
+ pipWin
+ );
+ }
+
+ await BrowserTestUtils.windowClosed(pipWin);
+
+ ok(pipWin.closed, "Picture-in-Picture window closed.");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_mediaStreamVideos.js b/toolkit/components/pictureinpicture/tests/browser_mediaStreamVideos.js
new file mode 100644
index 0000000000..46b36a3a6e
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_mediaStreamVideos.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test checks that the media stream video format has functional
+ * support for PiP
+ */
+add_task(async function test_mediaStreamVideos() {
+ await testToggle(
+ TEST_ROOT + "test-media-stream.html",
+ {
+ "with-controls": { canToggle: true },
+ "no-controls": { canToggle: true },
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ // Construct a new video element, and capture a stream from it
+ // to redirect to both testing videos. Create the captureStreams after
+ // we have metadata so tracks are immediately available, but wait with
+ // playback until the setup is done.
+
+ function logEvent(element, ev) {
+ element.addEventListener(ev, () =>
+ info(
+ `${element.id} got event ${ev}. currentTime=${element.currentTime}`
+ )
+ );
+ }
+
+ const newVideo = content.document.createElement("video");
+ newVideo.id = "new-video";
+ newVideo.src = "test-video.mp4";
+ newVideo.preload = "auto";
+ logEvent(newVideo, "timeupdate");
+ logEvent(newVideo, "ended");
+ content.document.body.appendChild(newVideo);
+ await ContentTaskUtils.waitForEvent(newVideo, "loadedmetadata");
+
+ const mediastreamPlayingPromises = [];
+ for (let videoID of ["with-controls", "no-controls"]) {
+ const testedVideo = content.document.createElement("video");
+ testedVideo.id = videoID;
+ testedVideo.srcObject = newVideo.mozCaptureStream();
+ testedVideo.play();
+ mediastreamPlayingPromises.push(
+ new Promise(r => (testedVideo.onplaying = r))
+ );
+ content.document.body.prepend(testedVideo);
+ }
+
+ await newVideo.play();
+ await Promise.all(mediastreamPlayingPromises);
+ newVideo.pause();
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_mouseButtonVariation.js b/toolkit/components/pictureinpicture/tests/browser_mouseButtonVariation.js
new file mode 100644
index 0000000000..f424093215
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_mouseButtonVariation.js
@@ -0,0 +1,97 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that if the user mousedown's on a Picture-in-Picture toggle,
+ * but then mouseup's on something completely different, that we still
+ * open a Picture-in-Picture window, and that the mouse button events are
+ * all suppressed. Also ensures that a subsequent click on the document
+ * body results in all mouse button events firing normally.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+ let videoID = "no-controls";
+
+ await prepareForToggleClick(browser, videoID);
+
+ // Hover the mouse over the video to reveal the toggle, which is necessary
+ // if we want to click on the toggle.
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mousemove",
+ },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ info("Waiting for toggle to become visible");
+ await toggleOpacityReachesThreshold(browser, videoID, "hoverVideo");
+
+ let toggleClientRect = await getToggleClientRect(browser, videoID);
+
+ // The toggle center, because of how it slides out, is actually outside
+ // of the bounds of a click event. For now, we move the mouse in by a
+ // hard-coded 15 pixels along the x and y axis to achieve the hover.
+ let toggleLeft = toggleClientRect.left + 15;
+ let toggleTop = toggleClientRect.top + 15;
+
+ info(
+ "Clicking on toggle, and expecting a Picture-in-Picture window to open"
+ );
+ // We need to wait for the window to have completed loading before we
+ // can close it as the document's type required by closeWindow may not
+ // be available.
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleLeft,
+ toggleTop,
+ {
+ type: "mousedown",
+ },
+ browser
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ 1,
+ 1,
+ {
+ type: "mouseup",
+ },
+ browser
+ );
+
+ let win = await domWindowOpened;
+ ok(win, "A Picture-in-Picture window opened.");
+
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ });
+
+ await BrowserTestUtils.closeWindow(win);
+ await assertSawMouseEvents(browser, false);
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(1, 1, {}, browser);
+ await assertSawMouseEvents(browser, true);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_multiPip.js b/toolkit/components/pictureinpicture/tests/browser_multiPip.js
new file mode 100644
index 0000000000..f4f48fc7a6
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_multiPip.js
@@ -0,0 +1,232 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function createTab() {
+ return BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PAGE,
+ waitForLoad: true,
+ });
+}
+
+function getTelemetryMaxPipCount(resetMax = false) {
+ const scalarData = Services.telemetry.getSnapshotForScalars("main", resetMax)
+ .parent;
+ return scalarData["pictureinpicture.most_concurrent_players"];
+}
+
+/**
+ * Set pref for multiple PiP support first
+ */
+add_task(async () => {
+ return SpecialPowers.pushPrefEnv({
+ set: [["media.videocontrols.picture-in-picture.allow-multiple", true]],
+ });
+});
+
+/**
+ * Tests that multiple PiPs can be opened and closed in a single tab
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let firstPip = await triggerPictureInPicture(browser, "with-controls");
+ ok(firstPip, "Got first PiP window");
+
+ let secondPip = await triggerPictureInPicture(browser, "no-controls");
+ ok(secondPip, "Got second PiP window");
+
+ await ensureMessageAndClosePiP(browser, "with-controls", firstPip, false);
+ info("First PiP was still open and is now closed");
+
+ await ensureMessageAndClosePiP(browser, "no-controls", secondPip, false);
+ info("Second PiP was still open and is now closed");
+ }
+ );
+});
+
+/**
+ * Tests that multiple PiPs can be opened and closed across different tabs
+ */
+add_task(async () => {
+ let firstTab = await createTab();
+ let secondTab = await createTab();
+
+ gBrowser.selectedTab = firstTab;
+
+ let firstPip = await triggerPictureInPicture(
+ firstTab.linkedBrowser,
+ "with-controls"
+ );
+ ok(firstPip, "Got first PiP window");
+
+ gBrowser.selectedTab = secondTab;
+
+ let secondPip = await triggerPictureInPicture(
+ secondTab.linkedBrowser,
+ "with-controls"
+ );
+ ok(secondPip, "Got second PiP window");
+
+ await ensureMessageAndClosePiP(
+ firstTab.linkedBrowser,
+ "with-controls",
+ firstPip,
+ false
+ );
+ info("First Picture-in-Picture window was open and is now closed.");
+
+ await ensureMessageAndClosePiP(
+ secondTab.linkedBrowser,
+ "with-controls",
+ secondPip,
+ false
+ );
+ info("Second Picture-in-Picture window was open and is now closed.");
+
+ BrowserTestUtils.removeTab(firstTab);
+ BrowserTestUtils.removeTab(secondTab);
+});
+
+/**
+ * Tests that when a tab is closed; that only PiPs originating from this tab
+ * are closed as well
+ */
+add_task(async () => {
+ let firstTab = await createTab();
+ let secondTab = await createTab();
+
+ let firstPip = await triggerPictureInPicture(
+ firstTab.linkedBrowser,
+ "with-controls"
+ );
+ ok(firstPip, "Got first PiP window");
+
+ let secondPip = await triggerPictureInPicture(
+ secondTab.linkedBrowser,
+ "with-controls"
+ );
+ ok(secondPip, "Got second PiP window");
+
+ let firstClosed = BrowserTestUtils.domWindowClosed(firstPip);
+ BrowserTestUtils.removeTab(firstTab);
+ await firstClosed;
+ info("First PiP closed after closing the first tab");
+
+ await assertVideoIsBeingCloned(secondTab.linkedBrowser, "with-controls");
+ info("Second PiP is still open after first tab close");
+
+ let secondClosed = BrowserTestUtils.domWindowClosed(secondPip);
+ BrowserTestUtils.removeTab(secondTab);
+ await secondClosed;
+ info("Second PiP closed after closing the second tab");
+});
+
+/**
+ * Check that correct number of pip players are recorded for Telemetry
+ * tracking
+ */
+add_task(async () => {
+ // run this to flush recorded values from previous tests
+ getTelemetryMaxPipCount(true);
+
+ let firstTab = await createTab();
+ let secondTab = await createTab();
+
+ gBrowser.selectedTab = firstTab;
+
+ let firstPip = await triggerPictureInPicture(
+ firstTab.linkedBrowser,
+ "with-controls"
+ );
+ ok(firstPip, "Got first PiP window");
+
+ Assert.equal(
+ getTelemetryMaxPipCount(true),
+ 1,
+ "There should only be 1 PiP window"
+ );
+
+ let secondPip = await triggerPictureInPicture(
+ firstTab.linkedBrowser,
+ "no-controls"
+ );
+ ok(secondPip, "Got second PiP window");
+
+ Assert.equal(
+ getTelemetryMaxPipCount(true),
+ 2,
+ "There should be 2 PiP windows"
+ );
+
+ await ensureMessageAndClosePiP(
+ firstTab.linkedBrowser,
+ "no-controls",
+ secondPip,
+ false
+ );
+ info("Second PiP was open and is now closed");
+
+ gBrowser.selectedTab = secondTab;
+
+ let thirdPip = await triggerPictureInPicture(
+ secondTab.linkedBrowser,
+ "with-controls"
+ );
+ ok(thirdPip, "Got third PiP window");
+
+ let fourthPip = await triggerPictureInPicture(
+ secondTab.linkedBrowser,
+ "no-controls"
+ );
+ ok(fourthPip, "Got fourth PiP window");
+
+ Assert.equal(
+ getTelemetryMaxPipCount(false),
+ 3,
+ "There should now be 3 PiP windows"
+ );
+
+ gBrowser.selectedTab = firstTab;
+
+ await ensureMessageAndClosePiP(
+ firstTab.linkedBrowser,
+ "with-controls",
+ firstPip,
+ false
+ );
+ info("First PiP was open, it is now closed.");
+
+ gBrowser.selectedTab = secondTab;
+
+ await ensureMessageAndClosePiP(
+ secondTab.linkedBrowser,
+ "with-controls",
+ thirdPip,
+ false
+ );
+ info("Third PiP was open, it is now closed.");
+
+ await ensureMessageAndClosePiP(
+ secondTab.linkedBrowser,
+ "no-controls",
+ fourthPip,
+ false
+ );
+ info("Fourth PiP was open, it is now closed.");
+
+ Assert.equal(
+ getTelemetryMaxPipCount(false),
+ 3,
+ "Max PiP count should still be 3"
+ );
+
+ BrowserTestUtils.removeTab(firstTab);
+ BrowserTestUtils.removeTab(secondTab);
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js b/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js
new file mode 100644
index 0000000000..0d16bdc9e2
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that if a <video> element only has audio, and no video
+ * frames, that we do not show the toggle.
+ */
+add_task(async function test_no_toggle_on_audio() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_ROOT + "owl.mp3",
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ await SimpleTest.promiseFocus(browser);
+
+ // The media player document we create for owl.mp3 inserts a <video>
+ // element pointed at the .mp3 file, which is what we're trying to
+ // test for. The <video> element does not get an ID created for it
+ // though, so we sneak one in with SpecialPowers.spawn so that we
+ // can use testToggleHelper (which requires an ID).
+ //
+ // testToggleHelper also wants click-event-helper.js loaded in the
+ // document, so we insert that too.
+ const VIDEO_ID = "video-element";
+ const SCRIPT_SRC = "click-event-helper.js";
+ await SpecialPowers.spawn(browser, [VIDEO_ID, SCRIPT_SRC], async function(
+ videoID,
+ scriptSrc
+ ) {
+ let video = content.document.querySelector("video");
+ video.id = videoID;
+
+ let script = content.document.createElement("script");
+ script.src = scriptSrc;
+ content.document.head.appendChild(script);
+ });
+
+ await testToggleHelper(browser, VIDEO_ID, false);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_playerControls.js b/toolkit/components/pictureinpicture/tests/browser_playerControls.js
new file mode 100644
index 0000000000..a2684dd451
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_playerControls.js
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests functionality of the various controls for the Picture-in-Picture
+ * video window.
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.videocontrols.picture-in-picture.audio-toggle.enabled", true],
+ ],
+ });
+ let videoID = "with-controls";
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let isVideoMuted = () => {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).muted;
+ });
+ };
+ let waitForVideoEvent = eventType => {
+ return BrowserTestUtils.waitForContentEvent(browser, eventType, true);
+ };
+
+ await ensureVideosReady(browser);
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ await content.document.getElementById(videoID).play();
+ });
+
+ // Open the video in PiP
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused.");
+
+ let playPause = pipWin.document.getElementById("playpause");
+ let audioButton = pipWin.document.getElementById("audio");
+
+ // Try the pause button
+ let pausedPromise = waitForVideoEvent("pause");
+ EventUtils.synthesizeMouseAtCenter(playPause, {}, pipWin);
+ await pausedPromise;
+ ok(await isVideoPaused(browser, videoID), "The video is paused.");
+
+ // Try the play button
+ let playPromise = waitForVideoEvent("play");
+ EventUtils.synthesizeMouseAtCenter(playPause, {}, pipWin);
+ await playPromise;
+ ok(!(await isVideoPaused(browser, videoID)), "The video is playing.");
+
+ // Try the mute button
+ let mutedPromise = waitForVideoEvent("volumechange");
+ ok(!(await isVideoMuted()), "The audio is playing.");
+ EventUtils.synthesizeMouseAtCenter(audioButton, {}, pipWin);
+ await mutedPromise;
+ ok(await isVideoMuted(), "The audio is muted.");
+
+ // Try the unmute button
+ let unmutedPromise = waitForVideoEvent("volumechange");
+ EventUtils.synthesizeMouseAtCenter(audioButton, {}, pipWin);
+ await unmutedPromise;
+ ok(!(await isVideoMuted()), "The audio is playing.");
+
+ // Try the unpip button.
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let unpipButton = pipWin.document.getElementById("unpip");
+ EventUtils.synthesizeMouseAtCenter(unpipButton, {}, pipWin);
+ await pipClosed;
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused.");
+
+ // Try the close button.
+ pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused.");
+
+ pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ ok(await isVideoPaused(browser, videoID), "The video is paused.");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js b/toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js
new file mode 100644
index 0000000000..d9622dc3f0
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that if a <video> element is being displayed in a
+ * Picture-in-Picture window, that the window closes if that
+ * original <video> is ever removed from the DOM.
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ Assert.ok(pipWin, "Got PiP window.");
+
+ // First, let's make sure that removing the _other_ video doesn't cause
+ // the special event to fire, nor the PiP window to close.
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let doc = content.document;
+ let otherVideo = doc.querySelector(`video:not([id="${videoID}"])`);
+ let eventFired = false;
+
+ let listener = e => {
+ eventFired = true;
+ };
+
+ docShell.chromeEventHandler.addEventListener(
+ "MozStopPictureInPicture",
+ listener,
+ {
+ capture: true,
+ }
+ );
+ otherVideo.remove();
+ Assert.ok(
+ !eventFired,
+ "Should not have seen MozStopPictureInPicture for other video"
+ );
+ docShell.chromeEventHandler.removeEventListener(
+ "MozStopPictureInPicture",
+ listener,
+ {
+ capture: true,
+ }
+ );
+ });
+
+ Assert.ok(!pipWin.closed, "PiP window should still be open.");
+
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let doc = content.document;
+ let video = doc.querySelector(`#${videoID}`);
+
+ let promise = ContentTaskUtils.waitForEvent(
+ docShell.chromeEventHandler,
+ "MozStopPictureInPicture",
+ { capture: true }
+ );
+ video.remove();
+ await promise;
+ });
+
+ try {
+ await BrowserTestUtils.waitForCondition(
+ () => pipWin.closed,
+ "Player window closed."
+ );
+ } finally {
+ if (!pipWin.closed) {
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ }
+ }
+ );
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_rerequestPiP.js b/toolkit/components/pictureinpicture/tests/browser_rerequestPiP.js
new file mode 100644
index 0000000000..27d2861bca
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_rerequestPiP.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that if a pre-existing Picture-in-Picture window exists, and a
+ * different video is requested to open in Picture-in-Picture, that the
+ * original Picture-in-Picture window closes and a new one is opened.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let pipWin2 = await triggerPictureInPicture(browser, "no-controls");
+ await pipClosed;
+ ok(true, "Original Picture-in-Picture window closed.");
+
+ pipClosed = BrowserTestUtils.domWindowClosed(pipWin2);
+ pipWin2.close();
+ await pipClosed;
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js b/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js
new file mode 100644
index 0000000000..917bceb9ac
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js
@@ -0,0 +1,263 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Run the resize test on a player window.
+ *
+ * @param browser (xul:browser)
+ * The browser that has the source video.
+ *
+ * @param videoID (string)
+ * The id of the video in the browser to test.
+ *
+ * @param pipWin (player window)
+ * A player window to run the tests on.
+ *
+ * @param opts (object)
+ * The options for the test.
+ *
+ * pinX (boolean):
+ * If true, the video's X position shouldn't change when resized.
+ *
+ * pinY (boolean):
+ * If true, the video's Y position shouldn't change when resized.
+ */
+async function testVideo(browser, videoID, pipWin, { pinX, pinY } = {}) {
+ async function switchVideoSource(src) {
+ let videoResized = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ await ContentTask.spawn(
+ browser,
+ { src, videoID },
+ async ({ src, videoID }) => {
+ let doc = content.document;
+ let video = doc.getElementById(videoID);
+ video.src = src;
+ }
+ );
+ await videoResized;
+ }
+
+ /**
+ * Check the new screen position against the previous one. When
+ * pinX or pinY is true then the top left corner is checked in that
+ * dimension. Otherwise, the bottom right corner is checked.
+ *
+ * The video position is determined by the screen edge it's closest
+ * to, so in the default bottom right its bottom right corner should
+ * match the previous video's bottom right corner. For the top left,
+ * the top left corners should match.
+ */
+ function checkPosition(
+ previousScreenX,
+ previousScreenY,
+ previousWidth,
+ previousHeight,
+ newScreenX,
+ newScreenY,
+ newWidth,
+ newHeight
+ ) {
+ if (pinX || previousScreenX == 0) {
+ Assert.equal(
+ previousScreenX,
+ newScreenX,
+ "New video is still in the same X position"
+ );
+ } else {
+ Assert.less(
+ Math.abs(previousScreenX + previousWidth - (newScreenX + newWidth)),
+ 2,
+ "New video ends at the same screen X position (within 1 pixel)"
+ );
+ }
+ if (pinY) {
+ Assert.equal(
+ previousScreenY,
+ newScreenY,
+ "New video is still in the same Y position"
+ );
+ } else {
+ Assert.equal(
+ previousScreenY + previousHeight,
+ newScreenY + newHeight,
+ "New video ends at the same screen Y position"
+ );
+ }
+ }
+
+ Assert.ok(pipWin, "Got PiP window.");
+
+ let initialWidth = pipWin.innerWidth;
+ let initialHeight = pipWin.innerHeight;
+ let initialAspectRatio = initialWidth / initialHeight;
+ Assert.equal(
+ Math.floor(initialAspectRatio * 100),
+ 177, // 16 / 9 = 1.777777777
+ "Original aspect ratio is 16:9"
+ );
+
+ // Store the window position for later.
+ let initialScreenX = pipWin.mozInnerScreenX;
+ let initialScreenY = pipWin.mozInnerScreenY;
+
+ await switchVideoSource("test-video-cropped.mp4");
+
+ let resizedWidth = pipWin.innerWidth;
+ let resizedHeight = pipWin.innerHeight;
+ let resizedAspectRatio = resizedWidth / resizedHeight;
+ Assert.equal(
+ Math.floor(resizedAspectRatio * 100),
+ 133, // 4 / 3 = 1.333333333
+ "Resized aspect ratio is 4:3"
+ );
+ Assert.less(resizedWidth, initialWidth, "Resized video has smaller width");
+ Assert.equal(
+ resizedHeight,
+ initialHeight,
+ "Resized video is the same vertically"
+ );
+
+ let resizedScreenX = pipWin.mozInnerScreenX;
+ let resizedScreenY = pipWin.mozInnerScreenY;
+ checkPosition(
+ initialScreenX,
+ initialScreenY,
+ initialWidth,
+ initialHeight,
+ resizedScreenX,
+ resizedScreenY,
+ resizedWidth,
+ resizedHeight
+ );
+
+ await switchVideoSource("test-video-vertical.mp4");
+
+ let verticalWidth = pipWin.innerWidth;
+ let verticalHeight = pipWin.innerHeight;
+ let verticalAspectRatio = verticalWidth / verticalHeight;
+
+ if (verticalWidth == 136) {
+ // The video is minimun width allowed
+ Assert.equal(
+ Math.floor(verticalAspectRatio * 100),
+ 56, // 1 / 2 = 0.5
+ "Vertical aspect ratio is 1:2"
+ );
+ } else {
+ Assert.equal(
+ Math.floor(verticalAspectRatio * 100),
+ 50, // 1 / 2 = 0.5
+ "Vertical aspect ratio is 1:2"
+ );
+ }
+
+ Assert.less(verticalWidth, resizedWidth, "Vertical video width shrunk");
+ Assert.equal(
+ verticalHeight,
+ initialHeight,
+ "Vertical video height matches previous height"
+ );
+
+ let verticalScreenX = pipWin.mozInnerScreenX;
+ let verticalScreenY = pipWin.mozInnerScreenY;
+ checkPosition(
+ resizedScreenX,
+ resizedScreenY,
+ resizedWidth,
+ resizedHeight,
+ verticalScreenX,
+ verticalScreenY,
+ verticalWidth,
+ verticalHeight
+ );
+
+ await switchVideoSource("test-video.mp4");
+
+ let restoredWidth = pipWin.innerWidth;
+ let restoredHeight = pipWin.innerHeight;
+ let restoredAspectRatio = restoredWidth / restoredHeight;
+ Assert.equal(
+ Math.floor(restoredAspectRatio * 100),
+ 177,
+ "Restored aspect ratio is still 16:9"
+ );
+ Assert.less(
+ Math.abs(initialWidth - pipWin.innerWidth),
+ 2,
+ "Restored video has its original width"
+ );
+ Assert.equal(
+ initialHeight,
+ pipWin.innerHeight,
+ "Restored video has its original height"
+ );
+
+ let restoredScreenX = pipWin.mozInnerScreenX;
+ let restoredScreenY = pipWin.mozInnerScreenY;
+ checkPosition(
+ initialScreenX,
+ initialScreenY,
+ initialWidth,
+ initialHeight,
+ restoredScreenX,
+ restoredScreenY,
+ restoredWidth,
+ restoredHeight
+ );
+}
+
+/**
+ * Tests that if a <video> element is resized the Picture-in-Picture window
+ * will be resized to match the new dimensions.
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+
+ await testVideo(browser, videoID, pipWin);
+
+ pipWin.moveTo(0, 0);
+
+ await testVideo(browser, videoID, pipWin, { pinX: true, pinY: true });
+
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ );
+ }
+});
+
+/**
+ * Tests that the RTL video starts on the left and is pinned in the X dimension.
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({ set: [["intl.l10n.pseudo", "bidi"]] });
+
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+
+ await testVideo(browser, videoID, pipWin, { pinX: true });
+
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ );
+ }
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_reversePiP.js b/toolkit/components/pictureinpicture/tests/browser_reversePiP.js
new file mode 100644
index 0000000000..a8ae20166f
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_reversePiP.js
@@ -0,0 +1,145 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the PiP toggle button is not flipped
+ * on certain websites (such as whereby.com).
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_ROOT + "test-reversed.html",
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ let videoID = "reversed";
+
+ // Test the toggle button
+ 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
+ );
+
+ let toggleFlippedAttribute = await SpecialPowers.spawn(
+ browser,
+ [videoID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return controlsOverlay.classList.contains("hovering");
+ }, "Waiting for the hovering state to be set on the video.");
+
+ return shadowRoot.firstChild.getAttribute("flipped");
+ }
+ );
+
+ // The "flipped" attribute should be set on the toggle button (when applicable).
+ Assert.equal(toggleFlippedAttribute, "true");
+ }
+ );
+});
+
+/**
+ * Tests that the "This video is playing in Picture-in-Picture" message
+ * as well as the video playing in PiP are both not flipped on certain sites
+ * (such as whereby.com)
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_ROOT + "test-reversed.html",
+ },
+ async browser => {
+ /**
+ * A helper function used to get the "flipped" attribute of the video's shadowRoot's first child.
+ * @param {Element} browser The <xul:browser> hosting the <video>
+ * @param {String} videoID The ID of the video being checked
+ */
+ async function getFlippedAttribute(browser, videoID) {
+ let videoFlippedAttribute = await SpecialPowers.spawn(
+ browser,
+ [videoID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ return shadowRoot.firstChild.getAttribute("flipped");
+ }
+ );
+ return videoFlippedAttribute;
+ }
+
+ /**
+ * A helper function that returns the transform.a of the video being played in PiP.
+ * @param {Element} playerBrowser The <xul:browser> of the PiP window
+ */
+ async function getPiPVideoTransform(playerBrowser) {
+ let pipVideoTransform = await SpecialPowers.spawn(
+ playerBrowser,
+ [],
+ async () => {
+ let video = content.document.querySelector("video");
+ return video.getTransformToViewport().a;
+ }
+ );
+ return pipVideoTransform;
+ }
+
+ await ensureVideosReady(browser);
+
+ let videoID = "reversed";
+
+ let videoFlippedAttribute = await getFlippedAttribute(browser, videoID);
+ Assert.equal(videoFlippedAttribute, null); // The "flipped" attribute should not be set initially.
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+
+ videoFlippedAttribute = await getFlippedAttribute(browser, "reversed");
+ Assert.equal(videoFlippedAttribute, "true"); // The "flipped" value should be set once the PiP window is opened (when applicable).
+
+ let playerBrowser = pipWin.document.getElementById("browser");
+ let pipVideoTransform = await getPiPVideoTransform(playerBrowser);
+ Assert.equal(pipVideoTransform, -1);
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+
+ videoFlippedAttribute = await getFlippedAttribute(browser, "reversed");
+ Assert.equal(videoFlippedAttribute, null); // The "flipped" attribute should be removed after closing PiP.
+
+ // Now we want to test that regular (not-reversed) videos are unaffected
+ videoID = "not-reversed";
+ videoFlippedAttribute = await getFlippedAttribute(browser, videoID);
+ Assert.equal(videoFlippedAttribute, null);
+
+ pipWin = await triggerPictureInPicture(browser, videoID);
+
+ videoFlippedAttribute = await getFlippedAttribute(browser, videoID);
+ Assert.equal(videoFlippedAttribute, null);
+
+ playerBrowser = pipWin.document.getElementById("browser");
+ pipVideoTransform = await getPiPVideoTransform(playerBrowser);
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js b/toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js
new file mode 100644
index 0000000000..fbdf7fb456
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js
@@ -0,0 +1,362 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This function tests that the browser saves the last location of size of
+ * the PiP window and will open the next PiP window in the same location
+ * with the size. It adjusts for aspect ratio by keeping the same height and
+ * adjusting the width of the PiP window.
+ */
+
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ // Function to switch video source.
+ async browser => {
+ async function switchVideoSource(src) {
+ let videoResized = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ await ContentTask.spawn(browser, { src }, async ({ src }) => {
+ let doc = content.document;
+ let video = doc.getElementById("with-controls");
+ video.src = src;
+ });
+ await videoResized;
+ }
+ // This function is used because the rounding of the width can be off
+ // by about 1 pixel sometimes so this checks that val1 and val2 are
+ // within 1 pixel
+ function checkIfEqual(val1, val2, str) {
+ let equal = Math.abs(val1 - val2);
+ if (equal <= 1) {
+ is(equal <= 1, true, str);
+ } else {
+ is(val1, val2, str);
+ }
+ }
+
+ // Used for clearing the size and location of the PiP window
+ const PLAYER_URI =
+ "chrome://global/content/pictureinpicture/player.xhtml";
+
+ // The PiP window now stores information between tests and needs to be
+ // cleared before the test begins
+ function clearSaved() {
+ 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 getAvailScreenSize(screen) {
+ let screenLeft = {},
+ screenTop = {},
+ screenWidth = {},
+ screenHeight = {};
+ screen.GetAvailRectDisplayPix(
+ screenLeft,
+ screenTop,
+ screenWidth,
+ screenHeight
+ );
+ let fullLeft = {},
+ fullTop = {},
+ fullWidth = {},
+ fullHeight = {};
+ screen.GetRectDisplayPix(fullLeft, fullTop, fullWidth, fullHeight);
+
+ // We have to divide these dimensions by the CSS scale factor for the
+ // display in order for the video to be positioned correctly on displays
+ // that are not at a 1.0 scaling.
+ let scaleFactor =
+ screen.contentsScaleFactor / screen.defaultCSSScaleFactor;
+ screenWidth.value *= scaleFactor;
+ screenHeight.value *= scaleFactor;
+ screenLeft.value =
+ (screenLeft.value - fullLeft.value) * scaleFactor + fullLeft.value;
+ screenTop.value =
+ (screenTop.value - fullTop.value) * scaleFactor + fullTop.value;
+
+ return [
+ screenLeft.value,
+ screenTop.value,
+ screenWidth.value,
+ screenHeight.value,
+ ];
+ }
+
+ let screen = Cc["@mozilla.org/gfx/screenmanager;1"]
+ .getService(Ci.nsIScreenManager)
+ .screenForRect(1, 1, 1, 1);
+
+ let [
+ defaultX,
+ defaultY,
+ defaultWidth,
+ defaultHeight,
+ ] = getAvailScreenSize(screen);
+
+ // Default size of PiP window
+ let rightEdge = defaultX + defaultWidth;
+ let bottomEdge = defaultY + defaultHeight;
+
+ // tab height
+ // Used only for Linux as the PiP window has a tab
+ let tabHeight = 35;
+
+ // clear already saved information
+ clearSaved();
+
+ // Open PiP
+ let pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let defaultPiPWidth = pipWin.innerWidth;
+ let defaultPiPHeight = pipWin.innerHeight;
+
+ // Check that it is opened at default location
+ checkIfEqual(
+ pipWin.screenX,
+ rightEdge - defaultPiPWidth,
+ "Default PiP X location"
+ );
+ if (AppConstants.platform == "linux") {
+ checkIfEqual(
+ pipWin.screenY,
+ bottomEdge - defaultPiPHeight - tabHeight,
+ "Default PiP Y location"
+ );
+ } else {
+ checkIfEqual(
+ pipWin.screenY,
+ bottomEdge - defaultPiPHeight,
+ "Default PiP Y location"
+ );
+ }
+ checkIfEqual(pipWin.innerHeight, defaultPiPHeight, "Default PiP height");
+ checkIfEqual(pipWin.innerWidth, defaultPiPWidth, "Default PiP width");
+
+ let top = defaultY;
+ let left = defaultX;
+ pipWin.moveTo(left, top);
+ let height = pipWin.innerHeight / 2;
+ let width = pipWin.innerWidth / 2;
+ pipWin.resizeTo(width, height);
+
+ // CLose first PiP window and open another
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // PiP is opened at 0, 0 with size 1/4 default width and 1/4 default height
+ checkIfEqual(pipWin.screenX, left, "Opened at last X location");
+ checkIfEqual(pipWin.screenY, top, "Opened at last Y location");
+ checkIfEqual(
+ pipWin.innerHeight,
+ height,
+ "Opened with 1/2 default height"
+ );
+ checkIfEqual(pipWin.innerWidth, width, "Opened with 1/2 default width");
+
+ // Mac and Linux did not allow moving to coordinates offscreen so this
+ // test is skipped on those platforms
+ if (AppConstants.platform == "win") {
+ // Move to -1111, -1111 and adjust size to 1/4 width and 1/4 height
+ left = -11111;
+ top = -11111;
+ pipWin.moveTo(left, top);
+ pipWin.resizeTo(pipWin.innerWidth / 4, pipWin.innerHeight / 4);
+
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // because the coordinates are off screen, the default size and location will be used
+ checkIfEqual(
+ pipWin.screenX,
+ rightEdge - defaultPiPWidth,
+ "Opened at default X location"
+ );
+ checkIfEqual(
+ pipWin.screenY,
+ bottomEdge - defaultPiPHeight,
+ "Opened at default Y location"
+ );
+ checkIfEqual(
+ pipWin.innerWidth,
+ defaultPiPWidth,
+ "Opened at default PiP width"
+ );
+ checkIfEqual(
+ pipWin.innerHeight,
+ defaultPiPHeight,
+ "Opened at default PiP height"
+ );
+ }
+
+ // Linux doesn't handle switching the video source well and it will
+ // cause the tests to failed in unexpected ways. Possibly caused by
+ // bug 1594223 https://bugzilla.mozilla.org/show_bug.cgi?id=1594223
+ if (AppConstants.platform != "linux") {
+ // Save width and height for when aspect ratio is changed
+ height = pipWin.innerHeight;
+ width = pipWin.innerWidth;
+
+ left = 200;
+ top = 100;
+ pipWin.moveTo(left, top);
+
+ // Now switch the video so the video ratio is different
+ await switchVideoSource("test-video-cropped.mp4");
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ checkIfEqual(pipWin.screenX, left, "Opened at last X location");
+ checkIfEqual(pipWin.screenY, top, "Opened at last Y location");
+ checkIfEqual(
+ pipWin.innerHeight,
+ height,
+ "Opened height with previous width"
+ );
+ checkIfEqual(
+ pipWin.innerWidth,
+ height * (pipWin.innerWidth / pipWin.innerHeight),
+ "Width is changed to adjust for aspect ration"
+ );
+
+ left = 300;
+ top = 300;
+ pipWin.moveTo(left, top);
+ pipWin.resizeTo(defaultPiPWidth / 2, defaultPiPHeight / 2);
+
+ // Save height for when aspect ratio is changed
+ height = pipWin.innerHeight;
+
+ // Now switch the video so the video ratio is different
+ await switchVideoSource("test-video.mp4");
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ checkIfEqual(pipWin.screenX, left, "Opened at last X location");
+ checkIfEqual(pipWin.screenY, top, "Opened at last Y location");
+ checkIfEqual(pipWin.innerHeight, height, "Opened with previous height");
+ checkIfEqual(
+ pipWin.innerWidth,
+ height * (pipWin.innerWidth / pipWin.innerHeight),
+ "Width is changed to adjust for aspect ration"
+ );
+ }
+
+ // Move so that part of PiP is off screen (bottom right)
+
+ left = rightEdge - Math.round((3 * pipWin.innerWidth) / 4);
+ top = bottomEdge - Math.round((3 * pipWin.innerHeight) / 4);
+
+ let movePromise = BrowserTestUtils.waitForEvent(
+ pipWin.windowRoot,
+ "MozUpdateWindowPos"
+ );
+ pipWin.moveTo(left, top);
+ await movePromise;
+
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Redefine top and left to where the PiP windop will open
+ left = rightEdge - pipWin.innerWidth;
+ top = bottomEdge - pipWin.innerHeight;
+
+ // await new Promise(r => setTimeout(r, 5000));
+ // PiP is opened bottom right but not off screen
+ checkIfEqual(
+ pipWin.screenX,
+ left,
+ "Opened at last X location but shifted back on screen"
+ );
+ if (AppConstants.platform == "linux") {
+ checkIfEqual(
+ pipWin.screenY,
+ top - tabHeight,
+ "Opened at last Y location but shifted back on screen"
+ );
+ } else {
+ checkIfEqual(
+ pipWin.screenY,
+ top,
+ "Opened at last Y location but shifted back on screen"
+ );
+ }
+
+ // Move so that part of PiP is off screen (top left)
+ left = defaultX - Math.round(pipWin.innerWidth / 4);
+ top = defaultY - Math.round(pipWin.innerHeight / 4);
+
+ movePromise = BrowserTestUtils.waitForEvent(
+ pipWin.windowRoot,
+ "MozUpdateWindowPos"
+ );
+ pipWin.moveTo(left, top);
+ await movePromise;
+
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // PiP is opened top left on screen
+ checkIfEqual(
+ pipWin.screenX,
+ defaultX,
+ "Opened at last X location but shifted back on screen"
+ );
+ checkIfEqual(
+ pipWin.screenY,
+ defaultY,
+ "Opened at last Y location but shifted back on screen"
+ );
+
+ if (AppConstants.platform != "linux") {
+ // test that if video is on right edge and new video with smaller width
+ // is opened next, it is still on the right edge
+ left = rightEdge - pipWin.innerWidth;
+ top = Math.round(bottomEdge / 4);
+
+ pipWin.moveTo(left, top);
+
+ // Used to ensure that video width decreases for next PiP window
+ width = pipWin.innerWidth;
+ checkIfEqual(
+ pipWin.innerWidth + pipWin.screenX,
+ rightEdge,
+ "Video is on right edge before video is changed"
+ );
+
+ // Now switch the video so the video width is smaller
+ await switchVideoSource("test-video-cropped.mp4");
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ checkIfEqual(
+ pipWin.innerWidth < width,
+ true,
+ "New video width is smaller"
+ );
+ checkIfEqual(
+ pipWin.innerWidth + pipWin.screenX,
+ rightEdge,
+ "Video is on right edge after video is changed"
+ );
+ }
+
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_shortcutsAfterFocus.js b/toolkit/components/pictureinpicture/tests/browser_shortcutsAfterFocus.js
new file mode 100644
index 0000000000..7d40664df4
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_shortcutsAfterFocus.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests functionality of arrow keys in Picture-in-Picture window
+ * for seeking and volume adjustment
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.keyboard-controls.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let waitForVideoEvent = eventType => {
+ return BrowserTestUtils.waitForContentEvent(browser, eventType, true);
+ };
+
+ await ensureVideosReady(browser);
+
+ // Open the video in PiP
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // run the next tests 4 times to ensure that they work for each PiP button, including none
+ for (var i = 0; i < 4; i++) {
+ // Try seek forward
+ let seekedForwardPromise = waitForVideoEvent("seeked");
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin);
+ ok(await seekedForwardPromise, "The time seeked forward");
+
+ // Try seek backward
+ let seekedBackwardPromise = waitForVideoEvent("seeked");
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin);
+ ok(await seekedBackwardPromise, "The time seeked backward");
+
+ // Try volume down
+ let volumeDownPromise = waitForVideoEvent("volumechange");
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, pipWin);
+ ok(await volumeDownPromise, "The volume went down");
+
+ // Try volume up
+ let volumeUpPromise = waitForVideoEvent("volumechange");
+ EventUtils.synthesizeKey("KEY_ArrowUp", {}, pipWin);
+ ok(await volumeUpPromise, "The volume went up");
+
+ // Tab to get to the next button
+ EventUtils.synthesizeKey("KEY_Tab", {}, pipWin);
+ }
+
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_showMessage.js b/toolkit/components/pictureinpicture/tests/browser_showMessage.js
new file mode 100644
index 0000000000..24d8347a7f
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_showMessage.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that triggering Picture-in-Picture causes the Picture-in-Picture
+ * window to be opened, and a message to be displayed in the original video
+ * player area. Also ensures that once the Picture-in-Picture window is closed,
+ * the video goes back to the original state.
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+ }
+ );
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_smallVideoLayout.js b/toolkit/components/pictureinpicture/tests/browser_smallVideoLayout.js
new file mode 100644
index 0000000000..0c2a392d9f
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_smallVideoLayout.js
@@ -0,0 +1,210 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the Picture-in-Picture toggle is hidden when videos
+ * are laid out with dimensions smaller than MIN_VIDEO_DIMENSION (a
+ * constant that is also defined in videocontrols.js).
+ */
+add_task(async () => {
+ // Most of the Picture-in-Picture tests run with the always-show
+ // preference set to true to avoid the toggle visibility heuristics.
+ // Since this test actually exercises those heuristics, we have
+ // to temporarily disable that pref.
+ //
+ // We also reduce the minimum video length for displaying the toggle
+ // to 5 seconds to avoid having to include or generate a 45 second long
+ // video (which is the default minimum length).
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.video-toggle.always-show",
+ false,
+ ],
+ ["media.videocontrols.picture-in-picture.video-toggle.min-video-secs", 5],
+ ],
+ });
+
+ // This is the minimum size of the video in either width or height for
+ // which we will show the toggle. See videocontrols.js.
+ const MIN_VIDEO_DIMENSION = 140; // pixels
+
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_SOUND,
+ gBrowser,
+ },
+ async browser => {
+ // Shrink the video down to less than MIN_VIDEO_DIMENSION.
+ let targetWidth = MIN_VIDEO_DIMENSION - 1;
+ await SpecialPowers.spawn(
+ browser,
+ [videoID, targetWidth],
+ async (videoID, targetWidth) => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let resizePromise = ContentTaskUtils.waitForEvent(
+ shadowRoot.firstChild,
+ "resizevideocontrols"
+ );
+ video.style.width = targetWidth + "px";
+ await resizePromise;
+ }
+ );
+
+ // The toggle should be hidden.
+ await testToggleHelper(browser, videoID, false);
+
+ // Now re-expand the video.
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let resizePromise = ContentTaskUtils.waitForEvent(
+ shadowRoot.firstChild,
+ "resizevideocontrols"
+ );
+ video.style.width = "";
+ await resizePromise;
+ });
+
+ // The toggle should be visible.
+ await testToggleHelper(browser, videoID, true);
+ }
+ );
+ }
+});
+
+/**
+ * Tests that when using the experimental toggle variations, videos
+ * under 320px width are given the "small-video" attribute.
+ */
+add_task(async () => {
+ const TOGGLE_SMALL = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-wrapper": 0.8,
+ },
+ hidden: [],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [".pip-expanded"],
+ },
+ },
+ };
+
+ const TOGGLE_LARGE = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-small": 0.0,
+ ".pip-wrapper": 0.8,
+ ".pip-expanded": 0.0,
+ },
+ hidden: [".pip-explainer", ".pip-icon-label > .pip-icon"],
+ },
+ hoverToggle: {
+ opacities: {
+ ".pip-small": 0.0,
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [
+ ".pip-explainer",
+ ".pip-icon-label > .pip-icon",
+ ".pip-expanded",
+ ],
+ },
+ },
+ };
+
+ // Most of the Picture-in-Picture tests run with the always-show
+ // preference set to true to avoid the toggle visibility heuristics.
+ // Since this test actually exercises those heuristics, we have
+ // to temporarily disable that pref.
+ //
+ // We also reduce the minimum video length for displaying the toggle
+ // to 5 seconds to avoid having to include or generate a 45 second long
+ // video (which is the default minimum length).
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.video-toggle.always-show",
+ false,
+ ],
+ ["media.videocontrols.picture-in-picture.video-toggle.min-video-secs", 5],
+ ["media.videocontrols.picture-in-picture.video-toggle.mode", 1],
+ ],
+ });
+
+ // Videos that are thinner than MIN_VIDEO_WIDTH should have the small-video
+ // attribute set on the experimental toggle.
+ const MIN_VIDEO_WIDTH = 320; // pixels
+
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_SOUND,
+ gBrowser,
+ },
+ async browser => {
+ // Shrink the video down to less than MIN_VIDEO_WIDTH.
+ let targetWidth = MIN_VIDEO_WIDTH - 1;
+ let isSmallVideo = await SpecialPowers.spawn(
+ browser,
+ [videoID, targetWidth],
+ async (videoID, targetWidth) => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let resizePromise = ContentTaskUtils.waitForEvent(
+ shadowRoot.firstChild,
+ "resizevideocontrols"
+ );
+ video.style.width = targetWidth + "px";
+ await resizePromise;
+ let toggle = shadowRoot.getElementById("pictureInPictureToggle");
+ return toggle.hasAttribute("small-video");
+ }
+ );
+
+ Assert.ok(isSmallVideo, "Video should have small-video attribute");
+
+ await testToggleHelper(browser, videoID, true, undefined, TOGGLE_SMALL);
+
+ // Now re-expand the video.
+ isSmallVideo = await SpecialPowers.spawn(
+ browser,
+ [videoID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let resizePromise = ContentTaskUtils.waitForEvent(
+ shadowRoot.firstChild,
+ "resizevideocontrols"
+ );
+ video.style.width = "";
+ await resizePromise;
+ let toggle = shadowRoot.getElementById("pictureInPictureToggle");
+ return toggle.hasAttribute("small-video");
+ }
+ );
+
+ Assert.ok(!isSmallVideo, "Video should not have small-video attribute");
+
+ await testToggleHelper(browser, videoID, true, undefined, TOGGLE_LARGE);
+ }
+ );
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_stripVideoStyles.js b/toolkit/components/pictureinpicture/tests/browser_stripVideoStyles.js
new file mode 100644
index 0000000000..10eb8f43ea
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_stripVideoStyles.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that <video>'s with styles on the element don't have those
+ * styles cloned over into the <video> that's inserted into the
+ * player window.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let styles = {
+ padding: "15px",
+ border: "5px solid red",
+ margin: "3px",
+ position: "absolute",
+ };
+
+ await SpecialPowers.spawn(browser, [styles], async videoStyles => {
+ let video = content.document.getElementById("no-controls");
+ for (let styleProperty in videoStyles) {
+ video.style[styleProperty] = videoStyles[styleProperty];
+ }
+ });
+
+ let pipWin = await triggerPictureInPicture(browser, "no-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let playerBrowser = pipWin.document.getElementById("browser");
+ await SpecialPowers.spawn(playerBrowser, [styles], async videoStyles => {
+ let video = content.document.querySelector("video");
+ for (let styleProperty in videoStyles) {
+ Assert.equal(
+ video.style[styleProperty],
+ "",
+ `Style ${styleProperty} should not be set`
+ );
+ }
+ });
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js b/toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js
new file mode 100644
index 0000000000..3ca55f2c73
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * The goal of this test is check the that "tab-icon-overlay" image is
+ * showing when the tab is using PiP.
+ *
+ * The browser will create a tab and open a video using PiP
+ * then the tests check that the tab icon overlay image is showing*
+ *
+ *
+ */
+add_task(async () => {
+ let videoID = "with-controls";
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_SOUND,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ let audioPromise = BrowserTestUtils.waitForEvent(
+ browser,
+ "DOMAudioPlaybackStarted"
+ );
+
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ await content.document.getElementById(videoID).play();
+ });
+
+ // Check that video is playing
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused.");
+ await audioPromise;
+
+ // Need tab to access the tab-icon-overlay element
+ let tab = gBrowser.getTabForBrowser(browser);
+
+ // Use tab to get the tab-icon-overlay element
+ let tabIconOverlay = tab.getElementsByClassName("tab-icon-overlay")[0];
+
+ // Not in PiP yet so the tab-icon-overlay does not have "pictureinpicture" attribute
+ ok(!tabIconOverlay.hasAttribute("pictureinpicture"), "Not using PiP");
+
+ // Sound is playing so tab should have "soundplaying" attribute
+ ok(tabIconOverlay.hasAttribute("soundplaying"), "Sound is playing");
+
+ // Start the PiP
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Check that video is still playing
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused.");
+
+ // Video is still playing so the tab-icon-overlay should have "soundplaying" as an attribute
+ ok(
+ tabIconOverlay.hasAttribute("soundplaying"),
+ "Tab knows sound is playing"
+ );
+
+ // Now in PiP. "pictureinpicture" is an attribute
+ ok(
+ tabIconOverlay.hasAttribute("pictureinpicture"),
+ "Tab knows were using PiP"
+ );
+
+ // We know the tab has sound playing and it is using PiP so we can check the
+ // tab-icon-overlay image is showing
+ let style = window.getComputedStyle(tabIconOverlay);
+ Assert.equal(
+ style.listStyleImage,
+ 'url("chrome://browser/skin/tabbrowser/tab-audio-playing-small.svg")',
+ "Got the tab-icon-overlay image"
+ );
+
+ // Check tab is not muted
+ ok(!tabIconOverlay.hasAttribute("muted"), "Tab is not muted");
+
+ // Click on tab icon overlay to mute tab and check it is muted
+ tabIconOverlay.click();
+ ok(tabIconOverlay.hasAttribute("muted"), "Tab is muted");
+
+ // Click on tab icon overlay to unmute tab and check it is not muted
+ tabIconOverlay.click();
+ ok(!tabIconOverlay.hasAttribute("muted"), "Tab is not muted");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js b/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js
new file mode 100644
index 0000000000..cf63768d85
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that videos hosted inside of a third-party <iframe> can be opened
+ * in a Picture-in-Picture window.
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_IFRAME,
+ gBrowser,
+ },
+ async browser => {
+ // TEST_PAGE_WITH_IFRAME is hosted at a different domain from TEST_PAGE,
+ // so loading TEST_PAGE within the iframe will act as our third-party
+ // iframe.
+ await SpecialPowers.spawn(browser, [TEST_PAGE], async TEST_PAGE => {
+ let iframe = content.document.getElementById("iframe");
+ let loadPromise = ContentTaskUtils.waitForEvent(iframe, "load");
+ iframe.src = TEST_PAGE;
+ await loadPromise;
+ });
+
+ let iframeBc = browser.browsingContext.children[0];
+
+ if (gFissionBrowser) {
+ Assert.notEqual(
+ browser.browsingContext.currentWindowGlobal.osPid,
+ iframeBc.currentWindowGlobal.osPid,
+ "The iframe should be running in a different process."
+ );
+ }
+
+ let pipWin = await triggerPictureInPicture(iframeBc, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await ensureMessageAndClosePiP(iframeBc, videoID, pipWin, true);
+ }
+ );
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js b/toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js
new file mode 100644
index 0000000000..5588b3a775
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Helper function that tries to use the mouse to open the Picture-in-Picture
+ * player window for a video with and without the built-in controls.
+ *
+ * @param {Element} tab The tab to be tested.
+ * @return Promise
+ * @resolves When the toggles for both the video-with-controls and
+ * video-without-controls have been tested.
+ */
+async function testToggleForTab(tab) {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ let browser = tab.linkedBrowser;
+ info(`Testing ${videoID} case.`);
+
+ await testToggleHelper(browser, videoID, true);
+ }
+}
+
+/**
+ * Tests that the Picture-in-Picture toggle still works after tearing out the
+ * tab into a new window, or tearing in a tab from one window to another.
+ */
+add_task(async () => {
+ // The startingTab will be torn out and placed in the new window.
+ let startingTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_PAGE
+ );
+
+ // Tear out the starting tab into its own window...
+ let newWinLoaded = BrowserTestUtils.waitForNewWindow();
+ let win2 = gBrowser.replaceTabWithWindow(startingTab);
+ await newWinLoaded;
+
+ // Let's maximize the newly opened window so we don't have to worry about
+ // the videos being visible.
+ if (win2.windowState != win2.STATE_MAXIMIZED) {
+ let resizePromise = BrowserTestUtils.waitForEvent(win2, "resize");
+ win2.maximize();
+ await resizePromise;
+ }
+
+ await SimpleTest.promiseFocus(win2);
+ await testToggleForTab(win2.gBrowser.selectedTab);
+
+ // Now bring the tab back to the original window.
+ let dragInTab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ gBrowser.swapBrowsersAndCloseOther(dragInTab, win2.gBrowser.selectedTab);
+ await SimpleTest.promiseFocus(window);
+ await testToggleForTab(dragInTab);
+
+ BrowserTestUtils.removeTab(dragInTab);
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js b/toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js
new file mode 100644
index 0000000000..6f81075770
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the Picture-in-Picture toggle can be clicked when overlaid
+ * with a transparent button, but not clicked when overlaid with an
+ * opaque button.
+ */
+add_task(async () => {
+ const PAGE = TEST_ROOT + "test-button-overlay.html";
+ await testToggle(PAGE, {
+ "video-partial-transparent-button": { canToggle: true },
+ "video-opaque-button": { canToggle: false },
+ });
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js b/toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js
new file mode 100644
index 0000000000..e7a62f6278
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js
@@ -0,0 +1,202 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * See the documentation for the DEFAULT_TOGGLE_STYLES object in head.js
+ * for a description of what these toggle style objects are representing.
+ */
+const TOGGLE_STYLES_LEFT_EXPLAINER = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-small": 0.0,
+ ".pip-wrapper": 0.8,
+ ".pip-expanded": 1.0,
+ },
+ hidden: [".pip-icon-label > .pip-icon"],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-small": 0.0,
+ ".pip-wrapper": 1.0,
+ ".pip-expanded": 1.0,
+ },
+ hidden: [".pip-icon-label > .pip-icon"],
+ },
+ },
+};
+
+const TOGGLE_STYLES_RIGHT_EXPLAINER = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-small": 0.0,
+ ".pip-wrapper": 0.8,
+ ".pip-expanded": 1.0,
+ },
+ hidden: [".pip-wrapper > .pip-icon"],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-small": 0.0,
+ ".pip-wrapper": 1.0,
+ ".pip-expanded": 1.0,
+ },
+ hidden: [".pip-wrapper > .pip-icon"],
+ },
+ },
+};
+
+const TOGGLE_STYLES_LEFT_SMALL = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-wrapper": 0.8,
+ },
+ hidden: [".pip-expanded"],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [".pip-expanded"],
+ },
+ },
+};
+
+const TOGGLE_STYLES_RIGHT_SMALL = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-wrapper": 0.8,
+ },
+ hidden: [".pip-expanded"],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [".pip-expanded"],
+ },
+ },
+};
+
+/**
+ * Tests the Mode 2 variation of the Picture-in-Picture toggle in both the
+ * left and right positions, when the user is in the state where they've never
+ * clicked on the Picture-in-Picture toggle before (since we show a more detailed
+ * toggle in that state).
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.videocontrols.picture-in-picture.video-toggle.position", "left"],
+ [HAS_USED_PREF, false],
+ ],
+ });
+
+ await testToggle(TEST_PAGE, {
+ "with-controls": {
+ canToggle: true,
+ toggleStyles: TOGGLE_STYLES_LEFT_EXPLAINER,
+ },
+ });
+
+ Assert.ok(
+ Services.prefs.getBoolPref(HAS_USED_PREF, false),
+ "Entered has-used mode."
+ );
+ Services.prefs.clearUserPref(HAS_USED_PREF);
+
+ await testToggle(TEST_PAGE, {
+ "no-controls": {
+ canToggle: true,
+ toggleStyles: TOGGLE_STYLES_LEFT_EXPLAINER,
+ },
+ });
+
+ Assert.ok(
+ Services.prefs.getBoolPref(HAS_USED_PREF, false),
+ "Entered has-used mode."
+ );
+ Services.prefs.clearUserPref(HAS_USED_PREF);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.videocontrols.picture-in-picture.video-toggle.position", "right"],
+ ],
+ });
+
+ await testToggle(TEST_PAGE, {
+ "with-controls": {
+ canToggle: true,
+ toggleStyles: TOGGLE_STYLES_RIGHT_EXPLAINER,
+ },
+ });
+
+ Assert.ok(
+ Services.prefs.getBoolPref(HAS_USED_PREF, false),
+ "Entered has-used mode."
+ );
+ Services.prefs.clearUserPref(HAS_USED_PREF);
+
+ await testToggle(TEST_PAGE, {
+ "no-controls": {
+ canToggle: true,
+ toggleStyles: TOGGLE_STYLES_RIGHT_EXPLAINER,
+ },
+ });
+
+ Assert.ok(
+ Services.prefs.getBoolPref(HAS_USED_PREF, false),
+ "Entered has-used mode."
+ );
+ Services.prefs.clearUserPref(HAS_USED_PREF);
+});
+
+/**
+ * Tests the Mode 2 variation of the Picture-in-Picture toggle in both the
+ * left and right positions, when the user is in the state where they've
+ * used the Picture-in-Picture feature before.
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.videocontrols.picture-in-picture.video-toggle.mode", 2],
+ ["media.videocontrols.picture-in-picture.video-toggle.position", "left"],
+ [HAS_USED_PREF, true],
+ ],
+ });
+
+ await testToggle(TEST_PAGE, {
+ "with-controls": {
+ canToggle: true,
+ toggleStyles: TOGGLE_STYLES_LEFT_SMALL,
+ },
+ "no-controls": { canToggle: true, toggleStyles: TOGGLE_STYLES_LEFT_SMALL },
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.videocontrols.picture-in-picture.video-toggle.position", "right"],
+ ],
+ });
+
+ await testToggle(TEST_PAGE, {
+ "with-controls": {
+ canToggle: true,
+ toggleStyles: TOGGLE_STYLES_LEFT_SMALL,
+ },
+ "no-controls": { canToggle: true, toggleStyles: TOGGLE_STYLES_LEFT_SMALL },
+ });
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleOnInsertedVideo.js b/toolkit/components/pictureinpicture/tests/browser_toggleOnInsertedVideo.js
new file mode 100644
index 0000000000..7163966e64
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleOnInsertedVideo.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the Picture-in-Picture toggle correctly attaches itself when the
+ * video element has been inserted into the DOM after the video is ready to
+ * play.
+ */
+add_task(async () => {
+ const PAGE = TEST_ROOT + "test-page.html";
+
+ await testToggle(
+ PAGE,
+ {
+ inserted: { canToggle: true },
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ let doc = content.document;
+
+ // To avoid issues with the video not being scrolled into view, get
+ // rid of the other videos on the page.
+ let preExistingVideos = doc.querySelectorAll("video");
+ for (let video of preExistingVideos) {
+ video.remove();
+ }
+
+ let newVideo = doc.createElement("video");
+ const { ContentTaskUtils } = ChromeUtils.import(
+ "resource://testing-common/ContentTaskUtils.jsm"
+ );
+ let ready = ContentTaskUtils.waitForEvent(newVideo, "canplay");
+ newVideo.src = "test-video.mp4";
+ newVideo.id = "inserted";
+ await ready;
+ doc.body.appendChild(newVideo);
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleOpaqueOverlay.js b/toolkit/components/pictureinpicture/tests/browser_toggleOpaqueOverlay.js
new file mode 100644
index 0000000000..18c906bf20
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleOpaqueOverlay.js
@@ -0,0 +1,16 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the Picture-in-Picture toggle is not clickable when
+ * overlaid with opaque elements.
+ */
+add_task(async () => {
+ const PAGE = TEST_ROOT + "test-opaque-overlay.html";
+ await testToggle(PAGE, {
+ "video-full-opacity": { canToggle: false },
+ "video-full-opacity-over-toggle": { canToggle: false },
+ });
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_togglePointerEventsNone.js b/toolkit/components/pictureinpicture/tests/browser_togglePointerEventsNone.js
new file mode 100644
index 0000000000..23cc393960
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_togglePointerEventsNone.js
@@ -0,0 +1,16 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the Picture-in-Picture toggle is clickable even if the
+ * video element has pointer-events: none.
+ */
+add_task(async () => {
+ const PAGE = TEST_ROOT + "test-pointer-events-none.html";
+ await testToggle(PAGE, {
+ "with-controls": { canToggle: true },
+ "no-controls": { canToggle: true },
+ });
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_togglePolicies.js b/toolkit/components/pictureinpicture/tests/browser_togglePolicies.js
new file mode 100644
index 0000000000..165fa1c72b
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_togglePolicies.js
@@ -0,0 +1,126 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that by setting a Picture-in-Picture toggle position policy
+ * in the sharedData structure, that the toggle position can be
+ * change for a particular URI.
+ */
+add_task(async () => {
+ let positionPolicies = [
+ TOGGLE_POLICIES.TOP,
+ TOGGLE_POLICIES.ONE_QUARTER,
+ TOGGLE_POLICIES.THREE_QUARTERS,
+ TOGGLE_POLICIES.BOTTOM,
+ ];
+
+ for (let policy of positionPolicies) {
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {
+ "*://example.com/*": { policy },
+ });
+ Services.ppmm.sharedData.flush();
+
+ let expectations = {
+ "with-controls": { canToggle: true, policy },
+ "no-controls": { canToggle: true, policy },
+ };
+
+ // For <video> elements with controls, the video controls overlap the
+ // toggle when its on the bottom and can't be clicked, so we'll ignore
+ // that case.
+ if (policy == TOGGLE_POLICIES.BOTTOM) {
+ expectations["with-controls"] = { canToggle: true };
+ }
+
+ await testToggle(TEST_PAGE, expectations);
+
+ // And ensure that other pages aren't affected by this override.
+ await testToggle(TEST_PAGE_2, {
+ "with-controls": { canToggle: true },
+ "no-controls": { canToggle: true },
+ });
+ }
+
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {});
+ Services.ppmm.sharedData.flush();
+});
+
+/**
+ * Tests that by setting a Picture-in-Picture toggle hidden policy
+ * in the sharedData structure, that the toggle can be suppressed.
+ */
+add_task(async () => {
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {
+ "*://example.com/*": { policy: TOGGLE_POLICIES.HIDDEN },
+ });
+ Services.ppmm.sharedData.flush();
+
+ await testToggle(TEST_PAGE, {
+ "with-controls": { canToggle: false, policy: TOGGLE_POLICIES.HIDDEN },
+ "no-controls": { canToggle: false, policy: TOGGLE_POLICIES.HIDDEN },
+ });
+
+ // And ensure that other pages aren't affected by this override.
+ await testToggle(TEST_PAGE_2, {
+ "with-controls": { canToggle: true },
+ "no-controls": { canToggle: true },
+ });
+
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {});
+ Services.ppmm.sharedData.flush();
+});
+
+/**
+ * Tests that policies are re-evaluated if the page URI is transitioned
+ * via the history API.
+ */
+add_task(async () => {
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {
+ "*://example.com/*/test-page.html": { policy: TOGGLE_POLICIES.HIDDEN },
+ });
+ Services.ppmm.sharedData.flush();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ await SimpleTest.promiseFocus(browser);
+
+ await testToggleHelper(
+ browser,
+ "no-controls",
+ false,
+ TOGGLE_POLICIES.HIDDEN
+ );
+
+ await SpecialPowers.spawn(browser, [], async function() {
+ content.history.pushState({}, "2", "otherpage.html");
+ });
+
+ // Since we no longer match the policy URI, we should be able
+ // to use the Picture-in-Picture toggle.
+ await testToggleHelper(browser, "no-controls", true);
+
+ // Now use the history API to put us back at the original location,
+ // which should have the HIDDEN policy re-applied.
+ await SpecialPowers.spawn(browser, [], async function() {
+ content.history.pushState({}, "Return", "test-page.html");
+ });
+
+ await testToggleHelper(
+ browser,
+ "no-controls",
+ false,
+ TOGGLE_POLICIES.HIDDEN
+ );
+ }
+ );
+
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {});
+ Services.ppmm.sharedData.flush();
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleSimple.js b/toolkit/components/pictureinpicture/tests/browser_toggleSimple.js
new file mode 100644
index 0000000000..a9351816fa
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleSimple.js
@@ -0,0 +1,18 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that we show the Picture-in-Picture toggle on video
+ * elements when hovering them with the mouse cursor, and that
+ * clicking on them causes the Picture-in-Picture window to
+ * open if the toggle isn't being occluded. This test tests videos
+ * both with and without controls.
+ */
+add_task(async () => {
+ await testToggle(TEST_PAGE, {
+ "with-controls": { canToggle: true },
+ "no-controls": { canToggle: true },
+ });
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-1.js b/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-1.js
new file mode 100644
index 0000000000..658bb0f362
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-1.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the Picture-in-Picture toggle can appear and be clicked
+ * when the video is overlaid with transparent elements. Also tests the
+ * site-specific toggle visibility threshold to ensure that we can
+ * configure opacities that can't be clicked through.
+ */
+add_task(async () => {
+ const PAGE = TEST_ROOT + "test-transparent-overlay-1.html";
+ await testToggle(PAGE, {
+ "video-transparent-background": { canToggle: true },
+ "video-alpha-background": { canToggle: true },
+ });
+
+ // Now set a toggle visibility threshold to 0.4 and ensure that the
+ // partially obscured toggle can't be clicked.
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {
+ "*://example.com/*": { visibilityThreshold: 0.4 },
+ });
+ Services.ppmm.sharedData.flush();
+
+ await testToggle(PAGE, {
+ "video-transparent-background": { canToggle: true },
+ "video-alpha-background": { canToggle: false },
+ });
+
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {});
+ Services.ppmm.sharedData.flush();
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-2.js b/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-2.js
new file mode 100644
index 0000000000..b425b50d1c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-2.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the Picture-in-Picture toggle can appear and be clicked
+ * when the video is overlaid with elements that have zero and partial
+ * opacity. Also tests the site-specific toggle visibility threshold to
+ * ensure that we can configure opacities that can't be clicked through.
+ */
+add_task(async () => {
+ const PAGE = TEST_ROOT + "test-transparent-overlay-2.html";
+ await testToggle(PAGE, {
+ "video-zero-opacity": { canToggle: true },
+ "video-partial-opacity": { canToggle: true },
+ });
+
+ // Now set a toggle visibility threshold to 0.4 and ensure that the
+ // partially obscured toggle can't be clicked.
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {
+ "*://example.com/*": { visibilityThreshold: 0.4 },
+ });
+ Services.ppmm.sharedData.flush();
+
+ await testToggle(PAGE, {
+ "video-zero-opacity": { canToggle: true },
+ "video-partial-opacity": { canToggle: false },
+ });
+
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {});
+ Services.ppmm.sharedData.flush();
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_videoSelection.js b/toolkit/components/pictureinpicture/tests/browser_videoSelection.js
new file mode 100644
index 0000000000..f887f158c1
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_videoSelection.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the correct video is opened in the
+ * Picture-in-Picture player when opened via keyboard shortcut.
+ * The shortcut will open the first unpaused video
+ * or the longest video on the page.
+ */
+add_task(async function test_video_selection() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_ROOT + "test-video-selection.html",
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ let pipVideoID = await SpecialPowers.spawn(browser, [], () => {
+ let videoList = content.document.querySelectorAll("video");
+ let longestDuration = -1;
+ let pipVideoID = null;
+
+ for (let video of videoList) {
+ if (!video.paused) {
+ pipVideoID = video.id;
+ break;
+ }
+ if (video.duration > longestDuration) {
+ pipVideoID = video.id;
+ longestDuration = video.duration;
+ }
+ }
+ return pipVideoID;
+ });
+
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ let videoReady = SpecialPowers.spawn(
+ browser,
+ [pipVideoID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ }
+ );
+
+ let eventObj = { accelKey: true, shiftKey: true };
+ if (AppConstants.platform == "macosx") {
+ eventObj.altKey = true;
+ }
+ EventUtils.synthesizeKey("]", eventObj, window);
+
+ let pipWin = await domWindowOpened;
+ await videoReady;
+
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await ensureMessageAndClosePiP(browser, pipVideoID, pipWin, false);
+
+ pipVideoID = await SpecialPowers.spawn(browser, [], () => {
+ let videoList = content.document.querySelectorAll("video");
+ videoList[1].play();
+ videoList[2].play();
+ let longestDuration = -1;
+ let pipVideoID = null;
+
+ for (let video of videoList) {
+ if (!video.paused) {
+ pipVideoID = video.id;
+ break;
+ }
+ if (video.duration > longestDuration) {
+ pipVideoID = video.id;
+ longestDuration = video.duration;
+ }
+ }
+
+ return pipVideoID;
+ });
+
+ // Next time we want to use a keyboard shortcut with the main window in focus again.
+ await SimpleTest.promiseFocus(browser);
+
+ domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ videoReady = SpecialPowers.spawn(browser, [pipVideoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ });
+
+ EventUtils.synthesizeKey("]", eventObj, window);
+
+ pipWin = await domWindowOpened;
+ await videoReady;
+
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await ensureMessageAndClosePiP(browser, pipVideoID, pipWin, false);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/click-event-helper.js b/toolkit/components/pictureinpicture/tests/click-event-helper.js
new file mode 100644
index 0000000000..6b3ba42994
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/click-event-helper.js
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This helper script is used to record mouse button events for
+ * Picture-in-Picture toggle click tests. Anytime the toggle is
+ * clicked, we expect none of the events to be fired. Otherwise,
+ * all events should be fired when clicking.
+ */
+
+let eventTypes = ["pointerdown", "mousedown", "pointerup", "mouseup", "click"];
+
+for (let event of eventTypes) {
+ addEventListener(event, recordEvent, { capture: true });
+}
+
+let recordedEvents = [];
+function recordEvent(event) {
+ recordedEvents.push(event.type);
+}
+
+function getRecordedEvents() {
+ let result = recordedEvents.concat();
+ recordedEvents = [];
+ return result;
+}
diff --git a/toolkit/components/pictureinpicture/tests/head.js b/toolkit/components/pictureinpicture/tests/head.js
new file mode 100644
index 0000000000..8df9062263
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/head.js
@@ -0,0 +1,835 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TOGGLE_POLICIES } = ChromeUtils.import(
+ "resource://gre/modules/PictureInPictureControls.jsm"
+);
+
+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 WINDOW_TYPE = "Toolkit:PictureInPicture";
+const TOGGLE_POSITION_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.position";
+const HAS_USED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+const SHARED_DATA_KEY = "PictureInPicture:SiteOverrides";
+
+/**
+ * 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": 0.8,
+ },
+ 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.
+ *
+ * @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.
+ *
+ * @return Promise
+ * @resolves With the Picture-in-Picture window when ready.
+ */
+async function triggerPictureInPicture(browser, videoID) {
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ let 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 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, videoId) {
+ return SpecialPowers.spawn(browser, [videoId], async videoID => {
+ let video = content.document.getElementById(videoID);
+ 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)
+ *
+ * @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) {
+ 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.is_hidden(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.jsm.
+ * 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.import(
+ "resource://gre/modules/PictureInPictureControls.jsm"
+ );
+ 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."
+ );
+}
+
+/**
+ * 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.import(
+ "resource://gre/actors/PictureInPictureChild.jsm"
+ );
+ 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.import("resource://gre/modules/Geometry.jsm");
+
+ 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,
+ };
+ });
+}
+
+/**
+ * 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.jsm.
+ *
+ * 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 }] of Object.entries(
+ expectations
+ )) {
+ await SimpleTest.promiseFocus(browser);
+ info(`Testing video with id: ${videoID}`);
+
+ await testToggleHelper(
+ browser,
+ videoID,
+ canToggle,
+ policy,
+ toggleStyles
+ );
+ }
+ }
+ );
+}
+
+/**
+ * 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.jsm.
+ * @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
+) {
+ 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);
+
+ // 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");
+ });
+}
+
+/**
+ * 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;
+ });
+}
diff --git a/toolkit/components/pictureinpicture/tests/short.mp4 b/toolkit/components/pictureinpicture/tests/short.mp4
new file mode 100644
index 0000000000..abe37b9f9d
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/short.mp4
Binary files differ
diff --git a/toolkit/components/pictureinpicture/tests/test-button-overlay.html b/toolkit/components/pictureinpicture/tests/test-button-overlay.html
new file mode 100644
index 0000000000..9917fba973
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-button-overlay.html
@@ -0,0 +1,81 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture test - transparent overlays - 1</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ }
+
+ .container {
+ position: relative;
+ display: inline-block;
+ }
+
+ .overlay {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ color: white;
+ }
+
+ .toggle-overlay {
+ position: absolute;
+ min-width: 50px;
+ right: 0px;
+ height: 100%;
+ top: calc(50% - 25px);
+ }
+
+ button {
+ height: 100px;
+ }
+
+ .transparent-background {
+ background-color: transparent;
+ }
+
+ .partial-opacity {
+ opacity: 0.5;
+ }
+
+ .full-opacity {
+ opacity: 1.0;
+ background-color: green;
+ }
+
+ .no-pointer-events {
+ pointer-events: none;
+ }
+
+ .pointer-events {
+ pointer-events: auto;
+ }
+</style>
+<body>
+ <div class="container">
+ <div class="overlay transparent-background no-pointer-events">
+ This is a fully transparent overlay using a transparent background.
+ <div class="toggle-overlay partial-opacity pointer-events">
+ <button>I'm a button overlapping the toggle</button>
+ </div>
+ </div>
+ <video id="video-partial-transparent-button" src="test-video.mp4" loop="true"></video>
+ </div>
+
+ <div class="container">
+ <div class="overlay transparent-background no-pointer-events">
+ This is a fully transparent overlay using a transparent background.
+ <div class="toggle-overlay full-opacity pointer-events">
+ <button>I'm a button overlapping the toggle</button>
+ </div>
+ </div>
+ <video id="video-opaque-button" src="test-video.mp4" loop="true"></video>
+ </div>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-media-stream.html b/toolkit/components/pictureinpicture/tests/test-media-stream.html
new file mode 100644
index 0000000000..ad5f91dd25
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-media-stream.html
@@ -0,0 +1,25 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture tests</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ border: 1px solid black;
+ }
+</style>
+<body>
+ <script>
+ function fireEvents() {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ let video = document.getElementById(videoID);
+ let event = new CustomEvent("MozTogglePictureInPicture", { bubbles: true });
+ video.dispatchEvent(event);
+ }
+ }
+ </script>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-opaque-overlay.html b/toolkit/components/pictureinpicture/tests/test-opaque-overlay.html
new file mode 100644
index 0000000000..425c0e74c2
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-opaque-overlay.html
@@ -0,0 +1,51 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture test - transparent overlays - 1</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ }
+
+ .container {
+ position: relative;
+ display: inline-block;
+ }
+
+ .overlay {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ color: white;
+ }
+
+ .toggle-overlay {
+ position: absolute;
+ min-width: 50px;
+ right: 0px;
+ height: 100%;
+ top: calc(50% - 25px);
+ }
+
+ .full-opacity {
+ opacity: 1.0;
+ background-color: green;
+ }
+</style>
+<body>
+ <div class="container">
+ <div class="overlay full-opacity">This is a fully opaque overlay using opacity: 1.0</div>
+ <video id="video-full-opacity" src="test-video.mp4" loop="true"></video>
+ </div>
+
+ <div class="container">
+ <div class="toggle-overlay full-opacity">This is a fully opaque overlay over a region covering the toggle at opacity: 1.0</div>
+ <video id="video-full-opacity-over-toggle" src="test-video.mp4" loop="true"></video>
+ </div>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-page-with-iframe.html b/toolkit/components/pictureinpicture/tests/test-page-with-iframe.html
new file mode 100644
index 0000000000..02205a028b
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page-with-iframe.html
@@ -0,0 +1,27 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture tests</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ html, body {
+ height: 100vh;
+ width: 100vw;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ }
+ #iframe {
+ height: 100vh;
+ width: 100vw;
+ padding: 0;
+ margin: 0;
+ border: 0;
+ }
+</style>
+<body>
+ <iframe id="iframe"></iframe>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-page-with-sound.html b/toolkit/components/pictureinpicture/tests/test-page-with-sound.html
new file mode 100644
index 0000000000..6b6a860bc2
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page-with-sound.html
@@ -0,0 +1,20 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture tests (longer video with sound)</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ border: 1px solid black;
+ }
+</style>
+<body>
+ <h1>Video with controls</h1>
+ <video id="with-controls" src="gizmo.mp4" controls loop="true"></video>
+ <h1>Video without controls</h1>
+ <video id="no-controls" src="gizmo.mp4" loop="true"></video>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-page.html b/toolkit/components/pictureinpicture/tests/test-page.html
new file mode 100644
index 0000000000..a62ff1ac4a
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page.html
@@ -0,0 +1,30 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture tests</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ border: 1px solid black;
+ }
+</style>
+<body>
+ <h1>Video with controls</h1>
+ <video id="with-controls" src="test-video.mp4" controls loop="true" width="400" height="225"></video>
+ <h1>Video without controls</h1>
+ <video id="no-controls" src="test-video.mp4" loop="true" width="400" height="225"></video>
+
+ <script>
+ function fireEvents() {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ let video = document.getElementById(videoID);
+ let event = new CustomEvent("MozTogglePictureInPicture", { bubbles: true });
+ video.dispatchEvent(event);
+ }
+ }
+ </script>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-pointer-events-none.html b/toolkit/components/pictureinpicture/tests/test-pointer-events-none.html
new file mode 100644
index 0000000000..63254b329c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-pointer-events-none.html
@@ -0,0 +1,21 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture test - pointer-events: none</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ pointer-events: none;
+ }
+
+</style>
+<body>
+ <h1>Video with controls</h1>
+ <video id="with-controls" src="test-video.mp4" controls loop="true"></video>
+ <h1>Video without controls</h1>
+ <video id="no-controls" src="test-video.mp4" loop="true"></video>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-reversed.html b/toolkit/components/pictureinpicture/tests/test-reversed.html
new file mode 100644
index 0000000000..4f6d516698
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-reversed.html
@@ -0,0 +1,19 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture tests</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ #reversed {
+ transform: scaleX(-1);
+ }
+</style>
+<body>
+ <h1>Reversed video</h1>
+ <video id="reversed" src="test-video.mp4" controls loop="true" width="400" height="225"></video>
+ <h1>Not Reversed Video</h1>
+ <video id="not-reversed" src="test-video.mp4" loop="true" width="400" height="225"></video>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-transparent-nested-iframes.html b/toolkit/components/pictureinpicture/tests/test-transparent-nested-iframes.html
new file mode 100644
index 0000000000..d7efcc1e92
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-transparent-nested-iframes.html
@@ -0,0 +1,51 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture test - transparent iframe</title>
+</head>
+
+<style>
+ video {
+ display: block;
+ }
+
+ .root {
+ position: relative;
+ display: inline-block;
+ }
+
+ .controls {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ color: white;
+ }
+
+ .container,
+ iframe {
+ width: 100%;
+ height: 100%;
+ }
+
+ iframe {
+ border: 0;
+ }
+</style>
+
+<body>
+ <div class="root">
+ <div class="controls">
+ <div class="container">
+ <iframe src="about:blank"></iframe>
+ </div>
+ </div>
+
+ <div class="video-container">
+ <video id="video-transparent-background" src="test-video.mp4" loop="true"></video>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-transparent-overlay-1.html b/toolkit/components/pictureinpicture/tests/test-transparent-overlay-1.html
new file mode 100644
index 0000000000..8f0f76311b
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-transparent-overlay-1.html
@@ -0,0 +1,46 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture test - transparent overlays - 1</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ }
+
+ .container {
+ position: relative;
+ display: inline-block;
+ }
+
+ .overlay {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ color: white;
+ }
+
+ .transparent-background {
+ background-color: transparent;
+ }
+
+ .alpha-background {
+ background-color: rgba(255, 0, 0, 0.5);
+ }
+</style>
+<body>
+ <div class="container">
+ <div class="overlay transparent-background">This is a fully transparent overlay</div>
+ <video id="video-transparent-background" src="test-video.mp4" loop="true"></video>
+ </div>
+
+ <div class="container">
+ <div class="overlay alpha-background">This is a partially transparent overlay using alpha</div>
+ <video id="video-alpha-background" src="test-video.mp4" loop="true"></video>
+ </div>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-transparent-overlay-2.html b/toolkit/components/pictureinpicture/tests/test-transparent-overlay-2.html
new file mode 100644
index 0000000000..86dab15690
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-transparent-overlay-2.html
@@ -0,0 +1,46 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture test - transparent overlays - 1</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ }
+
+ .container {
+ position: relative;
+ display: inline-block;
+ }
+
+ .overlay {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ color: white;
+ }
+
+ .zero-opacity {
+ opacity: 0;
+ }
+
+ .partial-opacity {
+ opacity: 0.5;
+ }
+</style>
+<body>
+ <div class="container">
+ <div class="overlay zero-opacity">This is a transparent overlay using opacity: 0</div>
+ <video id="video-zero-opacity" src="test-video.mp4" loop="true"></video>
+ </div>
+
+ <div class="container">
+ <div class="overlay partial-opacity">This is a partially transparent overlay using opacity: 0.5</div>
+ <video id="video-partial-opacity" src="test-video.mp4" loop="true"></video>
+ </div>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-video-cropped.mp4 b/toolkit/components/pictureinpicture/tests/test-video-cropped.mp4
new file mode 100644
index 0000000000..6ea66eb1fc
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-video-cropped.mp4
Binary files differ
diff --git a/toolkit/components/pictureinpicture/tests/test-video-long.mp4 b/toolkit/components/pictureinpicture/tests/test-video-long.mp4
new file mode 100644
index 0000000000..714c17ca12
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-video-long.mp4
Binary files differ
diff --git a/toolkit/components/pictureinpicture/tests/test-video-selection.html b/toolkit/components/pictureinpicture/tests/test-video-selection.html
new file mode 100644
index 0000000000..4ce65d6309
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-video-selection.html
@@ -0,0 +1,22 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture tests</title>
+ <script type="text/javascript"></script>
+</head>
+<style>
+ video {
+ display: block;
+ border: 1px solid black;
+ }
+</style>
+<body>
+ <h1>Shortest Video</h1>
+ <video id="shortest" src="test-video.mp4" controls loop="true"></video>
+ <h1>Shorter Video</h1>
+ <video id="short" src="short.mp4" controls loop="true"></video>
+ <h1>Longer Video</h1>
+ <video id="long" src="test-video-long.mp4" controls loop="true"></video>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-video-vertical.mp4 b/toolkit/components/pictureinpicture/tests/test-video-vertical.mp4
new file mode 100644
index 0000000000..404eec6fd0
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-video-vertical.mp4
Binary files differ
diff --git a/toolkit/components/pictureinpicture/tests/test-video.mp4 b/toolkit/components/pictureinpicture/tests/test-video.mp4
new file mode 100644
index 0000000000..90bbe6bc26
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-video.mp4
Binary files differ