summaryrefslogtreecommitdiffstats
path: root/toolkit/components/pictureinpicture/tests
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/pictureinpicture/tests')
-rw-r--r--toolkit/components/pictureinpicture/tests/browser.toml239
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_aaa_run_first_firstTimePiPToggleEvents.js314
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_aaa_telemetry_togglePiP.js69
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_audioScrubber.js262
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_backgroundTab.js93
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js32
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_changePiPSrcInFullscreen.js511
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_closePipPause.js68
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_closePip_pageNavigationChanges.js113
-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_close_unpip_focus.js72
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_conflictingPips.js46
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_contextMenu.js238
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_controlsHover.js191
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js271
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js101
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_disableSwipeGestures.js47
-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_fontSize_change.js152
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_fullscreen.js161
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_improved_controls.js303
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_keyboardClosePIPwithESC.js72
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_keyboardFullScreenPIPShortcut.js42
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js134
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_keyboardShortcutClosePIP.js53
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_keyboardShortcutWithNanDuration.js54
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_keyboardToggle.js32
-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.js225
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_nimbusDisplayDuration.js214
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_nimbusFirstTimeStyleVariant.js118
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_nimbusMessageFirstTimePip.js121
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_nimbusShowIconOnly.js114
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_noPlayerControlsOnMiddleRightClick.js53
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js46
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_occluded_window.js258
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_playerControls.js86
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_preserveTabPipIconOverlay.js167
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_privateWindow.js38
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js84
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_resizeVideo.js293
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_reversePiP.js145
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js398
-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_subtitles_settings_panel.js273
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js90
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_telemetry_enhancements.js230
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_1.js129
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_2.js448
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_3.js218
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js72
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js58
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleButtonOnNanDuration.js32
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js17
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js204
-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.js127
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_togglePositionChange.js58
-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_toggle_enabled.js99
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggle_videocontrols.js76
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggle_without_audio.js71
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_touch_toggle_enablepip.js242
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_urlbar_toggle.js456
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_videoEmptied.js155
-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.js1153
-rw-r--r--toolkit/components/pictureinpicture/tests/no-audio-track.webmbin0 -> 215529 bytes
-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-multiple-contexts.html19
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-pipDisabled.html18
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-with-iframe.html27
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-with-nan-video-duration.html14
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-with-sound.html20
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-with-webvtt.html66
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-without-audio.html18
-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
-rw-r--r--toolkit/components/pictureinpicture/tests/test-webvtt-1.vtt10
-rw-r--r--toolkit/components/pictureinpicture/tests/test-webvtt-2.vtt10
-rw-r--r--toolkit/components/pictureinpicture/tests/test-webvtt-3.vtt11
-rw-r--r--toolkit/components/pictureinpicture/tests/test-webvtt-4.vtt15
-rw-r--r--toolkit/components/pictureinpicture/tests/test-webvtt-5.vtt12
106 files changed, 11871 insertions, 0 deletions
diff --git a/toolkit/components/pictureinpicture/tests/browser.toml b/toolkit/components/pictureinpicture/tests/browser.toml
new file mode 100644
index 0000000000..f28a105698
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser.toml
@@ -0,0 +1,239 @@
+[DEFAULT]
+support-files = [
+ "click-event-helper.js",
+ "head.js",
+ "short.mp4",
+ "no-audio-track.webm",
+ "test-button-overlay.html",
+ "test-media-stream.html",
+ "test-opaque-overlay.html",
+ "test-page.html",
+ "test-page-without-audio.html",
+ "test-page-multiple-contexts.html",
+ "test-page-pipDisabled.html",
+ "test-page-with-iframe.html",
+ "test-page-with-sound.html",
+ "test-page-with-webvtt.html",
+ "test-pointer-events-none.html",
+ "test-reversed.html",
+ "test-transparent-nested-iframes.html",
+ "test-transparent-overlay-1.html",
+ "test-transparent-overlay-2.html",
+ "test-video.mp4",
+ "test-video-cropped.mp4",
+ "test-video-long.mp4",
+ "test-video-selection.html",
+ "test-video-vertical.mp4",
+ "test-webvtt-1.vtt",
+ "test-webvtt-2.vtt",
+ "test-webvtt-3.vtt",
+ "test-webvtt-4.vtt",
+ "test-webvtt-5.vtt",
+ "../../../../dom/media/test/gizmo.mp4",
+ "../../../../dom/media/test/owl.mp3",
+]
+
+prefs = [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled=false",
+ "media.videocontrols.picture-in-picture.enabled=true",
+ "media.videocontrols.picture-in-picture.video-toggle.always-show=true",
+ "media.videocontrols.picture-in-picture.video-toggle.enabled=true",
+ "media.videocontrols.picture-in-picture.video-toggle.has-used=true",
+ "media.videocontrols.picture-in-picture.video-toggle.position=\"right\"",
+ "media.videocontrols.picture-in-picture.video-toggle.testing=true",
+ "media.videocontrols.picture-in-picture.urlbar-button.enabled=true",
+]
+
+["browser_aaa_run_first_firstTimePiPToggleEvents.js"]
+
+["browser_aaa_telemetry_togglePiP.js"]
+
+["browser_audioScrubber.js"]
+
+["browser_backgroundTab.js"]
+
+["browser_cannotTriggerFromContent.js"]
+
+["browser_changePiPSrcInFullscreen.js"]
+
+["browser_closePipPause.js"]
+
+["browser_closePip_pageNavigationChanges.js"]
+
+["browser_closePlayer.js"]
+
+["browser_closeTab.js"]
+
+["browser_close_unpip_focus.js"]
+
+["browser_conflictingPips.js"]
+
+["browser_contextMenu.js"]
+skip-if = ["os == 'linux' && bits == 64 && os_version == '18.04'"] # Bug 1569205
+
+["browser_controlsHover.js"]
+
+["browser_cornerSnapping.js"]
+run-if = ["os == 'mac'"]
+
+["browser_dblclickFullscreen.js"]
+
+["browser_disableSwipeGestures.js"]
+skip-if = ["os == 'mac'"] # Bug 1840716
+
+["browser_durationChange.js"]
+
+["browser_flipIconWithRTL.js"]
+skip-if = [
+ "os == 'linux' && ccov", # Bug 1678091
+ "tsan", # Bug 1678091
+]
+
+["browser_fontSize_change.js"]
+
+["browser_fullscreen.js"]
+skip-if = [
+ "os == 'mac' && debug", # Bug 1566173
+ "os == 'linux'", # Bug 1664667
+]
+
+["browser_improved_controls.js"]
+
+["browser_keyboardClosePIPwithESC.js"]
+
+["browser_keyboardFullScreenPIPShortcut.js"]
+
+["browser_keyboardShortcut.js"]
+
+["browser_keyboardShortcutClosePIP.js"]
+
+["browser_keyboardShortcutWithNanDuration.js"]
+support-files = ["test-page-with-nan-video-duration.html"]
+
+["browser_keyboardToggle.js"]
+
+["browser_mediaStreamVideos.js"]
+
+["browser_mouseButtonVariation.js"]
+skip-if = [
+ "debug",
+ "os == 'linux' && bits == 64 && !debug", # Bug 1549875
+]
+
+["browser_multiPip.js"]
+
+["browser_nimbusDisplayDuration.js"]
+
+["browser_nimbusFirstTimeStyleVariant.js"]
+
+["browser_nimbusMessageFirstTimePip.js"]
+
+["browser_nimbusShowIconOnly.js"]
+
+["browser_noPlayerControlsOnMiddleRightClick.js"]
+
+["browser_noToggleOnAudio.js"]
+
+["browser_occluded_window.js"]
+
+["browser_playerControls.js"]
+
+["browser_preserveTabPipIconOverlay.js"]
+
+["browser_privateWindow.js"]
+
+["browser_removeVideoElement.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_subtitles_settings_panel.js"]
+
+["browser_tabIconOverlayPiP.js"]
+
+["browser_telemetry_enhancements.js"]
+
+["browser_text_tracks_webvtt_1.js"]
+
+["browser_text_tracks_webvtt_2.js"]
+
+["browser_text_tracks_webvtt_3.js"]
+
+["browser_thirdPartyIframe.js"]
+
+["browser_toggleAfterTabTearOutIn.js"]
+skip-if = [
+ "os == 'linux' && bits == 64", # Bug 1605546
+ "os == 'mac' && !debug", # Bug 1605546
+]
+
+["browser_toggleButtonOnNanDuration.js"]
+skip-if = ["os == 'linux' && !debug"] # Bug 1700504
+support-files = ["test-page-with-nan-video-duration.html"]
+
+["browser_toggleButtonOverlay.js"]
+skip-if = ["true"] # Bug 1546455
+
+["browser_toggleMode_2.js"]
+skip-if = [
+ "os == 'linux'", # Bug 1654971
+ "os == 'mac'", # 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
+ "apple_catalina && debug", # Bug 1605565
+]
+
+["browser_togglePositionChange.js"]
+skip-if = ["os == 'linux' && bits == 64 && !debug"] # Bug 1738532
+
+["browser_toggleSimple.js"]
+skip-if = ["os == 'linux'"] # Bug 1546455
+
+["browser_toggleTransparentOverlay-1.js"]
+skip-if = ["os == 'linux' && bits == 64"] # Bug 1552288
+
+["browser_toggleTransparentOverlay-2.js"]
+skip-if = ["os == 'linux' && bits == 64 && os_version == '18.04'"] # Bug 1546930
+
+["browser_toggle_enabled.js"]
+
+["browser_toggle_videocontrols.js"]
+
+["browser_toggle_without_audio.js"]
+
+["browser_touch_toggle_enablepip.js"]
+
+["browser_urlbar_toggle.js"]
+
+["browser_videoEmptied.js"]
+
+["browser_videoSelection.js"]
diff --git a/toolkit/components/pictureinpicture/tests/browser_aaa_run_first_firstTimePiPToggleEvents.js b/toolkit/components/pictureinpicture/tests/browser_aaa_run_first_firstTimePiPToggleEvents.js
new file mode 100644
index 0000000000..df6261584c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_aaa_run_first_firstTimePiPToggleEvents.js
@@ -0,0 +1,314 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const FIRST_TIME_PIP_TOGGLE_STYLES = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-wrapper": DEFAULT_TOGGLE_OPACITY,
+ },
+ hidden: [],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [],
+ },
+ },
+};
+
+const FIRST_CONTEXT_MENU_EXPECTED_EVENTS = [
+ [
+ "pictureinpicture",
+ "opened_method",
+ "contextMenu",
+ null,
+ { firstTimeToggle: "true" },
+ ],
+];
+
+const SECOND_CONTEXT_MENU_EXPECTED_EVENTS = [
+ [
+ "pictureinpicture",
+ "opened_method",
+ "contextMenu",
+ null,
+ { firstTimeToggle: "false" },
+ ],
+];
+
+const FIRST_TOGGLE_EXPECTED_EVENTS = [
+ ["pictureinpicture", "saw_toggle", "toggle", null, { firstTime: "true" }],
+ [
+ "pictureinpicture",
+ "opened_method",
+ "toggle",
+ null,
+ { firstTimeToggle: "true" },
+ ],
+];
+
+const SECOND_TOGGLE_EXPECTED_EVENTS = [
+ ["pictureinpicture", "saw_toggle", "toggle", null, { firstTime: "false" }],
+ [
+ "pictureinpicture",
+ "opened_method",
+ "toggle",
+ null,
+ { firstTimeToggle: "false" },
+ ],
+];
+
+/**
+ * This function will open the PiP window by clicking the toggle
+ * and then close the PiP window
+ * @param browser The current browser
+ * @param videoID The video element id
+ */
+async function openAndClosePipWithToggle(browser, videoID) {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ await prepareForToggleClick(browser, videoID);
+
+ await clearAllContentEvents();
+
+ // 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",
+ FIRST_TIME_PIP_TOGGLE_STYLES
+ );
+
+ 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 assertSawClickEventOnly(browser);
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(1, 1, {}, browser);
+ await assertSawMouseEvents(browser, true);
+}
+
+/**
+ * This function will open the PiP window by with the context menu
+ * @param browser The current browser
+ * @param videoID The video element id
+ */
+async function openAndClosePipWithContextMenu(browser, videoID) {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ let menu = document.getElementById("contentAreaContextMenu");
+ let popupshown = BrowserTestUtils.waitForPopupEvent(menu, "shown");
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "contextmenu",
+ },
+ browser
+ );
+
+ await popupshown;
+ let isContextMenuOpen = menu.state === "showing" || menu.state === "open";
+ ok(isContextMenuOpen, "Context menu is open");
+
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+
+ // clear content events
+ await clearAllContentEvents();
+
+ let hidden = BrowserTestUtils.waitForPopupEvent(menu, "hidden");
+ menu.activateItem(menu.querySelector("#context-video-pictureinpicture"));
+ await hidden;
+
+ 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);
+}
+
+async function clearAllContentEvents() {
+ // Clear everything.
+ await TestUtils.waitForCondition(() => {
+ Services.telemetry.clearEvents();
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).content;
+ return !events || !events.length;
+ });
+}
+
+add_task(async function test_eventTelemetry() {
+ Services.telemetry.clearEvents();
+ await clearAllContentEvents();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ Services.telemetry.setEventRecordingEnabled("pictureinpicture", true);
+ let videoID = "no-controls";
+
+ const PIP_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ // open with context menu for first time
+ await openAndClosePipWithContextMenu(browser, videoID);
+
+ let filter = {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "contextMenu",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ FIRST_CONTEXT_MENU_EXPECTED_EVENTS.length,
+ "content"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ FIRST_CONTEXT_MENU_EXPECTED_EVENTS,
+ filter,
+ { clear: true, process: "content" }
+ );
+
+ // open with toggle for first time
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ await openAndClosePipWithToggle(browser, videoID);
+
+ filter = {
+ category: "pictureinpicture",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ FIRST_TOGGLE_EXPECTED_EVENTS.length,
+ "content"
+ );
+
+ TelemetryTestUtils.assertEvents(FIRST_TOGGLE_EXPECTED_EVENTS, filter, {
+ clear: true,
+ process: "content",
+ });
+
+ // open with toggle for not first time
+ await openAndClosePipWithToggle(browser, videoID);
+
+ filter = {
+ category: "pictureinpicture",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ SECOND_TOGGLE_EXPECTED_EVENTS.length,
+ "content"
+ );
+
+ TelemetryTestUtils.assertEvents(SECOND_TOGGLE_EXPECTED_EVENTS, filter, {
+ clear: true,
+ process: "content",
+ });
+
+ // open with context menu for not first time
+ await openAndClosePipWithContextMenu(browser, videoID);
+
+ filter = {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "contextMenu",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ SECOND_CONTEXT_MENU_EXPECTED_EVENTS.length,
+ "content"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ SECOND_CONTEXT_MENU_EXPECTED_EVENTS,
+ filter,
+ { true: false, process: "content" }
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_aaa_telemetry_togglePiP.js b/toolkit/components/pictureinpicture/tests/browser_aaa_telemetry_togglePiP.js
new file mode 100644
index 0000000000..d6a7540e15
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_aaa_telemetry_togglePiP.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function getTelemetryToggleEnabled() {
+ const scalarData = Services.telemetry.getSnapshotForScalars(
+ "main",
+ false
+ ).parent;
+ return scalarData["pictureinpicture.toggle_enabled"];
+}
+
+/**
+ * Tests telemetry for user toggling on or off PiP.
+ */
+add_task(async () => {
+ const TOGGLE_PIP_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.enabled";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[TOGGLE_PIP_ENABLED_PREF, true]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ let contextPiPDisable = document.getElementById(
+ "context_HidePictureInPictureToggle"
+ );
+ contextPiPDisable.click();
+ const enabled = Services.prefs.getBoolPref(
+ TOGGLE_PIP_ENABLED_PREF,
+ false
+ );
+
+ Assert.equal(enabled, false, "PiP is disabled.");
+
+ await TestUtils.waitForCondition(() => {
+ return getTelemetryToggleEnabled() === false;
+ });
+
+ Assert.equal(
+ getTelemetryToggleEnabled(),
+ false,
+ "PiP is disabled according to Telemetry."
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[TOGGLE_PIP_ENABLED_PREF, true]],
+ });
+
+ await TestUtils.waitForCondition(() => {
+ return getTelemetryToggleEnabled() === true;
+ });
+
+ Assert.equal(
+ getTelemetryToggleEnabled(),
+ true,
+ "PiP is enabled according to Telemetry."
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_audioScrubber.js b/toolkit/components/pictureinpicture/tests/browser_audioScrubber.js
new file mode 100644
index 0000000000..d13d0d13ad
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_audioScrubber.js
@@ -0,0 +1,262 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const IMPROVED_CONTROLS_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.improved-video-controls.enabled";
+
+const videoID = "with-controls";
+
+async function getVideoVolume(browser, videoID) {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).volume;
+ });
+}
+
+async function getVideoMuted(browser, videoID) {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).muted;
+ });
+}
+
+add_task(async function test_audioScrubber() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[IMPROVED_CONTROLS_ENABLED_PREF, true]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let ratio = pipWin.innerWidth / pipWin.innerHeight;
+ let height = 750;
+ let width = Math.floor(ratio * height);
+ if (pipWin.innerHeight < height || pipWin.innerWidth < width) {
+ // Resize the PiP window so we know the audio scrubber is visible
+ let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ pipWin.resizeTo(width, height);
+ await resizePromise;
+ }
+
+ let audioScrubber = pipWin.document.getElementById("audio-scrubber");
+ audioScrubber.focus();
+
+ // Volume should be 1 and video should not be muted when opening this video
+ let actualVolume = await getVideoVolume(browser, videoID);
+ let expectedVolume = 1;
+
+ let actualMuted = await getVideoMuted(browser, videoID);
+
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ Number.EPSILON,
+ `The actual volume ${actualVolume}. The expected volume ${expectedVolume}`
+ );
+
+ ok(!actualMuted, "Video is not muted");
+
+ let volumeChangePromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "volumechange",
+ true
+ );
+
+ // Test that left arrow key changes volume by -0.1
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin);
+
+ await volumeChangePromise;
+
+ actualVolume = await getVideoVolume(browser, videoID);
+ expectedVolume = 0.9;
+
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ Number.EPSILON,
+ `The actual volume ${actualVolume}. The expected volume ${expectedVolume}`
+ );
+
+ // Test that right arrow key changes volume by +0.1 and does not exceed 1
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin);
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin);
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin);
+
+ actualVolume = await getVideoVolume(browser, videoID);
+ expectedVolume = 1;
+
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ Number.EPSILON,
+ `The actual volume ${actualVolume}. The expected volume ${expectedVolume}`
+ );
+
+ // Test that the mouse can move the audio scrubber to 0.5
+ let rect = audioScrubber.getBoundingClientRect();
+
+ volumeChangePromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "volumechange",
+ true
+ );
+
+ EventUtils.synthesizeMouse(
+ audioScrubber,
+ rect.width / 2,
+ rect.height / 2,
+ {},
+ pipWin
+ );
+
+ await volumeChangePromise;
+
+ actualVolume = await getVideoVolume(browser, videoID);
+ expectedVolume = 0.5;
+
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ 0.01,
+ `The actual volume ${actualVolume}. The expected volume ${expectedVolume}`
+ );
+
+ // Test muting and unmuting the video
+ let muteButton = pipWin.document.getElementById("audio");
+ volumeChangePromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "volumechange",
+ true
+ );
+ muteButton.click();
+ await volumeChangePromise;
+
+ ok(
+ await getVideoMuted(browser, videoID),
+ "The video is muted aftering clicking the mute button"
+ );
+ is(
+ audioScrubber.max,
+ "0",
+ "The max of the audio scrubber is 0, so the volume slider appears that the volume is 0"
+ );
+
+ volumeChangePromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "volumechange",
+ true
+ );
+ muteButton.click();
+ await volumeChangePromise;
+
+ ok(
+ !(await getVideoMuted(browser, videoID)),
+ "The video is muted aftering clicking the mute button"
+ );
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ 0.01,
+ `The volume is still ${actualVolume}.`
+ );
+
+ volumeChangePromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "volumechange",
+ true
+ );
+
+ // Test that the audio scrubber can be dragged from 0.5 to 0 and the video gets muted
+ EventUtils.synthesizeMouse(
+ audioScrubber,
+ rect.width / 2,
+ rect.height / 2,
+ { type: "mousedown" },
+ pipWin
+ );
+ EventUtils.synthesizeMouse(
+ audioScrubber,
+ 0,
+ rect.height / 2,
+ { type: "mousemove" },
+ pipWin
+ );
+ EventUtils.synthesizeMouse(
+ audioScrubber,
+ 0,
+ rect.height / 2,
+ { type: "mouseup" },
+ pipWin
+ );
+
+ await volumeChangePromise;
+
+ actualVolume = await getVideoVolume(browser, videoID);
+ expectedVolume = 0;
+
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ Number.EPSILON,
+ `The actual volume ${actualVolume}. The expected volume ${expectedVolume}`
+ );
+
+ ok(
+ await getVideoMuted(browser, videoID),
+ "Video is now muted because slider was moved to 0"
+ );
+
+ // Test that the left arrow key does not unmute the video and the volume remains at 0
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin);
+
+ actualVolume = await getVideoVolume(browser, videoID);
+
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ Number.EPSILON,
+ `The actual volume ${actualVolume}. The expected volume ${expectedVolume}`
+ );
+
+ ok(
+ await getVideoMuted(browser, videoID),
+ "Video is now muted because slider is still at 0"
+ );
+
+ volumeChangePromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "volumechange",
+ true
+ );
+
+ // Test that the right arrow key will increase the volume by +0.1 and will unmute the video
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin);
+
+ await volumeChangePromise;
+
+ actualVolume = await getVideoVolume(browser, videoID);
+ expectedVolume = 0.1;
+
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ Number.EPSILON,
+ `The actual volume ${actualVolume}. The expected volume ${expectedVolume}`
+ );
+
+ ok(
+ !(await getVideoMuted(browser, videoID)),
+ "Video is no longer muted because we moved the slider"
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_backgroundTab.js b/toolkit/components/pictureinpicture/tests/browser_backgroundTab.js
new file mode 100644
index 0000000000..e1f96748a5
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_backgroundTab.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+/**
+ * This test creates a PiP window, then switches to another tab and confirms
+ * that the PiP tab is still active.
+ */
+add_task(async () => {
+ let videoID = "no-controls";
+ let firstTab = gBrowser.selectedTab;
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let originatingTab = gBrowser.getTabForBrowser(browser);
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await BrowserTestUtils.switchTab(gBrowser, firstTab);
+
+ let switcher = gBrowser._getSwitcher();
+
+ Assert.equal(
+ switcher.getTabState(originatingTab),
+ switcher.STATE_LOADED,
+ "The originating browser tab should be in STATE_LOADED."
+ );
+ Assert.equal(
+ browser.docShellIsActive,
+ true,
+ "The docshell should be active in the originating tab"
+ );
+
+ // We need to destroy the current AsyncTabSwitcher to avoid
+ // tabrowser.shouldActivateDocShell going in the
+ // AsyncTabSwitcher.shouldActivateDocShell code path which isn't PiP aware.
+ switcher.destroy();
+
+ // Closing with window.close doesn't actually pause the video, so click
+ // the close button instead.
+ pipWin.document.getElementById("close").click();
+ await BrowserTestUtils.windowClosed(pipWin);
+
+ Assert.equal(
+ browser.docShellIsActive,
+ false,
+ "The docshell should be inactive in the originating tab"
+ );
+ }
+ );
+});
+
+/**
+ * This test creates a PiP window, then minimizes the browser and confirms
+ * that the PiP tab is still active.
+ */
+add_task(async () => {
+ let videoID = "no-controls";
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let originatingTab = gBrowser.getTabForBrowser(browser);
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let promiseSizeModeChange = BrowserTestUtils.waitForEvent(
+ window,
+ "sizemodechange"
+ );
+ window.minimize();
+ await promiseSizeModeChange;
+
+ let switcher = gBrowser._getSwitcher();
+
+ Assert.equal(
+ switcher.getTabState(originatingTab),
+ switcher.STATE_LOADED,
+ "The originating browser tab should be in STATE_LOADED."
+ );
+
+ await BrowserTestUtils.closeWindow(pipWin);
+
+ // Workaround bug 1782134.
+ window.restore();
+ }
+ );
+});
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_changePiPSrcInFullscreen.js b/toolkit/components/pictureinpicture/tests/browser_changePiPSrcInFullscreen.js
new file mode 100644
index 0000000000..e971f17296
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_changePiPSrcInFullscreen.js
@@ -0,0 +1,511 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+const NEW_VIDEO_ASPECT_RATIO = 1.334;
+
+async function switchVideoSource(browser, src) {
+ await ContentTask.spawn(browser, { src }, async ({ src }) => {
+ let doc = content.document;
+ let video = doc.getElementById("no-controls");
+ video.src = src;
+ });
+}
+
+/**
+ *
+ * @param {Object} actual The actual size and position of the window
+ * @param {Object} expected The expected size and position of the window
+ * @param {String} message A message to print before asserting the size and position
+ */
+function assertEvent(actual, expected, message) {
+ info(message);
+ isfuzzy(
+ actual.width,
+ expected.width,
+ ACCEPTABLE_DIFFERENCE,
+ `The actual width: ${actual.width}. The expected width: ${expected.width}`
+ );
+ isfuzzy(
+ actual.height,
+ expected.height,
+ ACCEPTABLE_DIFFERENCE,
+ `The actual height: ${actual.height}. The expected height: ${expected.height}`
+ );
+ isfuzzy(
+ actual.left,
+ expected.left,
+ ACCEPTABLE_DIFFERENCE,
+ `The actual left: ${actual.left}. The expected left: ${expected.left}`
+ );
+ isfuzzy(
+ actual.top,
+ expected.top,
+ ACCEPTABLE_DIFFERENCE,
+ `The actual top: ${actual.top}. The expected top: ${expected.top}`
+ );
+}
+
+/**
+ * This test is our control test. This tests that when the PiP window exits
+ * fullscreen it will return to the size it was before being fullscreened.
+ */
+add_task(async function testNoSrcChangeFullscreen() {
+ // After opening the PiP window, it is resized to 640x360. There is a change
+ // the PiP window will open with that size. To prevent that we override the
+ // last saved position so we open at (0, 0) and 300x300.
+ overrideSavedPosition(0, 0, 300, 300);
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ let pipWin = await triggerPictureInPicture(browser, "no-controls");
+ let controls = pipWin.document.getElementById("controls");
+ const screen = pipWin.screen;
+
+ let resizeEventArray = [];
+ pipWin.addEventListener("resize", event => {
+ let win = event.target;
+ let obj = {
+ width: win.innerWidth,
+ height: win.innerHeight,
+ left: win.screenLeft,
+ top: win.screenTop,
+ };
+ resizeEventArray.push(obj);
+ });
+
+ // Move the PiP window to an unsaved location
+ let left = 100;
+ let top = 100;
+ pipWin.moveTo(left, top);
+
+ await BrowserTestUtils.waitForCondition(
+ () => pipWin.screenLeft === 100 && pipWin.screenTop === 100,
+ "Waiting for PiP to move to 100, 100"
+ );
+
+ let width = 640;
+ let height = 360;
+
+ let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ pipWin.resizeTo(width, height);
+ await resizePromise;
+
+ Assert.equal(
+ resizeEventArray.length,
+ 1,
+ "resizeEventArray should have 1 event"
+ );
+
+ let actualEvent = resizeEventArray.splice(0, 1)[0];
+ let expectedEvent = { width, height, left, top };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly positioned before fullscreen"
+ );
+
+ await promiseFullscreenEntered(pipWin, async () => {
+ EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "Double-click caused us to enter fullscreen."
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => resizeEventArray.length === 1,
+ "Waiting for resizeEventArray to have 1 event"
+ );
+
+ actualEvent = resizeEventArray.splice(0, 1)[0];
+ expectedEvent = {
+ width: screen.width,
+ height: screen.height,
+ left: screen.left,
+ top: screen.top,
+ };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly fullscreened before switching source"
+ );
+
+ await promiseFullscreenExited(pipWin, async () => {
+ EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.ok(
+ !pipWin.document.fullscreenElement,
+ "Double-click caused us to exit fullscreen."
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => resizeEventArray.length >= 1,
+ "Waiting for resizeEventArray to have 1 event, got " +
+ resizeEventArray.length
+ );
+
+ actualEvent = resizeEventArray.splice(0, 1)[0];
+ expectedEvent = { width, height, left, top };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly positioned after exiting fullscreen"
+ );
+
+ await ensureMessageAndClosePiP(browser, "no-controls", pipWin, false);
+
+ clearSavedPosition();
+ }
+ );
+});
+
+/**
+ * This function tests changing the src of a Picture-in-Picture player while
+ * the player is fullscreened and then ensuring the that video stays
+ * fullscreened after the src change and that the player will resize to the new
+ * video size.
+ */
+add_task(async function testChangingSameSizeVideoSrcFullscreen() {
+ // After opening the PiP window, it is resized to 640x360. There is a change
+ // the PiP window will open with that size. To prevent that we override the
+ // last saved position so we open at (0, 0) and 300x300.
+ overrideSavedPosition(0, 0, 300, 300);
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ let pipWin = await triggerPictureInPicture(browser, "no-controls");
+ let controls = pipWin.document.getElementById("controls");
+ const screen = pipWin.screen;
+ let sandbox = sinon.createSandbox();
+ let resizeToVideoSpy = sandbox.spy(pipWin, "resizeToVideo");
+
+ let resizeEventArray = [];
+ pipWin.addEventListener("resize", event => {
+ let win = event.target;
+ let obj = {
+ width: win.innerWidth,
+ height: win.innerHeight,
+ left: win.screenLeft,
+ top: win.screenTop,
+ };
+ resizeEventArray.push(obj);
+ });
+
+ // Move the PiP window to an unsaved location
+ let left = 100;
+ let top = 100;
+ pipWin.moveTo(left, top);
+
+ await BrowserTestUtils.waitForCondition(
+ () => pipWin.screenLeft === 100 && pipWin.screenTop === 100,
+ "Waiting for PiP to move to 100, 100"
+ );
+
+ let width = 640;
+ let height = 360;
+
+ let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ pipWin.resizeTo(width, height);
+ await resizePromise;
+
+ Assert.equal(
+ resizeEventArray.length,
+ 1,
+ "resizeEventArray should have 1 event"
+ );
+
+ let actualEvent = resizeEventArray.splice(0, 1)[0];
+ let expectedEvent = { width, height, left, top };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly positioned before fullscreen"
+ );
+
+ await promiseFullscreenEntered(pipWin, async () => {
+ EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "Double-click caused us to enter fullscreen."
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => resizeEventArray.length === 1,
+ "Waiting for resizeEventArray to have 1 event"
+ );
+
+ actualEvent = resizeEventArray.splice(0, 1)[0];
+ expectedEvent = {
+ width: screen.width,
+ height: screen.height,
+ left: screen.left,
+ top: screen.top,
+ };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly fullscreened before switching source"
+ );
+
+ await switchVideoSource(browser, "test-video.mp4");
+
+ await BrowserTestUtils.waitForCondition(
+ () => resizeToVideoSpy.calledOnce,
+ "Waiting for deferredResize to be updated"
+ );
+
+ await promiseFullscreenExited(pipWin, async () => {
+ EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.ok(
+ !pipWin.document.fullscreenElement,
+ "Double-click caused us to exit fullscreen."
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => resizeEventArray.length >= 1,
+ "Waiting for resizeEventArray to have 1 event, got " +
+ resizeEventArray.length
+ );
+
+ actualEvent = resizeEventArray.splice(0, 1)[0];
+ expectedEvent = { width, height, left, top };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly positioned after exiting fullscreen"
+ );
+
+ sandbox.restore();
+ await ensureMessageAndClosePiP(browser, "no-controls", pipWin, false);
+
+ clearSavedPosition();
+ }
+ );
+});
+
+/**
+ * This is similar to the previous test but in this test we switch to a video
+ * with a different aspect ratio to confirm that the PiP window will take the
+ * new aspect ratio after exiting fullscreen. We also exit fullscreen with the
+ * escape key instead of double clicking in this test.
+ */
+add_task(async function testChangingDifferentSizeVideoSrcFullscreen() {
+ // After opening the PiP window, it is resized to 640x360. There is a change
+ // the PiP window will open with that size. To prevent that we override the
+ // last saved position so we open at (0, 0) and 300x300.
+ overrideSavedPosition(0, 0, 300, 300);
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ let pipWin = await triggerPictureInPicture(browser, "no-controls");
+ let controls = pipWin.document.getElementById("controls");
+ const screen = pipWin.screen;
+ let sandbox = sinon.createSandbox();
+ let resizeToVideoSpy = sandbox.spy(pipWin, "resizeToVideo");
+
+ let resizeEventArray = [];
+ pipWin.addEventListener("resize", event => {
+ let win = event.target;
+ let obj = {
+ width: win.innerWidth,
+ height: win.innerHeight,
+ left: win.screenLeft,
+ top: win.screenTop,
+ };
+ resizeEventArray.push(obj);
+ });
+
+ // Move the PiP window to an unsaved location
+ let left = 100;
+ let top = 100;
+ pipWin.moveTo(left, top);
+
+ await BrowserTestUtils.waitForCondition(
+ () => pipWin.screenLeft === 100 && pipWin.screenTop === 100,
+ "Waiting for PiP to move to 100, 100"
+ );
+
+ let width = 640;
+ let height = 360;
+
+ let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ pipWin.resizeTo(width, height);
+ await resizePromise;
+
+ Assert.equal(
+ resizeEventArray.length,
+ 1,
+ "resizeEventArray should have 1 event"
+ );
+
+ let actualEvent = resizeEventArray.splice(0, 1)[0];
+ let expectedEvent = { width, height, left, top };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly positioned before fullscreen"
+ );
+
+ await promiseFullscreenEntered(pipWin, async () => {
+ EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "Double-click caused us to enter fullscreen."
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => resizeEventArray.length === 1,
+ "Waiting for resizeEventArray to have 1 event"
+ );
+
+ actualEvent = resizeEventArray.splice(0, 1)[0];
+ expectedEvent = {
+ width: screen.width,
+ height: screen.height,
+ left: screen.left,
+ top: screen.top,
+ };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly fullscreened before switching source"
+ );
+
+ let previousWidth = pipWin.getDeferredResize().width;
+
+ await switchVideoSource(browser, "test-video-long.mp4");
+
+ // Confirm that we are updating the `deferredResize` and not actually resizing
+ await BrowserTestUtils.waitForCondition(
+ () => resizeToVideoSpy.calledOnce,
+ "Waiting for deferredResize to be updated"
+ );
+
+ // Confirm that we updated the deferredResize to the new width
+ await BrowserTestUtils.waitForCondition(
+ () => previousWidth !== pipWin.getDeferredResize().width,
+ "Waiting for deferredResize to update"
+ );
+
+ await promiseFullscreenExited(pipWin, async () => {
+ EventUtils.synthesizeKey("KEY_Escape", {}, pipWin);
+ });
+
+ Assert.ok(
+ !pipWin.document.fullscreenElement,
+ "Escape key caused us to exit fullscreen."
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => resizeEventArray.length >= 1,
+ "Waiting for resizeEventArray to have 1 event, got " +
+ resizeEventArray.length
+ );
+
+ actualEvent = resizeEventArray.splice(0, 1)[0];
+ expectedEvent = {
+ width: height * NEW_VIDEO_ASPECT_RATIO,
+ height,
+ left,
+ top,
+ };
+
+ // When two resize events happen very close together we optimize by
+ // "coalescing" the two resizes into a single resize event. Sometimes
+ // the events aren't "coalesced" together (I don't know why) so I check
+ // if the most recent event is what we are looking for and if it is not
+ // then I'll wait for the resize event we are looking for.
+ if (
+ Math.abs(
+ actualEvent.width - expectedEvent.width <= ACCEPTABLE_DIFFERENCE
+ )
+ ) {
+ // The exit fullscreen resize events were "coalesced".
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly positioned after exiting fullscreen"
+ );
+ } else {
+ // For some reason the exit fullscreen resize events weren't "coalesced"
+ // so we have to wait for the next resize event.
+ await BrowserTestUtils.waitForCondition(
+ () => resizeEventArray.length === 1,
+ "Waiting for resizeEventArray to have 1 event"
+ );
+
+ actualEvent = resizeEventArray.splice(0, 1)[0];
+
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly positioned after exiting fullscreen"
+ );
+ }
+
+ sandbox.restore();
+ await ensureMessageAndClosePiP(browser, "no-controls", pipWin, false);
+
+ clearSavedPosition();
+ }
+ );
+});
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_closePip_pageNavigationChanges.js b/toolkit/components/pictureinpicture/tests/browser_closePip_pageNavigationChanges.js
new file mode 100644
index 0000000000..c64b5f2b14
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_closePip_pageNavigationChanges.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the pip window closes when the pagehide page lifecycle event
+ * is not detected and if a video is not loaded with a src.
+ */
+add_task(async function test_close_empty_pip_window() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let videoID = "with-controls";
+
+ await ensureVideosReady(browser);
+
+ let emptied = SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ let video = content.document.getElementById(args.videoID);
+ info("Waiting for emptied event to be called");
+ await ContentTaskUtils.waitForEvent(video, "emptied");
+ });
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ await SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ let video = content.document.getElementById(args.videoID);
+ video.removeAttribute("src");
+ video.load();
+ });
+ await emptied;
+ await pipClosed;
+ }
+ );
+});
+
+/**
+ * Tests that the pip window closes when navigating to another page
+ * via the pagehide page lifecycle event.
+ */
+add_task(async function test_close_pagehide() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let videoID = "with-controls";
+
+ await ensureVideosReady(browser);
+ await SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ let video = content.document.getElementById(args.videoID);
+ video.onemptied = () => {
+ // Since we handle pagehide first, handle emptied should not be invoked
+ ok(false, "emptied not expected to be called");
+ };
+ });
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ await SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ content.location.href = "otherpage.html";
+ });
+
+ await pipClosed;
+ }
+ );
+});
+
+/**
+ * Tests that the pip window remains open if the pagehide page lifecycle
+ * event is not detected and if the video is still loaded with a src.
+ */
+add_task(async function test_open_pip_window_history_nav() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let videoID = "with-controls";
+
+ await ensureVideosReady(browser);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ let popStatePromise = ContentTaskUtils.waitForEvent(
+ content,
+ "popstate"
+ );
+ content.history.pushState({}, "new page", "test-page-with-sound.html");
+ content.history.back();
+ await popStatePromise;
+ });
+
+ ok(!pipWin.closed, "pip windows should still be open");
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ }
+ );
+});
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_close_unpip_focus.js b/toolkit/components/pictureinpicture/tests/browser_close_unpip_focus.js
new file mode 100644
index 0000000000..f535add96a
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_close_unpip_focus.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests that closing a pip window will not focus on the originating video's window.
+add_task(async function test_close_button_focus() {
+ // initialize
+ let win1 = await BrowserTestUtils.openNewBrowserWindow();
+ let win2 = await BrowserTestUtils.openNewBrowserWindow();
+ // Open PiP
+ let videoID = "with-controls";
+ let pipTab = await BrowserTestUtils.openNewForegroundTab(
+ win1.gBrowser,
+ TEST_PAGE
+ );
+ let browser = pipTab.linkedBrowser;
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let focus = BrowserTestUtils.waitForEvent(win2, "focus", true);
+ win2.focus();
+ await focus;
+
+ // Close PiP
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ let oldFocus = win1.focus;
+ win1.focus = () => {
+ ok(false, "Window is not supposed to be focused on");
+ };
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ ok(true, "Window did not get focus");
+
+ win1.focus = oldFocus;
+ // close windows
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+});
+
+// Tests that pressing the unpip button will focus on the originating video's window.
+add_task(async function test_unpip_button_focus() {
+ // initialize
+ let win1 = await BrowserTestUtils.openNewBrowserWindow();
+ let win2 = await BrowserTestUtils.openNewBrowserWindow();
+ // Open PiP
+ let videoID = "with-controls";
+ let pipTab = await BrowserTestUtils.openNewForegroundTab(
+ win1.gBrowser,
+ TEST_PAGE
+ );
+ let browser = pipTab.linkedBrowser;
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let focus = BrowserTestUtils.waitForEvent(win2, "focus", true);
+ win2.focus();
+ await focus;
+
+ // Close PiP
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("unpip");
+ let pipWinFocusedPromise = BrowserTestUtils.waitForEvent(win1, "focus", true);
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+
+ await pipClosed;
+ await pipWinFocusedPromise;
+ ok(true, "Originating window got focus");
+
+ // close windows
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_conflictingPips.js b/toolkit/components/pictureinpicture/tests/browser_conflictingPips.js
new file mode 100644
index 0000000000..cf9e27e327
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_conflictingPips.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * If multiple PiPs try to open in the same place, they should not overlap.
+ */
+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");
+
+ await ensureMessageAndClosePiP(browser, "with-controls", firstPip, false);
+ info("Closed first PiP to save location");
+
+ let secondPip = await triggerPictureInPicture(browser, "with-controls");
+ ok(secondPip, "Got second PiP window");
+
+ let thirdPip = await triggerPictureInPicture(browser, "no-controls");
+ ok(thirdPip, "Got third PiP window");
+
+ Assert.ok(
+ secondPip.screenX != thirdPip.screenX ||
+ secondPip.screenY != thirdPip.screenY,
+ "Conflicting PiPs were successfully opened in different locations"
+ );
+
+ await ensureMessageAndClosePiP(
+ browser,
+ "with-controls",
+ secondPip,
+ false
+ );
+ info("Second PiP was still open and is now closed");
+
+ await ensureMessageAndClosePiP(browser, "no-controls", thirdPip, false);
+ info("Third PiP was still open and is now closed");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_contextMenu.js b/toolkit/components/pictureinpicture/tests/browser_contextMenu.js
new file mode 100644
index 0000000000..ce6a85e91c
--- /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 => {
+ let contextMenu = 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."
+ );
+
+ contextMenu.activateItem(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.");
+
+ contextMenu = await openContextMenu(browser, videoId);
+
+ info("Context menu is open again.");
+
+ contextMenu.activateItem(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_controlsHover.js b/toolkit/components/pictureinpicture/tests/browser_controlsHover.js
new file mode 100644
index 0000000000..7a3a33733f
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_controlsHover.js
@@ -0,0 +1,191 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests functionality for the hover states of the various controls for the Picture-in-Picture
+ * video window.
+ */
+add_task(async () => {
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ 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.");
+
+ const l10n = new Localization(
+ ["toolkit/pictureinpicture/pictureinpicture.ftl"],
+ true
+ );
+
+ let [
+ close,
+ play,
+ unmute,
+ unpip,
+ subtitles,
+ pause,
+ mute,
+ fullscreenEnter,
+ fullscreenExit,
+ ] = l10n.formatMessagesSync([
+ {
+ id: "pictureinpicture-close-btn",
+ args: {
+ shortcut: ShortcutUtils.prettifyShortcut(
+ pipWin.document.getElementById("closeShortcut")
+ ),
+ },
+ },
+ { id: "pictureinpicture-play-btn" },
+ {
+ id: "pictureinpicture-unmute-btn",
+ args: {
+ shortcut: ShortcutUtils.prettifyShortcut(
+ pipWin.document.getElementById("unMuteShortcut")
+ ),
+ },
+ },
+ { id: "pictureinpicture-unpip-btn" },
+ { id: "pictureinpicture-subtitles-btn" },
+ { id: "pictureinpicture-pause-btn" },
+ {
+ id: "pictureinpicture-mute-btn",
+ args: {
+ shortcut: ShortcutUtils.prettifyShortcut(
+ pipWin.document.getElementById("muteShortcut")
+ ),
+ },
+ },
+ {
+ id: "pictureinpicture-fullscreen-btn2",
+ args: {
+ shortcut: ShortcutUtils.prettifyShortcut(
+ pipWin.document.getElementById("fullscreenToggleShortcut")
+ ),
+ },
+ },
+ {
+ id: "pictureinpicture-exit-fullscreen-btn2",
+ args: {
+ shortcut: ShortcutUtils.prettifyShortcut(
+ pipWin.document.getElementById("fullscreenToggleShortcut")
+ ),
+ },
+ },
+ ]);
+
+ let closeButton = pipWin.document.getElementById("close");
+ let playPauseButton = pipWin.document.getElementById("playpause");
+ let unpipButton = pipWin.document.getElementById("unpip");
+ let muteUnmuteButton = pipWin.document.getElementById("audio");
+ let subtitlesButton = pipWin.document.getElementById("closed-caption");
+ let fullscreenButton = pipWin.document.getElementById("fullscreen");
+
+ // checks hover title for close button
+ await pipWin.document.l10n.translateFragment(closeButton);
+ Assert.equal(
+ close.attributes[1].value,
+ closeButton.getAttribute("tooltip"),
+ "The close button title matches Fluent string"
+ );
+
+ // checks hover title for play button
+ await pipWin.document.l10n.translateFragment(playPauseButton);
+ Assert.equal(
+ pause.attributes[1].value,
+ playPauseButton.getAttribute("tooltip"),
+ "The play button title matches Fluent string"
+ );
+
+ // checks hover title for unpip button
+ await pipWin.document.l10n.translateFragment(unpipButton);
+ Assert.equal(
+ unpip.attributes[1].value,
+ unpipButton.getAttribute("tooltip"),
+ "The unpip button title matches Fluent string"
+ );
+
+ // checks hover title for subtitles button
+ await pipWin.document.l10n.translateFragment(subtitlesButton);
+ Assert.equal(
+ subtitles.attributes[1].value,
+ subtitlesButton.getAttribute("tooltip"),
+ "The subtitles button title matches Fluent string"
+ );
+
+ // checks hover title for unmute button
+ await pipWin.document.l10n.translateFragment(muteUnmuteButton);
+ Assert.equal(
+ mute.attributes[1].value,
+ muteUnmuteButton.getAttribute("tooltip"),
+ "The Unmute button title matches Fluent string"
+ );
+
+ // Pause the video
+ let pausedPromise = waitForVideoEvent("pause");
+ EventUtils.synthesizeMouseAtCenter(playPauseButton, {}, pipWin);
+ await pausedPromise;
+ ok(await isVideoPaused(browser, videoID), "The video is paused.");
+
+ // checks hover title for pause button
+ await pipWin.document.l10n.translateFragment(playPauseButton);
+ Assert.equal(
+ play.attributes[1].value,
+ playPauseButton.getAttribute("tooltip"),
+ "The pause button title matches Fluent string"
+ );
+
+ // Mute the video
+ let mutedPromise = waitForVideoEvent("volumechange");
+ EventUtils.synthesizeMouseAtCenter(muteUnmuteButton, {}, pipWin);
+ await mutedPromise;
+ ok(await isVideoMuted(browser, videoID), "The audio is muted.");
+
+ // checks hover title for mute button
+ await pipWin.document.l10n.translateFragment(muteUnmuteButton);
+ Assert.equal(
+ unmute.attributes[1].value,
+ muteUnmuteButton.getAttribute("tooltip"),
+ "The mute button title matches Fluent string"
+ );
+
+ // checks hover title for enter fullscreen button
+ await pipWin.document.l10n.translateFragment(fullscreenButton);
+ Assert.equal(
+ fullscreenEnter.attributes[1].value,
+ fullscreenButton.getAttribute("tooltip"),
+ "The enter fullscreen button title matches Fluent string"
+ );
+
+ // enable fullscreen
+ await promiseFullscreenEntered(pipWin, async () => {
+ EventUtils.synthesizeMouseAtCenter(fullscreenButton, {}, pipWin);
+ });
+
+ // checks hover title for exit fullscreen button
+ await pipWin.document.l10n.translateFragment(fullscreenButton);
+ Assert.equal(
+ fullscreenExit.attributes[1].value,
+ fullscreenButton.getAttribute("tooltip"),
+ "The exit fullscreen button title matches Fluent string"
+ );
+ }
+ );
+});
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..c30d316fe3
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js
@@ -0,0 +1,101 @@
+/* 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 () => {
+ EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "Double-click caused us to enter fullscreen."
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => {
+ let close = pipWin.document.getElementById("close");
+ let opacity = parseFloat(pipWin.getComputedStyle(close).opacity);
+ return opacity == 0.0;
+ },
+ "Close button in player should have reached 0.0 opacity",
+ 100,
+ 100
+ );
+
+ // First, we'll test exiting fullscreen by double-clicking again
+ // on the document body.
+
+ await promiseFullscreenExited(pipWin, async () => {
+ 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 () => {
+ 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_disableSwipeGestures.js b/toolkit/components/pictureinpicture/tests/browser_disableSwipeGestures.js
new file mode 100644
index 0000000000..b8eddf33f2
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_disableSwipeGestures.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/gfx/layers/apz/test/mochitest/apz_test_native_event_utils.js",
+ this
+);
+
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, "with-controls");
+
+ let receivedSwipeGestureEvents = false;
+ pipWin.addEventListener("MozSwipeGestureMayStart", () => {
+ receivedSwipeGestureEvents = true;
+ });
+
+ const wheelEventPromise = BrowserTestUtils.waitForEvent(pipWin, "wheel");
+
+ // Try swiping left to right.
+ await panLeftToRightBegin(pipWin, 100, 100, 100);
+ await panLeftToRightEnd(pipWin, 100, 100, 100);
+
+ // Wait a wheel event and a couple of frames to give a chance to receive
+ // the MozSwipeGestureMayStart event.
+ await wheelEventPromise;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ Assert.ok(
+ !receivedSwipeGestureEvents,
+ "No swipe gesture events observed"
+ );
+
+ 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_fontSize_change.js b/toolkit/components/pictureinpicture/tests/browser_fontSize_change.js
new file mode 100644
index 0000000000..df0fa4f403
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_fontSize_change.js
@@ -0,0 +1,152 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const videoID = "with-controls";
+const TEXT_TRACK_FONT_SIZE =
+ "media.videocontrols.picture-in-picture.display-text-tracks.size";
+const ACCEPTABLE_DIFF = 1;
+
+const checkFontSize = (actual, expected, str) => {
+ let fs1 = actual.substring(0, actual.length - 2);
+ let fs2 = expected;
+ let diff = Math.abs(fs1 - fs2);
+ info(`Actual font size: ${fs1}. Expected font size: ${fs2}`);
+ Assert.lessOrEqual(diff, ACCEPTABLE_DIFF, str);
+};
+
+function getFontSize(pipBrowser) {
+ return SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ return content.window.getComputedStyle(textTracks).fontSize;
+ });
+}
+
+function promiseResize(win, width, height) {
+ if (win.outerWidth == width && win.outerHeight == height) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve => {
+ // More than one "resize" might be received if the window was recently
+ // created.
+ win.addEventListener("resize", () => {
+ if (win.outerWidth == width && win.outerHeight == height) {
+ resolve();
+ }
+ });
+ win.resizeTo(width, height);
+ });
+}
+
+/**
+ * Tests the font size is correct for PiP windows
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE_WITH_WEBVTT,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // move PiP window to 0, 0 so resizing the window doesn't go offscreen
+ pipWin.moveTo(0, 0);
+
+ let width = pipWin.innerWidth;
+ let height = pipWin.innerHeight;
+
+ await promiseResize(pipWin, Math.round(250 * (width / height)), 250);
+
+ width = pipWin.innerWidth;
+ height = pipWin.innerHeight;
+
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ let fontSize = await getFontSize(pipBrowser);
+ checkFontSize(
+ fontSize,
+ Math.round(height * 0.06 * 10) / 10,
+ "The medium font size is .06 of the PiP window height"
+ );
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+
+ // change font size to small
+ await SpecialPowers.pushPrefEnv({
+ set: [[TEXT_TRACK_FONT_SIZE, "small"]],
+ });
+
+ pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ pipBrowser = pipWin.document.getElementById("browser");
+
+ fontSize = await getFontSize(pipBrowser);
+ checkFontSize(fontSize, 14, "The small font size is the minimum 14px");
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+
+ // change font size to large
+ await SpecialPowers.pushPrefEnv({
+ set: [[TEXT_TRACK_FONT_SIZE, "large"]],
+ });
+
+ pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ pipBrowser = pipWin.document.getElementById("browser");
+
+ fontSize = await getFontSize(pipBrowser);
+ checkFontSize(
+ fontSize,
+ Math.round(height * 0.09 * 10) / 10,
+ "The large font size is .09 of the PiP window height"
+ );
+
+ // resize PiP window to a larger size
+ width = pipWin.innerWidth * 2;
+ height = pipWin.innerHeight * 2;
+ await promiseResize(pipWin, width, height);
+
+ fontSize = await getFontSize(pipBrowser);
+ checkFontSize(fontSize, 40, "The large font size is the max of 40px");
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+
+ // change font size to small
+ await SpecialPowers.pushPrefEnv({
+ set: [[TEXT_TRACK_FONT_SIZE, "small"]],
+ });
+
+ pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ pipBrowser = pipWin.document.getElementById("browser");
+
+ fontSize = await getFontSize(pipBrowser);
+ checkFontSize(
+ fontSize,
+ Math.round(height * 0.03 * 10) / 10,
+ "The small font size is .03 of the PiP window height"
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_fullscreen.js b/toolkit/components/pictureinpicture/tests/browser_fullscreen.js
new file mode 100644
index 0000000000..439901c0f3
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_fullscreen.js
@@ -0,0 +1,161 @@
+/* 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) {
+ info(`Start test of video fullscreen for video ${videoID}.`);
+ await promiseFullscreenEntered(window, () => {
+ return SpecialPowers.spawn(browser, [videoID], videoID => {
+ let video = this.content.document.getElementById(videoID);
+ return video.requestFullscreen();
+ });
+ });
+
+ info(`Entered video fullscreen, about to mouseover the video.`);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ info(`Mouseover complete.`);
+
+ let args = { videoID, toggleID: DEFAULT_TOGGLE_STYLES.rootID };
+
+ await promiseFullscreenExited(window, () => {
+ return SpecialPowers.spawn(browser, [args], args => {
+ let { videoID, toggleID } = args;
+ let video = this.content.document.getElementById(videoID);
+ let toggle = video.openOrClosedShadowRoot.getElementById(toggleID);
+ ok(
+ ContentTaskUtils.isHidden(toggle),
+ `Toggle should be hidden in video fullscreen mode for video ${videoID}.`
+ );
+ return this.content.document.exitFullscreen();
+ });
+ });
+
+ info(`Exited video fullscreen.`);
+ }
+ }
+ );
+});
+
+/**
+ * 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 => {
+ info(`Start test of browser fullscreen.`);
+ await promiseFullscreenEntered(window, () => {
+ return SpecialPowers.spawn(browser, [], () => {
+ return this.content.document.body.requestFullscreen();
+ });
+ });
+
+ info(`Entered browser fullscreen.`);
+
+ for (let videoID of VIDEOS) {
+ info(`About to mouseover for video ${videoID}.`);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ info(`Mouseover complete.`);
+
+ 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.isHidden(toggle),
+ `Toggle should be hidden in body fullscreen mode for video ${videoID}.`
+ );
+ });
+ }
+
+ await promiseFullscreenExited(window, () => {
+ return SpecialPowers.spawn(browser, [], () => {
+ return this.content.document.exitFullscreen();
+ });
+ });
+
+ info(`Exited browser fullscreen.`);
+ }
+ );
+});
+
+/**
+ * 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 for video ${videoID}.`);
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+
+ // need to focus first, since fullscreen request will be blocked otherwise
+ await SimpleTest.promiseFocus(window);
+
+ await promiseFullscreenEntered(window, () => {
+ return SpecialPowers.spawn(browser, [], () => {
+ return this.content.document.body.requestFullscreen();
+ });
+ });
+
+ await pipClosed;
+ ok(
+ pipWin.closed,
+ `Picture-In-Picture successfully closed for video ${videoID}.`
+ );
+
+ await promiseFullscreenExited(window, () => {
+ return SpecialPowers.spawn(browser, [], () => {
+ return this.content.document.exitFullscreen();
+ });
+ });
+ }
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_improved_controls.js b/toolkit/components/pictureinpicture/tests/browser_improved_controls.js
new file mode 100644
index 0000000000..deadc8d53b
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_improved_controls.js
@@ -0,0 +1,303 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const TEST_PAGE_LONG = TEST_ROOT + "test-video-selection.html";
+
+const IMPROVED_CONTROLS_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.improved-video-controls.enabled";
+
+async function getVideoCurrentTime(browser, videoID) {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).currentTime;
+ });
+}
+
+async function getVideoDuration(browser, videoID) {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).duration;
+ });
+}
+
+async function timestampUpdated(timestampEl, expectedTimestamp) {
+ await BrowserTestUtils.waitForMutationCondition(
+ timestampEl,
+ { childList: true },
+ () => {
+ return expectedTimestamp === timestampEl.textContent;
+ }
+ );
+}
+
+function checkTimeCloseEnough(actual, expected, message) {
+ let equal = Math.abs(actual - expected);
+ if (equal <= 0.5) {
+ is(equal <= 0.5, true, message);
+ } else {
+ is(actual, expected, message);
+ }
+}
+
+/**
+ * Tests the functionality of improved Picture-in-picture
+ * playback controls.
+ */
+add_task(async () => {
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ 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();
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[IMPROVED_CONTROLS_ENABLED_PREF, true]],
+ });
+
+ // Open the video in PiP
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let fullscreenButton = pipWin.document.getElementById("fullscreen");
+ let seekForwardButton = pipWin.document.getElementById("seekForward");
+ let seekBackwardButton = pipWin.document.getElementById("seekBackward");
+
+ // Try seek forward button
+ let seekedForwardPromise = waitForVideoEvent("seeked");
+ EventUtils.synthesizeMouseAtCenter(seekForwardButton, {}, pipWin);
+ ok(await seekedForwardPromise, "The Forward button triggers");
+
+ // Try seek backward button
+ let seekedBackwardPromise = waitForVideoEvent("seeked");
+ EventUtils.synthesizeMouseAtCenter(seekBackwardButton, {}, pipWin);
+ ok(await seekedBackwardPromise, "The Backward button triggers");
+
+ // The Fullsreen button appears when the pref is enabled and the fullscreen hidden property is set to false
+ Assert.ok(!fullscreenButton.hidden, "The Fullscreen button is visible");
+
+ // The seek Forward button appears when the pref is enabled and the seek forward button hidden property is set to false
+ Assert.ok(!seekForwardButton.hidden, "The Forward button is visible");
+
+ // The seek Backward button appears when the pref is enabled and the seek backward button hidden property is set to false
+ Assert.ok(!seekBackwardButton.hidden, "The Backward button is visible");
+
+ // CLose the PIP window
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[IMPROVED_CONTROLS_ENABLED_PREF, false]],
+ });
+
+ // Open the video in PiP
+ pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ fullscreenButton = pipWin.document.getElementById("fullscreen");
+ seekForwardButton = pipWin.document.getElementById("seekForward");
+ seekBackwardButton = pipWin.document.getElementById("seekBackward");
+
+ // The Fullsreen button disappears when the pref is disabled and the fullscreen hidden property is set to true
+ Assert.ok(
+ fullscreenButton.hidden,
+ "The Fullscreen button is not visible"
+ );
+
+ // The seek Forward button disappears when the pref is disabled and the seek forward button hidden property is set to true
+ Assert.ok(seekForwardButton.hidden, "The Forward button is not visible");
+
+ // The seek Backward button disappears when the pref is disabled and the seek backward button hidden property is set to true
+ Assert.ok(
+ seekBackwardButton.hidden,
+ "The Backward button is not visible"
+ );
+ }
+ );
+});
+
+/**
+ * Tests the functionality of Picture-in-picture
+ * video scrubber
+ */
+add_task(async function testVideoScrubber() {
+ let videoID = "long";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_LONG,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[IMPROVED_CONTROLS_ENABLED_PREF, true]],
+ });
+
+ // Open the video in PiP
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let scrubber = pipWin.document.getElementById("scrubber");
+ scrubber.focus();
+
+ let currentTime = await getVideoCurrentTime(browser, videoID);
+ let expectedVideoTime = 0;
+ const duration = await getVideoDuration(browser, videoID);
+ checkTimeCloseEnough(
+ currentTime,
+ expectedVideoTime,
+ "Video current time is 0"
+ );
+
+ let timestampEl = pipWin.document.getElementById("timestamp");
+ let expectedTimestamp = "0:00 / 0:08";
+
+ // Wait for the timestamp to update
+ await timestampUpdated(timestampEl, expectedTimestamp);
+ let actualTimestamp = timestampEl.textContent;
+ is(actualTimestamp, expectedTimestamp, "Timestamp reads 0:00 / 0:08");
+
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin);
+
+ currentTime = await getVideoCurrentTime(browser, videoID);
+ expectedVideoTime = 5;
+ checkTimeCloseEnough(
+ currentTime,
+ expectedVideoTime,
+ "Video current time is 5"
+ );
+
+ expectedTimestamp = "0:05 / 0:08";
+ await timestampUpdated(timestampEl, expectedTimestamp);
+ actualTimestamp = timestampEl.textContent;
+ is(actualTimestamp, expectedTimestamp, "Timestamp reads 0:05 / 0:08");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin);
+
+ currentTime = await getVideoCurrentTime(browser, videoID);
+ expectedVideoTime = 0;
+ checkTimeCloseEnough(
+ currentTime,
+ expectedVideoTime,
+ "Video current time is 0"
+ );
+
+ expectedTimestamp = "0:00 / 0:08";
+ await timestampUpdated(timestampEl, expectedTimestamp);
+ actualTimestamp = timestampEl.textContent;
+ is(actualTimestamp, expectedTimestamp, "Timestamp reads 0:00 / 0:08");
+
+ let rect = scrubber.getBoundingClientRect();
+
+ EventUtils.synthesizeMouse(
+ scrubber,
+ rect.width / 2,
+ rect.height / 2,
+ {},
+ pipWin
+ );
+
+ expectedVideoTime = duration / 2;
+ currentTime = await getVideoCurrentTime(browser, videoID);
+ checkTimeCloseEnough(
+ currentTime,
+ expectedVideoTime,
+ "Video current time is 3.98..."
+ );
+
+ expectedTimestamp = "0:04 / 0:08";
+ await timestampUpdated(timestampEl, expectedTimestamp);
+ actualTimestamp = timestampEl.textContent;
+ is(actualTimestamp, expectedTimestamp, "Timestamp reads 0:04 / 0:08");
+
+ EventUtils.synthesizeMouse(
+ scrubber,
+ rect.width / 2,
+ rect.height / 2,
+ { type: "mousedown" },
+ pipWin
+ );
+
+ EventUtils.synthesizeMouse(
+ scrubber,
+ rect.width,
+ rect.height / 2,
+ { type: "mousemove" },
+ pipWin
+ );
+
+ EventUtils.synthesizeMouse(
+ scrubber,
+ rect.width,
+ rect.height / 2,
+ { type: "mouseup" },
+ pipWin
+ );
+
+ expectedVideoTime = duration;
+ currentTime = await getVideoCurrentTime(browser, videoID);
+ checkTimeCloseEnough(
+ currentTime,
+ expectedVideoTime,
+ "Video current time is 7.96..."
+ );
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+ }
+ );
+});
+
+/**
+ * Tests the behavior of the scrubber and position/duration indicator for a
+ * video with an invalid/non-finite duration.
+ */
+add_task(async function testInvalidDuration() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_NAN_VIDEO_DURATION,
+ gBrowser,
+ },
+ async browser => {
+ const videoID = "nan-duration";
+
+ // This tests skips calling ensureVideosReady, because canplaythrough
+ // will never fire for the NaN duration video.
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[IMPROVED_CONTROLS_ENABLED_PREF, true]],
+ });
+
+ // Open the video in PiP
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Both the scrubber and the duration should be hidden.
+ let timestampEl = pipWin.document.getElementById("timestamp");
+ ok(timestampEl.hidden, "Timestamp in the PIP window should be hidden.");
+
+ let scrubberEl = pipWin.document.getElementById("scrubber");
+ ok(
+ scrubberEl.hidden,
+ "Scrubber control in the PIP window should be hidden"
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardClosePIPwithESC.js b/toolkit/components/pictureinpicture/tests/browser_keyboardClosePIPwithESC.js
new file mode 100644
index 0000000000..14dd3d1b7a
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_keyboardClosePIPwithESC.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * tests that the ESC key would stop the player and close the PiP player floating window
+ */
+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();
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "The Picture-in-Picture window is not there.");
+ ok(
+ !(await isVideoPaused(browser, videoID)),
+ "The video is paused, but should not."
+ );
+ ok(
+ !pipWin.document.fullscreenElement,
+ "PiP should not yet be in fullscreen."
+ );
+
+ let controls = pipWin.document.getElementById("controls");
+ await promiseFullscreenEntered(pipWin, async () => {
+ EventUtils.sendMouseEvent({ type: "dblclick" }, controls, pipWin);
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "Double-click should have caused to enter fullscreen."
+ );
+
+ await promiseFullscreenExited(pipWin, async () => {
+ EventUtils.synthesizeKey("KEY_Escape", {}, pipWin);
+ });
+
+ ok(
+ !pipWin.document.fullscreenElement,
+ "ESC should have caused to leave fullscreen."
+ );
+ ok(
+ !(await isVideoPaused(browser, videoID)),
+ "The video is paused, but should not."
+ );
+
+ // Try to close the PiP window via the ESC button, since now it is not in fullscreen anymore.
+ EventUtils.synthesizeKey("KEY_Escape", {}, pipWin);
+
+ // then the PiP should have been closed
+ ok(pipWin.closed, "Picture-in-Picture window is not closed, but should.");
+ // and the video should not be playing anymore
+ ok(
+ await isVideoPaused(browser, videoID),
+ "The video is not paused, but should."
+ );
+
+ await BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardFullScreenPIPShortcut.js b/toolkit/components/pictureinpicture/tests/browser_keyboardFullScreenPIPShortcut.js
new file mode 100644
index 0000000000..da4191cb0e
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_keyboardFullScreenPIPShortcut.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 F-key would enter and exit full screen mode in PiP for the default locale (en-US).
+ */
+add_task(async () => {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ let browser = tab.linkedBrowser;
+ let videoID = "with-controls";
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "The Picture-in-Picture window is there.");
+
+ ok(
+ !pipWin.document.fullscreenElement,
+ "PiP should not yet be in fullscreen."
+ );
+
+ await promiseFullscreenEntered(pipWin, async () => {
+ EventUtils.synthesizeKey("f", {}, pipWin);
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "F-key should have caused to enter fullscreen."
+ );
+
+ await promiseFullscreenExited(pipWin, async () => {
+ EventUtils.synthesizeKey("f", {}, pipWin);
+ });
+
+ ok(
+ !pipWin.document.fullscreenElement,
+ "F-key should have caused to leave fullscreen."
+ );
+
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js
new file mode 100644
index 0000000000..a81d99e18c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js
@@ -0,0 +1,134 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const PIP_SHORTCUT_OPEN_EVENTS = [
+ {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "shortcut",
+ },
+];
+
+const PIP_SHORTCUT_CLOSE_EVENTS = [
+ {
+ category: "pictureinpicture",
+ method: "closed_method",
+ object: "shortcut",
+ },
+];
+
+/**
+ * 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 => {
+ Services.telemetry.clearEvents();
+ 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);
+
+ let openFilter = {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "shortcut",
+ };
+ await waitForTelemeryEvents(
+ openFilter,
+ PIP_SHORTCUT_OPEN_EVENTS.length,
+ "content"
+ );
+ TelemetryTestUtils.assertEvents(PIP_SHORTCUT_OPEN_EVENTS, openFilter, {
+ clear: true,
+ process: "content",
+ });
+
+ // 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.");
+
+ let closeFilter = {
+ category: "pictureinpicture",
+ method: "closed_method",
+ object: "shortcut",
+ };
+ await waitForTelemeryEvents(
+ closeFilter,
+ PIP_SHORTCUT_CLOSE_EVENTS.length,
+ "parent"
+ );
+ TelemetryTestUtils.assertEvents(PIP_SHORTCUT_CLOSE_EVENTS, closeFilter, {
+ clear: true,
+ process: "parent",
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutClosePIP.js b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutClosePIP.js
new file mode 100644
index 0000000000..a85cdd5a63
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutClosePIP.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that keyboard shortcut ctr + w / cmd + w closing PIP window
+ */
+
+add_task(async function test_pip_close_keyboard_shortcut() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ 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.");
+
+ EventUtils.synthesizeKey("w", { accelKey: true }, pipWin);
+ await BrowserTestUtils.windowClosed(pipWin);
+ ok(await isVideoPaused(browser, VIDEO_ID), "The video is paused");
+ ok(pipWin.closed, "Closed PIP");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutWithNanDuration.js b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutWithNanDuration.js
new file mode 100644
index 0000000000..c812dc3e4e
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutWithNanDuration.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_pip_keyboard_shortcut_with_nan_video_duration() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_NAN_VIDEO_DURATION,
+ gBrowser,
+ },
+ async browser => {
+ const VIDEO_ID = "test-video";
+
+ await SpecialPowers.spawn(browser, [VIDEO_ID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ if (video.readyState < content.HTMLMediaElement.HAVE_ENOUGH_DATA) {
+ info(`Waiting for 'canplaythrough' for ${videoID}`);
+ await ContentTaskUtils.waitForEvent(video, "canplaythrough");
+ }
+ });
+
+ 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.");
+
+ pipWin.close();
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardToggle.js b/toolkit/components/pictureinpicture/tests/browser_keyboardToggle.js
new file mode 100644
index 0000000000..a70100de85
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_keyboardToggle.js
@@ -0,0 +1,32 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests to ensure that tabbing to the pip button and pressing space works
+ * to open the picture-in-picture window.
+ */
+add_task(async () => {
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ // Open the video in PiP
+ let pipWin = await triggerPictureInPicture(browser, videoID, () => {
+ EventUtils.synthesizeKey("KEY_Tab", {}); // play button
+ EventUtils.synthesizeKey("KEY_Tab", {}); // pip button
+ EventUtils.synthesizeKey(" ", {});
+ });
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ );
+});
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..5744c83496
--- /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 assertSawClickEventOnly(browser);
+
+ 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..2821c0d484
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_multiPip.js
@@ -0,0 +1,225 @@
+/* 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"];
+}
+
+/**
+ * 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_nimbusDisplayDuration.js b/toolkit/components/pictureinpicture/tests/browser_nimbusDisplayDuration.js
new file mode 100644
index 0000000000..a2e7e66b40
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_nimbusDisplayDuration.js
@@ -0,0 +1,214 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+const TOGGLE_HAS_USED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+const TOGGLE_FIRST_SEEN_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.first-seen-secs";
+
+/**
+ * This tests that the first-time toggle doesn't change to the icon toggle.
+ */
+add_task(async function test_experiment_control_displayDuration() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_FIRST_SEEN_PREF, 0],
+ [TOGGLE_HAS_USED_PREF, false],
+ ],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ const hasUsed = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF);
+ const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF);
+
+ Assert.ok(!hasUsed, "has-used is false and toggle is not icon");
+ Assert.notEqual(firstSeen, 0, "First seen should not be 0");
+ }
+ );
+});
+
+/**
+ * This tests that the first-time toggle changes to the icon toggle
+ * if the displayDuration end date is reached or passed.
+ */
+add_task(async function test_experiment_displayDuration_end_date_was_reached() {
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "pictureinpicture",
+ value: {
+ displayDuration: 1,
+ },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_FIRST_SEEN_PREF, 222],
+ [TOGGLE_HAS_USED_PREF, false],
+ ],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ const hasUsed = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF);
+ const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF);
+
+ Assert.ok(hasUsed, "has-used is true and toggle is icon");
+ Assert.equal(firstSeen, 222, "First seen should remain unchanged");
+ }
+ );
+
+ await doExperimentCleanup();
+});
+
+/**
+ * This tests that the first-time toggle does not change to the icon toggle
+ * if the displayDuration end date is not yet reached or passed.
+ */
+add_task(async function test_experiment_displayDuration_end_date_not_reached() {
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "pictureinpicture",
+ value: {
+ displayDuration: 5,
+ },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ const currentDateSec = Math.round(Date.now() / 1000);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_FIRST_SEEN_PREF, currentDateSec],
+ [TOGGLE_HAS_USED_PREF, false],
+ ],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ const hasUsed = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF);
+ const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF);
+
+ Assert.ok(!hasUsed, "has-used is false and toggle is not icon");
+ Assert.equal(
+ firstSeen,
+ currentDateSec,
+ "First seen should remain unchanged"
+ );
+ }
+ );
+
+ await doExperimentCleanup();
+});
+
+/**
+ * This tests that the toggle does not change to the icon toggle if duration is negative.
+ */
+add_task(async function test_experiment_displayDuration_negative_duration() {
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "pictureinpicture",
+ value: {
+ displayDuration: -1,
+ },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_FIRST_SEEN_PREF, 0],
+ [TOGGLE_HAS_USED_PREF, false],
+ ],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ const hasUsed = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF);
+ const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF);
+
+ Assert.ok(!hasUsed, "has-used is false and toggle is not icon");
+ Assert.notEqual(firstSeen, 0, "First seen should not be 0");
+ }
+ );
+
+ await doExperimentCleanup();
+});
+
+/**
+ * This tests that first-seen is only recorded for the first-time toggle.
+ */
+add_task(async function test_experiment_displayDuration_already_icon() {
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "pictureinpicture",
+ value: {
+ displayDuration: 1,
+ },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_FIRST_SEEN_PREF, 0],
+ [TOGGLE_HAS_USED_PREF, true],
+ ],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF);
+ Assert.equal(firstSeen, 0, "First seen should be 0");
+ }
+ );
+
+ await doExperimentCleanup();
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_nimbusFirstTimeStyleVariant.js b/toolkit/components/pictureinpicture/tests/browser_nimbusFirstTimeStyleVariant.js
new file mode 100644
index 0000000000..492dc6851d
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_nimbusFirstTimeStyleVariant.js
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+const EXPERIMENT_CLASS_NAME = "experiment";
+
+/**
+ * This tests that the original PiP toggle design is shown.
+ */
+add_task(async function test_experiment_control_toggle_style() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ const PIP_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ await SpecialPowers.spawn(
+ browser,
+ [EXPERIMENT_CLASS_NAME],
+ async EXPERIMENT_CLASS_NAME => {
+ let video = content.document.getElementById("with-controls");
+ let shadowRoot = video.openOrClosedShadowRoot;
+
+ let controlsContainer =
+ shadowRoot.querySelector(".controlsContainer");
+ let pipWrapper = shadowRoot.querySelector(".pip-wrapper");
+ let pipExplainer = shadowRoot.querySelector(".pip-explainer");
+
+ Assert.ok(
+ !controlsContainer.classList.contains(EXPERIMENT_CLASS_NAME)
+ );
+ Assert.ok(!pipWrapper.classList.contains(EXPERIMENT_CLASS_NAME));
+ Assert.ok(
+ ContentTaskUtils.isVisible(pipExplainer),
+ "The PiP message should be visible on the toggle"
+ );
+ }
+ );
+ }
+ );
+});
+
+/**
+ * This tests that the variant PiP toggle design is shown if Nimbus
+ * variable `oldToggle` is false.
+ */
+add_task(async function test_experiment_toggle_style() {
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "pictureinpicture",
+ value: {
+ oldToggle: false,
+ },
+ });
+
+ registerCleanupFunction(async function () {
+ await doExperimentCleanup();
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ const PIP_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ await SpecialPowers.spawn(
+ browser,
+ [EXPERIMENT_CLASS_NAME],
+ async EXPERIMENT_CLASS_NAME => {
+ let video = content.document.getElementById("with-controls");
+ let shadowRoot = video.openOrClosedShadowRoot;
+
+ let controlsContainer =
+ shadowRoot.querySelector(".controlsContainer");
+ let pipWrapper = shadowRoot.querySelector(".pip-wrapper");
+ let pipExplainer = shadowRoot.querySelector(".pip-explainer");
+
+ Assert.ok(
+ controlsContainer.classList.contains(EXPERIMENT_CLASS_NAME)
+ );
+ Assert.ok(pipWrapper.classList.contains(EXPERIMENT_CLASS_NAME));
+ Assert.ok(
+ ContentTaskUtils.isHidden(pipExplainer),
+ "The PiP message should not be visible on the toggle"
+ );
+ }
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_nimbusMessageFirstTimePip.js b/toolkit/components/pictureinpicture/tests/browser_nimbusMessageFirstTimePip.js
new file mode 100644
index 0000000000..e998b4f65d
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_nimbusMessageFirstTimePip.js
@@ -0,0 +1,121 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+const PIP_EXPERIMENT_MESSAGE = "Hello world message";
+const PIP_EXPERIMENT_TITLE = "Hello world title";
+
+/**
+ * This tests that the original DTD string is shown for the PiP toggle
+ */
+add_task(async function test_experiment_control() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ const l10n = new Localization(
+ ["branding/brand.ftl", "toolkit/global/videocontrols.ftl"],
+ true
+ );
+
+ let pipExplainerMessage = l10n.formatValueSync(
+ "videocontrols-picture-in-picture-explainer3"
+ );
+
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ const PIP_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ await SpecialPowers.spawn(
+ browser,
+ [pipExplainerMessage],
+ async function (pipExplainerMessage) {
+ let video = content.document.getElementById("with-controls");
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let pipButton = shadowRoot.querySelector(".pip-explainer");
+
+ Assert.equal(
+ pipButton.textContent.trim(),
+ pipExplainerMessage,
+ "The PiP explainer is default"
+ );
+ }
+ );
+ }
+ );
+});
+
+/**
+ * This tests that the experiment message is shown for the PiP toggle
+ */
+add_task(async function test_experiment_message() {
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "pictureinpicture",
+ value: {
+ title: PIP_EXPERIMENT_TITLE,
+ message: PIP_EXPERIMENT_MESSAGE,
+ },
+ });
+
+ registerCleanupFunction(async function () {
+ await doExperimentCleanup();
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ const PIP_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ await SpecialPowers.spawn(
+ browser,
+ [PIP_EXPERIMENT_MESSAGE, PIP_EXPERIMENT_TITLE],
+ async function (PIP_EXPERIMENT_MESSAGE, PIP_EXPERIMENT_TITLE) {
+ let video = content.document.getElementById("with-controls");
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let pipExplainer = shadowRoot.querySelector(".pip-explainer");
+ let pipLabel = shadowRoot.querySelector(".pip-label");
+
+ Assert.equal(
+ pipExplainer.textContent.trim(),
+ PIP_EXPERIMENT_MESSAGE,
+ "The PiP explainer is being overridden by the experiment"
+ );
+
+ Assert.equal(
+ pipLabel.textContent.trim(),
+ PIP_EXPERIMENT_TITLE,
+ "The PiP label is being overridden by the experiment"
+ );
+ }
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_nimbusShowIconOnly.js b/toolkit/components/pictureinpicture/tests/browser_nimbusShowIconOnly.js
new file mode 100644
index 0000000000..7c6dcea01b
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_nimbusShowIconOnly.js
@@ -0,0 +1,114 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+const PIP_EXPERIMENT_MESSAGE = "Hello world message";
+const PIP_EXPERIMENT_TITLE = "Hello world title";
+
+/**
+ * This tests that the original DTD string is shown for the PiP toggle
+ */
+add_task(async function test_experiment_control() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ const l10n = new Localization(
+ ["branding/brand.ftl", "toolkit/global/videocontrols.ftl"],
+ true
+ );
+
+ let pipExplainerMessage = l10n.formatValueSync(
+ "videocontrols-picture-in-picture-explainer3"
+ );
+
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ const PIP_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ await SpecialPowers.spawn(
+ browser,
+ [pipExplainerMessage],
+ async function (pipExplainerMessage) {
+ let video = content.document.getElementById("with-controls");
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let pipButton = shadowRoot.querySelector(".pip-explainer");
+
+ Assert.equal(
+ pipButton.textContent.trim(),
+ pipExplainerMessage,
+ "The PiP explainer is default"
+ );
+ }
+ );
+ }
+ );
+});
+
+/**
+ * This tests that the experiment is showing the icon only
+ */
+add_task(async function test_experiment_iconOnly() {
+ let experimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "pictureinpicture",
+ value: {
+ showIconOnly: true,
+ },
+ });
+
+ registerCleanupFunction(async function () {
+ await experimentCleanup();
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ const PIP_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let video = content.document.getElementById("with-controls");
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let pipExpanded = shadowRoot.querySelector(".pip-expanded");
+ let pipIcon = shadowRoot.querySelector("div.pip-icon");
+
+ Assert.ok(
+ ContentTaskUtils.isHidden(pipExpanded),
+ "The PiP explainer hidden by the experiment"
+ );
+
+ Assert.ok(
+ ContentTaskUtils.isVisible(pipIcon),
+ "The PiP icon is visible by the experiment"
+ );
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_noPlayerControlsOnMiddleRightClick.js b/toolkit/components/pictureinpicture/tests/browser_noPlayerControlsOnMiddleRightClick.js
new file mode 100644
index 0000000000..e021900cad
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_noPlayerControlsOnMiddleRightClick.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that no player controls are triggered by middle or
+ * right click.
+ */
+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 => {
+ 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);
+
+ let playPause = pipWin.document.getElementById("playpause");
+ let audioButton = pipWin.document.getElementById("audio");
+
+ // Middle click the pause button
+ EventUtils.synthesizeMouseAtCenter(playPause, { button: 1 }, pipWin);
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused.");
+
+ // Right click the pause button
+ EventUtils.synthesizeMouseAtCenter(playPause, { button: 2 }, pipWin);
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused.");
+
+ // Middle click the mute button
+ EventUtils.synthesizeMouseAtCenter(audioButton, { button: 1 }, pipWin);
+ ok(!(await isVideoMuted(browser, videoID)), "The audio is not muted.");
+
+ // Right click the mute button
+ EventUtils.synthesizeMouseAtCenter(audioButton, { button: 2 }, pipWin);
+ ok(!(await isVideoMuted(browser, videoID)), "The audio is not muted.");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js b/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js
new file mode 100644
index 0000000000..b6d434cf8c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js
@@ -0,0 +1,46 @@
+/* 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_occluded_window.js b/toolkit/components/pictureinpicture/tests/browser_occluded_window.js
new file mode 100644
index 0000000000..6ee66f2ade
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_occluded_window.js
@@ -0,0 +1,258 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the forceAppWindowActive flag is correctly set whenever a PiP window is opened
+ * and closed across multiple tabs on the same browser window.
+ */
+add_task(async function forceActiveMultiPiPTabs() {
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ let bc = browser.ownerGlobal.browsingContext;
+ info("is window active: " + bc.isActive);
+
+ info("Opening new tab");
+ let newTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_PAGE
+ );
+ let newTabBrowser = newTab.linkedBrowser;
+ await ensureVideosReady(newTabBrowser);
+
+ ok(!bc.forceAppWindowActive, "Forced window active should be false");
+ info("is window active: " + bc.isActive);
+
+ info("Now opening PiP windows");
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ ok(
+ bc.forceAppWindowActive,
+ "Forced window active should be true since PiP is open"
+ );
+ info("is window active: " + bc.isActive);
+
+ let newTabPiPWin = await triggerPictureInPicture(newTabBrowser, videoID);
+ ok(newTabPiPWin, "Got Picture-in-Picture window in new tab");
+ ok(
+ bc.forceAppWindowActive,
+ "Force window active should still be true after opening a new PiP window in new tab"
+ );
+ info("is window active: " + bc.isActive);
+
+ let pipClosedNewTab = BrowserTestUtils.domWindowClosed(newTabPiPWin);
+ let pipUnloadedNewTab = BrowserTestUtils.waitForEvent(
+ newTabPiPWin,
+ "unload"
+ );
+ let closeButtonNewTab = newTabPiPWin.document.getElementById("close");
+ info("Selecting close button");
+ EventUtils.synthesizeMouseAtCenter(closeButtonNewTab, {}, newTabPiPWin);
+ info("Waiting for PiP window to close");
+ await pipUnloadedNewTab;
+ await pipClosedNewTab;
+
+ ok(
+ bc.forceAppWindowActive,
+ "Force window active should still be true after removing new tab's PiP window"
+ );
+
+ info("is window active: " + bc.isActive);
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let pipUnloaded = BrowserTestUtils.waitForEvent(pipWin, "unload");
+ let closeButton = pipWin.document.getElementById("close");
+ info("Selecting close button");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ info("Waiting for PiP window to close");
+ await pipUnloaded;
+ await pipClosed;
+
+ ok(
+ !bc.forceAppWindowActive,
+ "Force window active should now be false after removing the last PiP window"
+ );
+
+ info("is window active: " + bc.isActive);
+
+ await BrowserTestUtils.removeTab(newTab);
+ }
+ );
+});
+
+/**
+ * Tests that the forceAppWindowActive flag is correctly set when a tab with PiP enabled is
+ * moved to another window.
+ */
+add_task(async function forceActiveMovePiPToWindow() {
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ info("Opening first tab");
+
+ await ensureVideosReady(browser);
+
+ let tab = gBrowser.getTabForBrowser(browser);
+ let bc = browser.ownerGlobal.browsingContext;
+
+ info("is window active: " + bc.isActive);
+
+ ok(!bc.forceAppWindowActive, "Forced window active should be false");
+ info("is window active: " + bc.isActive);
+
+ info("Now opening PiP windows");
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window in first tab.");
+
+ ok(
+ bc.forceAppWindowActive,
+ "Forced window active should be true since PiP is open"
+ );
+ info("is window active: " + bc.isActive);
+
+ let swapDocShellsPromise = BrowserTestUtils.waitForEvent(
+ browser,
+ "SwapDocShells"
+ );
+ let tabClosePromise = BrowserTestUtils.waitForEvent(tab, "TabClose");
+ let tabSwapPiPPromise = BrowserTestUtils.waitForEvent(
+ tab,
+ "TabSwapPictureInPicture"
+ );
+ info("Replacing tab with window");
+ let newWindow = gBrowser.replaceTabWithWindow(tab);
+ let newWinLoadedPromise = BrowserTestUtils.waitForEvent(
+ newWindow,
+ "load"
+ );
+
+ info("Waiting for new window to initialize after swap");
+ await Promise.all([
+ tabSwapPiPPromise,
+ swapDocShellsPromise,
+ tabClosePromise,
+ newWinLoadedPromise,
+ ]);
+
+ let newWindowBC = newWindow.browsingContext;
+ tab = newWindow.gBrowser.selectedTab;
+
+ ok(
+ !bc.forceAppWindowActive,
+ "Force window active should no longer be true after moving the previous tab to a new window"
+ );
+ info("is window active: " + bc.isActive);
+ ok(
+ newWindowBC.forceAppWindowActive,
+ "Force window active should be true for new window since PiP is open"
+ );
+ info("is secondary window active: " + newWindowBC.isActive);
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let pipUnloaded = BrowserTestUtils.waitForEvent(pipWin, "unload");
+ let closeButton = pipWin.document.getElementById("close");
+ info("Selecting close button");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ info("Waiting for PiP window to close");
+ await pipUnloaded;
+ await pipClosed;
+
+ ok(
+ !newWindowBC.forceAppWindowActive,
+ "Force window active should now be false for new window after removing the last PiP window"
+ );
+ info("is secondary window active: " + newWindowBC.isActive);
+
+ await BrowserTestUtils.removeTab(tab);
+ }
+ );
+});
+
+/**
+ * Tests that the forceAppWindowActive flag is correctly set when multiple PiP
+ * windows are created for a single PiP window.
+ */
+add_task(async function forceActiveMultiPiPSamePage() {
+ let videoID1 = "with-controls";
+ let videoID2 = "no-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ let bc = browser.ownerGlobal.browsingContext;
+
+ ok(
+ !bc.forceAppWindowActive,
+ "Forced window active should be false at the start of the test"
+ );
+ info("is window active: " + bc.isActive);
+
+ let pipWin1 = await triggerPictureInPicture(browser, videoID1);
+ ok(pipWin1, "Got Picture-in-Picture window 1.");
+
+ ok(
+ bc.forceAppWindowActive,
+ "Forced window active should be true since PiP is open"
+ );
+ info("is window active: " + bc.isActive);
+
+ let pipWin2 = await triggerPictureInPicture(browser, videoID2);
+ ok(pipWin2, "Got Picture-in-Picture window 2.");
+
+ ok(
+ bc.forceAppWindowActive,
+ "Forced window active should be true after opening another PiP window on the same page"
+ );
+ info("is window active: " + bc.isActive);
+
+ let pipClosed1 = BrowserTestUtils.domWindowClosed(pipWin1);
+ let pipUnloaded1 = BrowserTestUtils.waitForEvent(pipWin1, "unload");
+ let closeButton1 = pipWin1.document.getElementById("close");
+ info("Selecting close button");
+ EventUtils.synthesizeMouseAtCenter(closeButton1, {}, pipWin1);
+ info("Waiting for PiP window to close");
+ await pipUnloaded1;
+ await pipClosed1;
+
+ ok(
+ bc.forceAppWindowActive,
+ "Force window active should still be true after removing PiP window 1"
+ );
+ info("is window active: " + bc.isActive);
+
+ let pipClosed2 = BrowserTestUtils.domWindowClosed(pipWin2);
+ let pipUnloaded2 = BrowserTestUtils.waitForEvent(pipWin2, "unload");
+ let closeButton2 = pipWin2.document.getElementById("close");
+ info("Selecting close button");
+ EventUtils.synthesizeMouseAtCenter(closeButton2, {}, pipWin2);
+ info("Waiting for PiP window to close");
+ await pipUnloaded2;
+ await pipClosed2;
+
+ ok(
+ !bc.forceAppWindowActive,
+ "Force window active should now be false after removing PiP window 2"
+ );
+ info("is window active: " + bc.isActive);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_playerControls.js b/toolkit/components/pictureinpicture/tests/browser_playerControls.js
new file mode 100644
index 0000000000..9929da597a
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_playerControls.js
@@ -0,0 +1,86 @@
+/* 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 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(browser, videoID)), "The audio is playing.");
+ EventUtils.synthesizeMouseAtCenter(audioButton, {}, pipWin);
+ await mutedPromise;
+ ok(await isVideoMuted(browser, videoID), "The audio is muted.");
+
+ // Try the unmute button
+ let unmutedPromise = waitForVideoEvent("volumechange");
+ EventUtils.synthesizeMouseAtCenter(audioButton, {}, pipWin);
+ await unmutedPromise;
+ ok(!(await isVideoMuted(browser, videoID)), "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_preserveTabPipIconOverlay.js b/toolkit/components/pictureinpicture/tests/browser_preserveTabPipIconOverlay.js
new file mode 100644
index 0000000000..55cd003a2c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_preserveTabPipIconOverlay.js
@@ -0,0 +1,167 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+const EVENTUTILS_URL =
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js";
+var EventUtils = {};
+
+Services.scriptloader.loadSubScript(EVENTUTILS_URL, EventUtils);
+
+async function detachTab(tab) {
+ let newWindowPromise = new Promise((resolve, reject) => {
+ let observe = (win, topic, data) => {
+ Services.obs.removeObserver(observe, "domwindowopened");
+ resolve(win);
+ };
+ Services.obs.addObserver(observe, "domwindowopened");
+ });
+
+ await EventUtils.synthesizePlainDragAndDrop({
+ srcElement: tab,
+
+ // destElement is null because tab detaching happens due
+ // to a drag'n'drop on an invalid drop target.
+ destElement: null,
+
+ // don't move horizontally because that could cause a tab move
+ // animation, and there's code to prevent a tab detaching if
+ // the dragged tab is released while the animation is running.
+ stepX: 0,
+ stepY: 100,
+ });
+
+ return newWindowPromise;
+}
+
+/**
+ * Tests that tabs dragged between windows with PiP open, the pip attribute stays
+ */
+add_task(async function test_dragging_pip_to_other_window() {
+ // initialize
+ let win1 = await BrowserTestUtils.openNewBrowserWindow();
+ let win2 = await BrowserTestUtils.openNewBrowserWindow();
+
+ let pipTab = await BrowserTestUtils.openNewForegroundTab(
+ win1.gBrowser,
+ TEST_PAGE
+ );
+ let destTab = await BrowserTestUtils.openNewForegroundTab(win2.gBrowser);
+
+ let awaitCloseEventPromise = BrowserTestUtils.waitForEvent(
+ pipTab,
+ "TabClose"
+ );
+ let tabSwapPictureInPictureEventPromise = BrowserTestUtils.waitForEvent(
+ pipTab,
+ "TabSwapPictureInPicture"
+ );
+
+ // Open PiP
+ let videoID = "with-controls";
+ let browser = pipTab.linkedBrowser;
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // tear out window
+ let effect = EventUtils.synthesizeDrop(
+ pipTab,
+ destTab,
+ [[{ type: TAB_DROP_TYPE, data: pipTab }]],
+ null,
+ win1,
+ win2
+ );
+ is(effect, "move", "Tab should be moved from win1 to win2.");
+
+ let closeEvent = await awaitCloseEventPromise;
+ let swappedPipTabsEvent = await tabSwapPictureInPictureEventPromise;
+
+ is(
+ closeEvent.detail.adoptedBy,
+ swappedPipTabsEvent.detail,
+ "Pip tab adopted by new tab created when original tab closed"
+ );
+
+ // make sure we reassign the pip tab to the new one
+ pipTab = swappedPipTabsEvent.detail;
+
+ // check PiP attribute
+ ok(pipTab.hasAttribute("pictureinpicture"), "Tab should have PiP attribute");
+
+ // end PiP
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+
+ // ensure PiP attribute is gone
+ await TestUtils.waitForCondition(
+ () => !pipTab.hasAttribute("pictureinpicture"),
+ "pictureinpicture attribute was removed"
+ );
+
+ ok(true, "pictureinpicture attribute successfully cleared");
+
+ // close windows
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+});
+
+/**
+ * Tests that tabs torn out into a new window with PiP open, the pip attribute stays
+ */
+add_task(async function test_dragging_pip_into_new_window() {
+ // initialize
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ // Create PiP
+ let videoID = "with-controls";
+ let pipTab = gBrowser.getTabForBrowser(browser);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+
+ let tabSwapPictureInPictureEventPromise = BrowserTestUtils.waitForEvent(
+ pipTab,
+ "TabSwapPictureInPicture"
+ );
+
+ // tear out into new window
+ let newWin = await detachTab(pipTab);
+
+ let swappedPipTabsEvent = await tabSwapPictureInPictureEventPromise;
+ pipTab = swappedPipTabsEvent.detail;
+
+ // check PiP attribute
+ ok(
+ pipTab.hasAttribute("pictureinpicture"),
+ "Tab should have PiP attribute"
+ );
+
+ // end PiP
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+
+ // ensure pip attribute is gone
+ await TestUtils.waitForCondition(
+ () => !pipTab.hasAttribute("pictureinpicture"),
+ "pictureinpicture attribute was removed"
+ );
+ ok(true, "pictureinpicture attribute successfully cleared");
+
+ // close windows
+ await BrowserTestUtils.closeWindow(newWin);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_privateWindow.js b/toolkit/components/pictureinpicture/tests/browser_privateWindow.js
new file mode 100644
index 0000000000..26d051b14a
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_privateWindow.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that a Picture-in-Picture window opened by a Private browsing
+ * window has the "private" feature set on its window (which is important
+ * for some things, eg: taskbar grouping on Windows).
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ let pipTab = await BrowserTestUtils.openNewForegroundTab(
+ privateWin.gBrowser,
+ TEST_PAGE
+ );
+ let browser = pipTab.linkedBrowser;
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ Assert.equal(
+ pipWin.docShell.treeOwner
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIAppWindow).chromeFlags &
+ Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW,
+ Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW,
+ "Picture-in-Picture window should be marked as private"
+ );
+
+ await BrowserTestUtils.closeWindow(privateWin);
+ }
+});
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_resizeVideo.js b/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js
new file mode 100644
index 0000000000..9254ca10cc
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js
@@ -0,0 +1,293 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Global values for the left and top edge pixel coordinates. These will be written to
+// during the add_setup function in this test file.
+let gLeftEdge = 0;
+let gTopEdge = 0;
+
+/**
+ * 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 == gLeftEdge) {
+ 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
+ );
+}
+
+add_setup(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ // Reset the saved PiP location to top-left edge of the screen, wherever
+ // that may be. We record the top-left edge of the screen coordinates into
+ // global variables to do later coordinate comparisons after resizes.
+ let clearWin = await triggerPictureInPicture(browser, "with-controls");
+ let initialScreenX = clearWin.mozInnerScreenX;
+ let initialScreenY = clearWin.mozInnerScreenY;
+ let PiPScreen = PictureInPicture.getWorkingScreen(
+ initialScreenX,
+ initialScreenY
+ );
+ [gLeftEdge, gTopEdge] = PictureInPicture.getAvailScreenSize(PiPScreen);
+ clearWin.moveTo(gLeftEdge, gTopEdge);
+
+ await BrowserTestUtils.closeWindow(clearWin);
+ }
+ );
+});
+
+/**
+ * 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(gLeftEdge, gTopEdge);
+
+ 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..1e787dafc7
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js
@@ -0,0 +1,398 @@
+/* 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.
+ */
+async function doTest() {
+ 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;
+ }
+
+ function getAvailScreenSize(screen) {
+ let screenLeft = {},
+ screenTop = {},
+ screenWidth = {},
+ screenHeight = {};
+ screen.GetAvailRectDisplayPix(
+ screenLeft,
+ screenTop,
+ screenWidth,
+ screenHeight
+ );
+
+ // 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 *= scaleFactor;
+ screenTop.value *= scaleFactor;
+
+ 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
+ clearSavedPosition();
+
+ // 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
+ isfuzzy(
+ pipWin.screenX,
+ rightEdge - defaultPiPWidth,
+ ACCEPTABLE_DIFFERENCE,
+ "Default PiP X location"
+ );
+ if (AppConstants.platform == "linux") {
+ isfuzzy(
+ pipWin.screenY,
+ bottomEdge - defaultPiPHeight - tabHeight,
+ ACCEPTABLE_DIFFERENCE,
+ "Default PiP Y location"
+ );
+ } else {
+ isfuzzy(
+ pipWin.screenY,
+ bottomEdge - defaultPiPHeight,
+ ACCEPTABLE_DIFFERENCE,
+ "Default PiP Y location"
+ );
+ }
+ isfuzzy(
+ pipWin.innerHeight,
+ defaultPiPHeight,
+ ACCEPTABLE_DIFFERENCE,
+ "Default PiP height"
+ );
+ isfuzzy(
+ pipWin.innerWidth,
+ defaultPiPWidth,
+ ACCEPTABLE_DIFFERENCE,
+ "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
+ isfuzzy(
+ pipWin.screenX,
+ left,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last X location"
+ );
+ isfuzzy(
+ pipWin.screenY,
+ top,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last Y location"
+ );
+ isfuzzy(
+ pipWin.innerHeight,
+ height,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened with 1/2 default height"
+ );
+ isfuzzy(
+ pipWin.innerWidth,
+ width,
+ ACCEPTABLE_DIFFERENCE,
+ "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
+ isfuzzy(
+ pipWin.screenX,
+ rightEdge - defaultPiPWidth,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at default X location"
+ );
+ isfuzzy(
+ pipWin.screenY,
+ bottomEdge - defaultPiPHeight,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at default Y location"
+ );
+ isfuzzy(
+ pipWin.innerWidth,
+ defaultPiPWidth,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at default PiP width"
+ );
+ isfuzzy(
+ pipWin.innerHeight,
+ defaultPiPHeight,
+ ACCEPTABLE_DIFFERENCE,
+ "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.");
+
+ isfuzzy(
+ pipWin.screenX,
+ left,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last X location"
+ );
+ isfuzzy(
+ pipWin.screenY,
+ top,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last Y location"
+ );
+ isfuzzy(
+ pipWin.innerHeight,
+ height,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened height with previous width"
+ );
+ isfuzzy(
+ pipWin.innerWidth,
+ height * (pipWin.innerWidth / pipWin.innerHeight),
+ ACCEPTABLE_DIFFERENCE,
+ "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.");
+
+ isfuzzy(
+ pipWin.screenX,
+ left,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last X location"
+ );
+ isfuzzy(
+ pipWin.screenY,
+ top,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last Y location"
+ );
+ isfuzzy(
+ pipWin.innerHeight,
+ height,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened with previous height"
+ );
+ isfuzzy(
+ pipWin.innerWidth,
+ height * (pipWin.innerWidth / pipWin.innerHeight),
+ ACCEPTABLE_DIFFERENCE,
+ "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
+ isfuzzy(
+ pipWin.screenX,
+ left,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last X location but shifted back on screen"
+ );
+ if (AppConstants.platform == "linux") {
+ isfuzzy(
+ pipWin.screenY,
+ top - tabHeight,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last Y location but shifted back on screen"
+ );
+ } else {
+ isfuzzy(
+ pipWin.screenY,
+ top,
+ ACCEPTABLE_DIFFERENCE,
+ "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
+ isfuzzy(
+ pipWin.screenX,
+ defaultX,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last X location but shifted back on screen"
+ );
+ isfuzzy(
+ pipWin.screenY,
+ defaultY,
+ ACCEPTABLE_DIFFERENCE,
+ "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;
+ isfuzzy(
+ pipWin.innerWidth + pipWin.screenX,
+ rightEdge,
+ ACCEPTABLE_DIFFERENCE,
+ "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.");
+
+ Assert.less(pipWin.innerWidth, width, "New video width is smaller");
+ isfuzzy(
+ pipWin.innerWidth + pipWin.screenX,
+ rightEdge,
+ ACCEPTABLE_DIFFERENCE,
+ "Video is on right edge after video is changed"
+ );
+ }
+
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ }
+ );
+}
+
+add_task(async function test_pip_save_last_loc() {
+ await doTest();
+});
+
+add_task(async function test_pip_save_last_loc_with_os_zoom() {
+ await SpecialPowers.pushPrefEnv({ set: [["ui.textScaleFactor", 120]] });
+ await doTest();
+});
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..84866fba3e
--- /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": DEFAULT_TOGGLE_OPACITY,
+ },
+ hidden: [],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [".pip-expanded"],
+ },
+ },
+ };
+
+ const TOGGLE_LARGE = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-small": 0.0,
+ ".pip-wrapper": DEFAULT_TOGGLE_OPACITY,
+ ".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_subtitles_settings_panel.js b/toolkit/components/pictureinpicture/tests/browser_subtitles_settings_panel.js
new file mode 100644
index 0000000000..bbb81cde55
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_subtitles_settings_panel.js
@@ -0,0 +1,273 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that pressing the Escape key will close the subtitles settings panel and
+ * not remove focus if activated via the mouse.
+ */
+add_task(async function test_closePanelESCMouseFocus() {
+ clearSavedPosition();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.size",
+ "medium",
+ ],
+ ],
+ });
+
+ let videoID = "with-controls";
+
+ await ensureVideosReady(browser);
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ await content.document.getElementById(videoID).play();
+ });
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Resize PiP window so that subtitles button is visible
+ let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ pipWin.resizeTo(640, 360);
+ await resizePromise;
+
+ let subtitlesButton = pipWin.document.getElementById("closed-caption");
+ Assert.ok(subtitlesButton, "Subtitles button found");
+
+ let subtitlesPanel = pipWin.document.getElementById("settings");
+ let panelVisiblePromise = BrowserTestUtils.waitForCondition(
+ () => BrowserTestUtils.isVisible(subtitlesPanel),
+ "Wait for panel to be visible"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(subtitlesButton, {}, pipWin);
+
+ await panelVisiblePromise;
+
+ let audioButton = pipWin.document.getElementById("audio");
+ audioButton.focus();
+
+ let panelHiddenPromise = BrowserTestUtils.waitForCondition(
+ () => BrowserTestUtils.isHidden(subtitlesPanel),
+ "Wait for panel to be hidden"
+ );
+
+ EventUtils.synthesizeKey("KEY_Escape", {}, pipWin);
+
+ info("Make sure subtitles settings panel closes after pressing ESC");
+ await panelHiddenPromise;
+
+ Assert.notEqual(
+ pipWin.document.activeElement,
+ subtitlesButton,
+ "Subtitles button does not have focus after closing panel"
+ );
+ Assert.ok(pipWin, "PiP window is still open");
+
+ clearSavedPosition();
+ }
+ );
+});
+
+/**
+ * Tests that pressing the Escape key will close the subtitles settings panel and
+ * refocus on the subtitles button if activated via the keyboard.
+ */
+add_task(async function test_closePanelESCKeyboardFocus() {
+ clearSavedPosition();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+
+ let videoID = "with-controls";
+
+ await ensureVideosReady(browser);
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ await content.document.getElementById(videoID).play();
+ });
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Resize PiP window so that subtitles button is visible
+ let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ pipWin.resizeTo(640, 360);
+ await resizePromise;
+
+ let subtitlesButton = pipWin.document.getElementById("closed-caption");
+ Assert.ok(subtitlesButton, "Subtitles button found");
+
+ let subtitlesPanel = pipWin.document.getElementById("settings");
+ let subtitlesToggle = pipWin.document.getElementById("subtitles-toggle");
+ let panelVisiblePromise = BrowserTestUtils.waitForCondition(
+ () => BrowserTestUtils.isVisible(subtitlesPanel),
+ "Wait for panel to be visible"
+ );
+
+ subtitlesButton.focus();
+ EventUtils.synthesizeKey(" ", {}, pipWin);
+
+ await panelVisiblePromise;
+
+ Assert.equal(
+ pipWin.document.activeElement,
+ subtitlesToggle,
+ "Subtitles switch toggle should have focus after opening panel"
+ );
+
+ let panelHiddenPromise = BrowserTestUtils.waitForCondition(
+ () => BrowserTestUtils.isHidden(subtitlesPanel),
+ "Wait for panel to be hidden"
+ );
+
+ EventUtils.synthesizeKey("KEY_Escape", {}, pipWin);
+
+ info("Make sure subtitles settings panel closes after pressing ESC");
+ await panelHiddenPromise;
+
+ Assert.equal(
+ pipWin.document.activeElement,
+ subtitlesButton,
+ "Subtitles button has focus after closing panel"
+ );
+ Assert.ok(pipWin, "PiP window is still open");
+
+ clearSavedPosition();
+ }
+ );
+});
+
+/**
+ * Tests keyboard navigation for the subtitles settings panel and that it closes after selecting
+ * the subtitles button.
+ */
+add_task(async function test_panelKeyboardButtons() {
+ clearSavedPosition();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+
+ let videoID = "with-controls";
+
+ await ensureVideosReady(browser);
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ await content.document.getElementById(videoID).play();
+ // Mute video
+ content.document.getElementById(videoID).muted = true;
+ });
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Resize PiP window so that subtitles button is visible
+ let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ pipWin.resizeTo(640, 360);
+ await resizePromise;
+
+ let subtitlesButton = pipWin.document.getElementById("closed-caption");
+ Assert.ok(subtitlesButton, "Subtitles button found");
+
+ let subtitlesPanel = pipWin.document.getElementById("settings");
+ let subtitlesToggle = pipWin.document.getElementById("subtitles-toggle");
+ let panelVisiblePromise = BrowserTestUtils.waitForCondition(
+ () => BrowserTestUtils.isVisible(subtitlesPanel),
+ "Wait for panel to be visible"
+ );
+
+ subtitlesButton.focus();
+ EventUtils.synthesizeKey(" ", {}, pipWin);
+
+ await panelVisiblePromise;
+
+ Assert.equal(
+ pipWin.document.activeElement,
+ subtitlesToggle,
+ "Subtitles switch toggle should have focus after opening panel"
+ );
+
+ let fontMediumRadio = pipWin.document.getElementById("medium");
+ EventUtils.synthesizeKey("KEY_Tab", {}, pipWin);
+
+ Assert.equal(
+ pipWin.document.activeElement,
+ fontMediumRadio,
+ "Medium font size radio button should have focus"
+ );
+
+ let fontSmallRadio = pipWin.document.getElementById("small");
+ EventUtils.synthesizeKey("KEY_ArrowUp", {}, pipWin);
+
+ Assert.equal(
+ pipWin.document.activeElement,
+ fontSmallRadio,
+ "Small font size radio button should have focus"
+ );
+ Assert.ok(isVideoMuted(browser, videoID), "Video should still be muted");
+ Assert.equal(
+ SpecialPowers.getCharPref(
+ "media.videocontrols.picture-in-picture.display-text-tracks.size"
+ ),
+ "small",
+ "Font size changed to small"
+ );
+
+ subtitlesButton.focus();
+
+ let panelHiddenPromise = BrowserTestUtils.waitForCondition(
+ () => BrowserTestUtils.isHidden(subtitlesPanel),
+ "Wait for panel to be hidden"
+ );
+
+ EventUtils.synthesizeKey(" ", {}, pipWin);
+
+ info(
+ "Make sure subtitles settings panel closes after pressing the subtitles button"
+ );
+ await panelHiddenPromise;
+
+ Assert.ok(pipWin, "PiP window is still open");
+
+ clearSavedPosition();
+ }
+ );
+});
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_telemetry_enhancements.js b/toolkit/components/pictureinpicture/tests/browser_telemetry_enhancements.js
new file mode 100644
index 0000000000..3f027de170
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_telemetry_enhancements.js
@@ -0,0 +1,230 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE_LONG = TEST_ROOT + "test-video-selection.html";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const EXPECTED_EVENT_CREATE = [
+ [
+ "pictureinpicture",
+ "create",
+ "player",
+ undefined,
+ { ccEnabled: "false", webVTTSubtitles: "false" },
+ ],
+];
+
+const EXPECTED_EVENT_CREATE_WITH_TEXT_TRACKS = [
+ [
+ "pictureinpicture",
+ "create",
+ "player",
+ undefined,
+ { ccEnabled: "true", webVTTSubtitles: "true" },
+ ],
+];
+
+const EXPECTED_EVENT_CLOSED_METHOD_CLOSE_BUTTON = [
+ {
+ category: "pictureinpicture",
+ method: "closed_method",
+ object: "closeButton",
+ },
+];
+
+const videoID = "with-controls";
+
+const EXPECTED_EVENT_CLOSED_METHOD_UNPIP = [
+ {
+ category: "pictureinpicture",
+ method: "closed_method",
+ object: "unpip",
+ },
+];
+
+const FULLSCREEN_EVENTS = [
+ {
+ category: "pictureinpicture",
+ method: "fullscreen",
+ object: "player",
+ extraKey: { enter: "true" },
+ },
+ {
+ category: "pictureinpicture",
+ method: "fullscreen",
+ object: "player",
+ extraKey: { enter: "true" },
+ },
+];
+
+add_task(async function testCreateAndCloseButtonTelemetry() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ Services.telemetry.clearEvents();
+
+ await ensureVideosReady(browser);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let filter = {
+ category: "pictureinpicture",
+ method: "create",
+ object: "player",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ EXPECTED_EVENT_CREATE.length,
+ "parent"
+ );
+
+ TelemetryTestUtils.assertEvents(EXPECTED_EVENT_CREATE, filter, {
+ clear: true,
+ process: "parent",
+ });
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+
+ filter = {
+ category: "pictureinpicture",
+ method: "closed_method",
+ object: "closeButton",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ EXPECTED_EVENT_CLOSED_METHOD_CLOSE_BUTTON.length,
+ "parent"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ EXPECTED_EVENT_CLOSED_METHOD_CLOSE_BUTTON,
+ filter,
+ { clear: true, process: "parent" }
+ );
+
+ let hist = TelemetryTestUtils.getAndClearHistogram(
+ "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION"
+ );
+
+ Assert.ok(hist, "Histogram exists");
+ }
+ );
+});
+
+add_task(async function textTextTracksAndUnpipTelemetry() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ Services.telemetry.clearEvents();
+
+ await ensureVideosReady(browser);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let filter = {
+ category: "pictureinpicture",
+ method: "create",
+ object: "player",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ EXPECTED_EVENT_CREATE_WITH_TEXT_TRACKS.length,
+ "parent"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ EXPECTED_EVENT_CREATE_WITH_TEXT_TRACKS,
+ filter,
+ { clear: true, process: "parent" }
+ );
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let unpipButton = pipWin.document.getElementById("unpip");
+ EventUtils.synthesizeMouseAtCenter(unpipButton, {}, pipWin);
+ await pipClosed;
+
+ filter = {
+ category: "pictureinpicture",
+ method: "closed_method",
+ object: "unpip",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ EXPECTED_EVENT_CLOSED_METHOD_UNPIP.length,
+ "parent"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ EXPECTED_EVENT_CLOSED_METHOD_UNPIP,
+ filter,
+ { clear: true, process: "parent" }
+ );
+ }
+ );
+});
+
+add_task(async function test_fullscreen_events() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ Services.telemetry.clearEvents();
+
+ await ensureVideosReady(browser);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let fullscreenBtn = pipWin.document.getElementById("fullscreen");
+
+ await promiseFullscreenEntered(pipWin, () => {
+ fullscreenBtn.click();
+ });
+
+ await promiseFullscreenExited(pipWin, () => {
+ fullscreenBtn.click();
+ });
+
+ let filter = {
+ category: "pictureinpicture",
+ method: "fullscreen",
+ object: "player",
+ };
+ await waitForTelemeryEvents(filter, FULLSCREEN_EVENTS.length, "parent");
+
+ TelemetryTestUtils.assertEvents(FULLSCREEN_EVENTS, filter, {
+ clear: true,
+ process: "parent",
+ });
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_1.js b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_1.js
new file mode 100644
index 0000000000..b2b1ded13d
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_1.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test ensures that text tracks shown on the source video
+ * do not appear on a newly created pip window if the pref
+ * is disabled.
+ */
+add_task(async function test_text_tracks_new_window_pref_disabled() {
+ info("Running test: new window - pref disabled");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ false,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ ok(
+ !textTracks.textContent,
+ "Text tracks should not be visible on the pip window"
+ );
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks shown on the source video
+ * appear on a newly created pip window if the pref is enabled.
+ */
+add_task(async function test_text_tracks_new_window_pref_enabled() {
+ info("Running test: new window - pref enabled");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks do not appear on a new pip window
+ * if no track is loaded and the pref is enabled.
+ */
+add_task(async function test_text_tracks_new_window_no_track() {
+ info("Running test: new window - no track");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID, -1);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ ok(
+ !textTracks.textContent,
+ "Text tracks should not be visible on the pip window"
+ );
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_2.js b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_2.js
new file mode 100644
index 0000000000..9f1ebefbf5
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_2.js
@@ -0,0 +1,448 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test ensures that text tracks disappear from the pip window
+ * when the pref is disabled.
+ */
+add_task(async function test_text_tracks_existing_window_pref_disabled() {
+ info("Running test: existing window - pref disabled");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ });
+
+ info("Turning off pref");
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ false,
+ ],
+ ],
+ });
+
+ // Verify that cue is no longer on the pip window
+ info("Checking that cue is no longer on pip window");
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ let textTracks = content.document.getElementById("texttracks");
+ await ContentTaskUtils.waitForCondition(() => {
+ return !textTracks.textContent;
+ }, `Text track is still visible on the pip window. Got ${textTracks.textContent}`);
+ info("Successfully removed text tracks from pip window");
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks shown on the source video
+ * window appear on an existing pip window when the pref is enabled.
+ */
+add_task(async function test_text_tracks_existing_window_pref_enabled() {
+ info("Running test: existing window - pref enabled");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ false,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ ok(
+ !textTracks.textContent,
+ "Text tracks should not be visible on the pip window"
+ );
+ });
+
+ info("Turning on pref");
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+
+ info("Checking that cue is on pip window");
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ let textTracks = content.document.getElementById("texttracks");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ info("Successfully displayed text tracks on pip window");
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks update to the correct track
+ * when a new track is selected.
+ */
+add_task(async function test_text_tracks_existing_window_new_track() {
+ info("Running test: existing window - new track");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ ok(
+ textTracks.textContent.includes("track 1"),
+ "Track 1 should be loaded"
+ );
+ });
+
+ // Change track in the content window
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ let tracks = video.textTracks;
+
+ info("Changing to a new track");
+ let track1 = tracks[0];
+ track1.mode = "disabled";
+ let track2 = tracks[1];
+ track2.mode = "showing";
+ });
+
+ // Ensure new track is loaded
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking new text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ ok(
+ textTracks.textContent.includes("track 2"),
+ "Track 2 should be loaded"
+ );
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks are correctly updated.
+ */
+add_task(async function test_text_tracks_existing_window_cues() {
+ info("Running test: existing window - cues");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+
+ // Verify that first cue appears
+ info("Checking first cue on pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ ok(
+ textTracks.textContent.includes("cue 1"),
+ `Expected text should be displayed on the pip window. Got ${textTracks.textContent}.`
+ );
+ });
+
+ // Play video to move to the next cue
+ await waitForNextCue(browser, videoID);
+
+ // Test remaining cues
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ let textTracks = content.document.getElementById("texttracks");
+
+ // Verify that empty cue makes text disappear
+ info("Checking empty cue in pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ info(`Current text content is: ${textTracks.textContent}`);
+ return !textTracks.textContent;
+ }, `Text track is still visible on the pip window. Got ${textTracks.textContent}`);
+ });
+
+ await waitForNextCue(browser, videoID);
+
+ // Wait and verify second cue
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ let textTracks = content.document.getElementById("texttracks");
+ info("Checking second cue in pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ // Cue may not appear right away after cuechange event.
+ // Wait until it appears before verifying text content.
+ info(`Current text content is: ${textTracks.textContent}`);
+ return (
+ textTracks.textContent && textTracks.textContent.includes("cue 2")
+ );
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks disappear if no track is selected.
+ */
+add_task(async function test_text_tracks_existing_window_no_track() {
+ info("Running test: existing window - no track");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ });
+
+ // Remove track in the content window
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ let tracks = video.textTracks;
+
+ info("Removing tracks");
+ let track1 = tracks[0];
+ track1.mode = "disabled";
+ let track2 = tracks[1];
+ track2.mode = "disabled";
+ });
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking that text track disappears from pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return !textTracks.textContent;
+ }, `Text track is still visible on the pip window. Got ${textTracks.textContent}`);
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks appear correctly if there are multiple active cues.
+ */
+add_task(async function test_text_tracks_existing_window_multi_cue() {
+ info("Running test: existing window - multi cue");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID, 2);
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ // Verify multiple active cues
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ is(textTracks.children.length, 2, "Text tracks should load 2 cues");
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that the showHiddenTextTracks override correctly shows
+ * text tracks with a mode of "hidden".
+ */
+const prepareHiddenTrackTest = () =>
+ new Promise((resolve, reject) => {
+ BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+
+ async browser => {
+ const videoID = "with-controls";
+ await prepareVideosAndWebVTTTracks(browser, videoID, 0, "hidden");
+ await SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ let video = content.document.getElementById(args.videoID);
+ const tracks = video.textTracks;
+ Assert.strictEqual(
+ tracks[0].mode,
+ "hidden",
+ "Track 1 mode is 'hidden'"
+ );
+ });
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+ if (!pipBrowser) {
+ reject();
+ }
+ resolve(pipBrowser);
+ }
+ );
+ });
+
+add_task(async function test_hidden_text_tracks_override() {
+ info("Running test - showHiddenTextTracks");
+
+ info("hidden mode with override");
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {
+ "*://example.com/*": { showHiddenTextTracks: true },
+ });
+ Services.ppmm.sharedData.flush();
+
+ await prepareHiddenTrackTest().then(async pipBrowser => {
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ // Verify text track is showing in PiP window.
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ ok(textTracks.textContent.includes("track 1"), "Track 1 should be shown");
+ });
+ });
+
+ info("hidden mode without override");
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {});
+ Services.ppmm.sharedData.flush();
+
+ await prepareHiddenTrackTest().then(async pipBrowser => {
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ // Verify text track is [not] showing in PiP window.
+ ok(
+ !textTracks || !textTracks.textContent.length,
+ "Text track should NOT appear in PiP window."
+ );
+ });
+ });
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_3.js b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_3.js
new file mode 100644
index 0000000000..3e62556d45
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_3.js
@@ -0,0 +1,218 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Verifies the value of a cue's .line property.
+ * @param {Element} browser The <xul:browser> hosting the <video>
+ * @param {String} videoID The ID of the video being checked
+ * @param {Integer} trackIndex The index of the track to be loaded
+ * @param {Integer} cueIndex The index of the cue to be tested on
+ * @param {Integer|String} expectedValue The expected line value of the cue
+ */
+async function verifyLineForCue(
+ browser,
+ videoID,
+ trackIndex,
+ cueIndex,
+ expectedValue
+) {
+ await SpecialPowers.spawn(
+ browser,
+ [{ videoID, trackIndex, cueIndex, expectedValue }],
+ async args => {
+ info("Checking .line property values");
+ const video = content.document.getElementById(args.videoID);
+ const activeCues = video.textTracks[args.trackIndex].activeCues;
+ const vttCueLine = activeCues[args.cueIndex].line;
+ is(vttCueLine, args.expectedValue, "Cue line should have expected value");
+ }
+ );
+}
+
+/**
+ * This test ensures that text tracks appear in expected order if
+ * VTTCue.line property is auto.
+ */
+add_task(async function test_text_tracks_new_window_line_auto() {
+ info("Running test: new window - line auto");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ let trackIndex = 2;
+ await prepareVideosAndWebVTTTracks(browser, videoID, trackIndex);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await verifyLineForCue(browser, videoID, trackIndex, 0, "auto");
+ await verifyLineForCue(browser, videoID, trackIndex, 1, "auto");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+
+ let cueDivs = textTracks.children;
+
+ is(cueDivs.length, 2, "There should be 2 active cues");
+ // cue1 in this case refers to the first cue to be defined in the vtt file.
+ // cue2 is therefore the next cue to be defined right after in the vtt file.
+ ok(
+ cueDivs[0].textContent.includes("cue 2"),
+ `cue 2 should be above. Got: ${cueDivs[0].textContent}`
+ );
+ ok(
+ cueDivs[1].textContent.includes("cue 1"),
+ `cue 1 should be below. Got: ${cueDivs[1].textContent}`
+ );
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks appear in expected order if
+ * VTTCue.line property is an integer.
+ */
+add_task(async function test_text_tracks_new_window_line_integer() {
+ info("Running test: new window - line integer");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ let trackIndex = 3;
+ await prepareVideosAndWebVTTTracks(browser, videoID, trackIndex);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await verifyLineForCue(browser, videoID, trackIndex, 0, 2);
+ await verifyLineForCue(browser, videoID, trackIndex, 1, 3);
+ await verifyLineForCue(browser, videoID, trackIndex, 2, 1);
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+
+ let cueDivs = textTracks.children;
+
+ is(cueDivs.length, 3, "There should be 3 active cues");
+
+ // cue1 in this case refers to the first cue to be defined in the vtt file.
+ // cue2 is therefore the next cue to be defined right after in the vtt file.
+ ok(
+ cueDivs[0].textContent.includes("cue 3"),
+ `cue 3 should be above. Got: ${cueDivs[0].textContent}`
+ );
+ ok(
+ cueDivs[1].textContent.includes("cue 1"),
+ `cue 1 should be next. Got: ${cueDivs[1].textContent}`
+ );
+ ok(
+ cueDivs[2].textContent.includes("cue 2"),
+ `cue 2 should be below. Got: ${cueDivs[2].textContent}`
+ );
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks appear in expected order if
+ * VTTCue.line property is a percentage value.
+ */
+add_task(async function test_text_tracks_new_window_line_percent() {
+ info("Running test: new window - line percent");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ let trackIndex = 4;
+ await prepareVideosAndWebVTTTracks(browser, videoID, trackIndex);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await verifyLineForCue(browser, videoID, trackIndex, 0, 90);
+ await verifyLineForCue(browser, videoID, trackIndex, 1, 10);
+ await verifyLineForCue(browser, videoID, trackIndex, 2, 50);
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+
+ let cueDivs = textTracks.children;
+ is(cueDivs.length, 3, "There should be 3 active cues");
+
+ // cue1 in this case refers to the first cue to be defined in the vtt file.
+ // cue2 is therefore the next cue to be defined right after in the vtt file.
+ ok(
+ cueDivs[0].textContent.includes("cue 2"),
+ `cue 2 should be above. Got: ${cueDivs[0].textContent}`
+ );
+ ok(
+ cueDivs[1].textContent.includes("cue 3"),
+ `cue 3 should be next. Got: ${cueDivs[1].textContent}`
+ );
+ ok(
+ cueDivs[2].textContent.includes("cue 1"),
+ `cue 1 should be below. Got: ${cueDivs[2].textContent}`
+ );
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js b/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js
new file mode 100644
index 0000000000..e02fe21f4e
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const EXAMPLE_COM = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const EXAMPLE_ORG = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+);
+const EXAMPLE_COM_TEST_PAGE = EXAMPLE_COM + "test-page.html";
+const EXAMPLE_ORG_WITH_IFRAME = EXAMPLE_ORG + "test-page-with-iframe.html";
+
+/**
+ * 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: EXAMPLE_ORG_WITH_IFRAME,
+ gBrowser,
+ },
+ async browser => {
+ // EXAMPLE_ORG_WITH_IFRAME is hosted at a different domain from
+ // EXAMPLE_COM_TEST_PAGE, so loading EXAMPLE_COM_TEST_PAGE within
+ // the iframe will act as our third-party iframe.
+ await SpecialPowers.spawn(
+ browser,
+ [EXAMPLE_COM_TEST_PAGE],
+ async EXAMPLE_COM_TEST_PAGE => {
+ let iframe = content.document.getElementById("iframe");
+ let loadPromise = ContentTaskUtils.waitForEvent(iframe, "load");
+ iframe.src = EXAMPLE_COM_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);
+
+ await SimpleTest.promiseFocus(window);
+
+ // Now try using the command / keyboard shortcut
+ pipWin = await triggerPictureInPicture(iframeBc, videoID, () => {
+ document.getElementById("View:PictureInPicture").doCommand();
+ });
+ ok(pipWin, "Got Picture-in-Picture window using command.");
+
+ 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_toggleButtonOnNanDuration.js b/toolkit/components/pictureinpicture/tests/browser_toggleButtonOnNanDuration.js
new file mode 100644
index 0000000000..6eaa4b5bcd
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleButtonOnNanDuration.js
@@ -0,0 +1,32 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that we do not show the Picture-in-Picture toggle on video
+ * elements that have a NaN duration.
+ */
+add_task(async function test_toggleButtonOnNanDuration() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_NAN_VIDEO_DURATION,
+ gBrowser,
+ },
+ async browser => {
+ const VIDEO_ID = "test-video";
+
+ await SpecialPowers.spawn(browser, [VIDEO_ID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ if (video.readyState < content.HTMLMediaElement.HAVE_ENOUGH_DATA) {
+ info(`Waiting for 'canplaythrough' for ${videoID}`);
+ await ContentTaskUtils.waitForEvent(video, "canplaythrough");
+ }
+ });
+
+ await testToggleHelper(browser, "nan-duration", false);
+
+ await testToggleHelper(browser, "test-video", true);
+ }
+ );
+});
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..a845137a69
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js
@@ -0,0 +1,204 @@
+/* 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": DEFAULT_TOGGLE_OPACITY,
+ ".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": DEFAULT_TOGGLE_OPACITY,
+ ".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": DEFAULT_TOGGLE_OPACITY,
+ },
+ hidden: [".pip-expanded"],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [".pip-expanded"],
+ },
+ },
+};
+
+const TOGGLE_STYLES_RIGHT_SMALL = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-wrapper": DEFAULT_TOGGLE_OPACITY,
+ },
+ 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,
+ shouldSeeClickEventAfterToggle: true,
+ },
+ });
+
+ 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,
+ shouldSeeClickEventAfterToggle: true,
+ },
+ });
+
+ 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..36172feeb4
--- /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.importESModule(
+ "resource://testing-common/ContentTaskUtils.sys.mjs"
+ );
+ 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..a95bbb0d48
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_togglePolicies.js
@@ -0,0 +1,127 @@
+/* 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.MIDDLE,
+ 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_togglePositionChange.js b/toolkit/components/pictureinpicture/tests/browser_togglePositionChange.js
new file mode 100644
index 0000000000..a868bc3d71
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_togglePositionChange.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+/**
+ * Tests that Picture-in-Picture "move toggle" context menu item
+ * successfully changes preference.
+ */
+add_task(async () => {
+ let videoID = "with-controls";
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ await content.document.getElementById(videoID).play();
+ });
+
+ const TOGGLE_POSITION_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.position";
+ const TOGGLE_POSITION_RIGHT = "right";
+ const TOGGLE_POSITION_LEFT = "left";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[TOGGLE_POSITION_PREF, TOGGLE_POSITION_RIGHT]],
+ });
+
+ let contextMoveToggle = document.getElementById(
+ "context_MovePictureInPictureToggle"
+ );
+ contextMoveToggle.click();
+ let position = Services.prefs.getStringPref(
+ TOGGLE_POSITION_PREF,
+ TOGGLE_POSITION_RIGHT
+ );
+
+ Assert.ok(
+ position === TOGGLE_POSITION_LEFT,
+ "Picture-in-Picture toggle position value should be 'left'."
+ );
+
+ contextMoveToggle.click();
+ position = Services.prefs.getStringPref(
+ TOGGLE_POSITION_PREF,
+ TOGGLE_POSITION_RIGHT
+ );
+
+ Assert.ok(
+ position === TOGGLE_POSITION_RIGHT,
+ "Picture-in-Picture toggle position value should be 'right'."
+ );
+ }
+ );
+});
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_toggle_enabled.js b/toolkit/components/pictureinpicture/tests/browser_toggle_enabled.js
new file mode 100644
index 0000000000..197bb9357c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggle_enabled.js
@@ -0,0 +1,99 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const videoID = "with-controls";
+const TOGGLE_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.enabled";
+const PIP_ENABLED_PREF = "media.videocontrols.picture-in-picture.enabled";
+const ACCEPTABLE_DIFF = 1;
+
+function checkDifference(actual, expected) {
+ let diff = Math.abs(actual - expected);
+ return diff <= ACCEPTABLE_DIFF;
+}
+
+function isVideoRect(videoRect, rect) {
+ info(
+ "Video rect and toggle rect will be the same if the toggle doesn't show"
+ );
+ info(`Video rect: ${JSON.stringify(videoRect)}`);
+ info(`Toggle rect: ${JSON.stringify(rect)}`);
+ return (
+ checkDifference(videoRect.top, rect.top) &&
+ checkDifference(videoRect.left, rect.left) &&
+ checkDifference(videoRect.width, rect.width) &&
+ checkDifference(videoRect.height, rect.height)
+ );
+}
+
+/**
+ * Tests if the toggle is available depending on prefs
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let videoRect = await SpecialPowers.spawn(
+ browser,
+ [videoID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ let rect = video.getBoundingClientRect();
+
+ return {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ };
+ }
+ );
+ // both toggle and pip true
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_ENABLED_PREF, true],
+ [PIP_ENABLED_PREF, true],
+ ],
+ });
+
+ let rect = await getToggleClientRect(browser, videoID);
+
+ Assert.ok(!isVideoRect(videoRect, rect), "Toggle is showing");
+
+ // only toggle false
+ await SpecialPowers.pushPrefEnv({
+ set: [[TOGGLE_ENABLED_PREF, false]],
+ });
+
+ rect = await getToggleClientRect(browser, videoID);
+ Assert.ok(isVideoRect(videoRect, rect), "The toggle is not showing");
+
+ // only pip false
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_ENABLED_PREF, true],
+ [PIP_ENABLED_PREF, false],
+ ],
+ });
+
+ rect = await getToggleClientRect(browser, videoID);
+ Assert.ok(isVideoRect(videoRect, rect), "The toggle is not showing");
+
+ // both toggle and pip false
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_ENABLED_PREF, false],
+ [PIP_ENABLED_PREF, false],
+ ],
+ });
+
+ rect = await getToggleClientRect(browser, videoID);
+ Assert.ok(isVideoRect(videoRect, rect), "The toggle is not showing");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggle_videocontrols.js b/toolkit/components/pictureinpicture/tests/browser_toggle_videocontrols.js
new file mode 100644
index 0000000000..248f816fba
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggle_videocontrols.js
@@ -0,0 +1,76 @@
+/* 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 opening the closed captions menu
+ * and is visible when closing the closed captions menu.
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ["media.videocontrols.picture-in-picture.video-toggle.enabled", true],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID, -1);
+ await prepareForToggleClick(browser, videoID);
+
+ let args = {
+ videoID,
+ DEFAULT_TOGGLE_STYLES,
+ };
+
+ await SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID, DEFAULT_TOGGLE_STYLES } = args;
+ let video = this.content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let closedCaptionButton = shadowRoot.querySelector(
+ "#closedCaptionButton"
+ );
+ let toggle = shadowRoot.querySelector(
+ `#${DEFAULT_TOGGLE_STYLES.rootID}`
+ );
+ let textTrackListContainer = shadowRoot.querySelector(
+ "#textTrackListContainer"
+ );
+
+ Assert.ok(!toggle.hidden, "Toggle should be visible");
+ Assert.ok(
+ textTrackListContainer.hidden,
+ "textTrackListContainer should be hidden"
+ );
+
+ info("Opening text track list from cc button");
+ closedCaptionButton.click();
+
+ Assert.ok(toggle.hidden, "Toggle should be hidden");
+ Assert.ok(
+ !textTrackListContainer.hidden,
+ "textTrackListContainer should be visible"
+ );
+
+ info("Clicking the cc button again to close it");
+ closedCaptionButton.click();
+
+ Assert.ok(!toggle.hidden, "Toggle should be visible again");
+ Assert.ok(
+ textTrackListContainer.hidden,
+ "textTrackListContainer should be hidden again"
+ );
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggle_without_audio.js b/toolkit/components/pictureinpicture/tests/browser_toggle_without_audio.js
new file mode 100644
index 0000000000..0c9ca5eeba
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggle_without_audio.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const videoID = "without-audio";
+const MIN_DURATION_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.min-video-secs";
+const ALWAYS_SHOW_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.always-show";
+const ACCEPTABLE_DIFF = 1;
+
+function checkDifference(actual, expected) {
+ let diff = Math.abs(actual - expected);
+ return diff <= ACCEPTABLE_DIFF;
+}
+
+function isVideoRect(videoRect, rect) {
+ info(
+ "Video rect and toggle rect will be the same if the toggle doesn't show"
+ );
+ info(`Video rect: ${JSON.stringify(videoRect)}`);
+ info(`Toggle rect: ${JSON.stringify(rect)}`);
+ return (
+ checkDifference(videoRect.top, rect.top) &&
+ checkDifference(videoRect.left, rect.left) &&
+ checkDifference(videoRect.width, rect.width) &&
+ checkDifference(videoRect.height, rect.height)
+ );
+}
+
+/**
+ * Tests if the toggle is available for a video without an audio track
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE_WITHOUT_AUDIO,
+ },
+ async browser => {
+ let videoRect = await SpecialPowers.spawn(
+ browser,
+ [videoID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ Assert.ok(!video.mozHasAudio, "Video does not have an audio track");
+ let rect = video.getBoundingClientRect();
+
+ return {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ };
+ }
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [ALWAYS_SHOW_PREF, false], // don't always show, we're testing the display logic
+ [MIN_DURATION_PREF, 3], // sample video is only 4s
+ ],
+ });
+
+ let rect = await getToggleClientRect(browser, videoID);
+
+ Assert.ok(!isVideoRect(videoRect, rect), "Toggle is showing");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_touch_toggle_enablepip.js b/toolkit/components/pictureinpicture/tests/browser_touch_toggle_enablepip.js
new file mode 100644
index 0000000000..e50c430d0c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_touch_toggle_enablepip.js
@@ -0,0 +1,242 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that Picture-in-Picture intializes without changes to video playback
+ * when opened via the toggle using a touch event. Also ensures that elements
+ * in the content window can still be interacted with afterwards.
+ */
+add_task(async () => {
+ let videoID = "with-controls";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.videocontrols.picture-in-picture.video-toggle.position", "right"],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ let toggleStyles = DEFAULT_TOGGLE_STYLES;
+ let stage = "hoverVideo";
+ let toggleStylesForStage = toggleStyles.stages[stage];
+
+ // Remove other page elements before reading PiP toggle's client rect.
+ // Otherwise, we will provide the wrong coordinates when simulating the touch event.
+ await SpecialPowers.spawn(browser, [], async args => {
+ info(
+ "Removing other elements first to make the PiP toggle more visible"
+ );
+ this.content.document.getElementById("no-controls").remove();
+ let otherEls = this.content.document.querySelectorAll("h1");
+ for (let el of otherEls) {
+ el.remove();
+ }
+ });
+
+ let toggleClientRect = await getToggleClientRect(browser, videoID);
+ await prepareForToggleClick(browser, videoID);
+
+ await SpecialPowers.spawn(
+ browser,
+ [{ videoID, toggleClientRect, toggleStylesForStage }],
+ async args => {
+ // waitForToggleOpacity is based on toggleOpacityReachesThreshold.
+ // Waits for toggle to reach target opacity.
+ async function waitForToggleOpacity(
+ shadowRoot,
+ toggleStylesForStage
+ ) {
+ for (let hiddenElement of toggleStylesForStage.hidden) {
+ let el = shadowRoot.querySelector(hiddenElement);
+ ok(
+ ContentTaskUtils.isHidden(el),
+ `Expected ${hiddenElement} to be hidden.`
+ );
+ }
+
+ for (let opacityElement in toggleStylesForStage.opacities) {
+ let opacityThreshold =
+ toggleStylesForStage.opacities[opacityElement];
+ let el = shadowRoot.querySelector(opacityElement);
+
+ await ContentTaskUtils.waitForCondition(
+ () => {
+ let opacity = parseFloat(
+ this.content.getComputedStyle(el).opacity
+ );
+ return opacity >= opacityThreshold;
+ },
+ `Toggle element ${opacityElement} should have eventually reached ` +
+ `target opacity ${opacityThreshold}`,
+ 100,
+ 100
+ );
+
+ ok(true, "Toggle reached target opacity.");
+ }
+ }
+ let { videoID, toggleClientRect, toggleStylesForStage } = args;
+ let video = this.content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+
+ info("Creating a new button in the content window");
+ let button = this.content.document.createElement("button");
+ let buttonSelected = false;
+ button.ontouchstart = () => {
+ info("Button selected via touch event");
+ buttonSelected = true;
+ return true;
+ };
+ button.id = "testbutton";
+ button.style.backgroundColor = "red";
+ // Move button to the top for better visibility.
+ button.style.position = "absolute";
+ button.style.top = "0";
+ button.textContent = "test button";
+ this.content.document.body.appendChild(button);
+
+ await video.play();
+
+ info("Hover over the video to show the Picture-in-Picture toggle");
+ await EventUtils.synthesizeMouseAtCenter(
+ video,
+ { type: "mousemove" },
+ this.content.window
+ );
+ await EventUtils.synthesizeMouseAtCenter(
+ video,
+ { type: "mouseover" },
+ this.content.window
+ );
+
+ let toggleCenterX =
+ toggleClientRect.left + toggleClientRect.width / 2;
+ let toggleCenterY =
+ toggleClientRect.top + toggleClientRect.height / 2;
+
+ // We want to wait for the toggle to reach opacity so that we can select it.
+ info("Waiting for toggle to become fully visible");
+ await waitForToggleOpacity(shadowRoot, toggleStylesForStage);
+
+ info("Simulating touch event on PiP toggle");
+ let utils = EventUtils._getDOMWindowUtils(this.content.window);
+ let id = utils.DEFAULT_TOUCH_POINTER_ID;
+ let rx = 1;
+ let ry = 1;
+ let angle = 0;
+ let force = 1;
+ let tiltX = 0;
+ let tiltY = 0;
+ let twist = 0;
+
+ let defaultPrevented = utils.sendTouchEvent(
+ "touchstart",
+ [id],
+ [toggleCenterX],
+ [toggleCenterY],
+ [rx],
+ [ry],
+ [angle],
+ [force],
+ [tiltX],
+ [tiltY],
+ [twist],
+ false /* modifiers */
+ );
+ utils.sendTouchEvent(
+ "touchend",
+ [id],
+ [toggleCenterX],
+ [toggleCenterY],
+ [rx],
+ [ry],
+ [angle],
+ [force],
+ [tiltX],
+ [tiltY],
+ [twist],
+ false /* modifiers */
+ );
+
+ ok(
+ defaultPrevented,
+ "Touchstart event's default actions should be prevented"
+ );
+ ok(!video.paused, "Video should still be playing");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+
+ let testButton = this.content.document.getElementById("testbutton");
+ let buttonRect = testButton.getBoundingClientRect();
+ let buttonCenterX = buttonRect.left + buttonRect.width / 2;
+ let buttonCenterY = buttonRect.top + buttonRect.height / 2;
+
+ info("Simulating touch event on new button");
+ defaultPrevented = utils.sendTouchEvent(
+ "touchstart",
+ [id],
+ [buttonCenterX],
+ [buttonCenterY],
+ [rx],
+ [ry],
+ [angle],
+ [force],
+ [tiltX],
+ [tiltY],
+ [twist],
+ false /* modifiers */
+ );
+ utils.sendTouchEvent(
+ "touchend",
+ [id],
+ [buttonCenterX],
+ [buttonCenterY],
+ [rx],
+ [ry],
+ [angle],
+ [force],
+ [tiltX],
+ [tiltY],
+ [twist],
+ false /* modifiers */
+ );
+
+ ok(
+ buttonSelected,
+ "Button in content window was selected via touchstart"
+ );
+ ok(
+ !defaultPrevented,
+ "Touchstart event's default actions should no longer be prevented"
+ );
+ }
+ );
+
+ try {
+ info("Picture-in-Picture window should open");
+ await BrowserTestUtils.waitForCondition(
+ () => Services.wm.getEnumerator(WINDOW_TYPE).hasMoreElements(),
+ "Found a Picture-in-Picture"
+ );
+ for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) {
+ if (!win.closed) {
+ ok(true, "Found a Picture-in-Picture window as expected");
+ win.close();
+ }
+ }
+ } catch {
+ ok(false, "No Picture-in-Picture window found, which is unexpected");
+ }
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_urlbar_toggle.js b/toolkit/components/pictureinpicture/tests/browser_urlbar_toggle.js
new file mode 100644
index 0000000000..cf808c5177
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_urlbar_toggle.js
@@ -0,0 +1,456 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const { ASRouter } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+
+const PIP_URLBAR_EVENTS = [
+ {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "urlBar",
+ },
+];
+
+const PIP_DISABLED_EVENTS = [
+ {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "urlBar",
+ extra: { disableDialog: "true" },
+ },
+ {
+ category: "pictureinpicture",
+ method: "disrespect_disable",
+ object: "urlBar",
+ },
+];
+
+const FIRST_TOGGLE_AFTER_CALLOUT_EXPECTED_EVENTS = [
+ {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "urlBar",
+ extra: { firstTimeToggle: "true", callout: "true" },
+ },
+];
+
+add_task(async function test_urlbar_toggle_multiple_contexts() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_MULTIPLE_CONTEXTS,
+ gBrowser,
+ },
+ async browser => {
+ Services.telemetry.clearEvents();
+ await ensureVideosReady(browser);
+ await ensureVideosReady(browser.browsingContext.children[0]);
+
+ await TestUtils.waitForCondition(
+ () =>
+ PictureInPicture.getEligiblePipVideoCount(browser).totalPipCount ===
+ 2,
+ "Waiting for videos to register"
+ );
+
+ let { totalPipCount } =
+ PictureInPicture.getEligiblePipVideoCount(browser);
+ is(totalPipCount, 2, "Total PiP count is 2");
+
+ let pipUrlbarToggle = document.getElementById(
+ "picture-in-picture-button"
+ );
+ ok(
+ BrowserTestUtils.isHidden(pipUrlbarToggle),
+ "PiP urlbar toggle is hidden because there is more than 1 video"
+ );
+
+ // Remove one video from page so urlbar toggle will show
+ await SpecialPowers.spawn(browser, [], async () => {
+ let video = content.document.getElementById("with-controls");
+ video.remove();
+ });
+
+ await BrowserTestUtils.waitForMutationCondition(
+ pipUrlbarToggle,
+ { attributeFilter: ["hidden"] },
+ () => BrowserTestUtils.isVisible(pipUrlbarToggle)
+ );
+
+ ok(
+ BrowserTestUtils.isVisible(pipUrlbarToggle),
+ "PiP urlbar toggle is visible"
+ );
+
+ ({ totalPipCount } = PictureInPicture.getEligiblePipVideoCount(browser));
+ is(totalPipCount, 1, "Total PiP count is 1");
+
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ pipUrlbarToggle.click();
+ let win = await domWindowOpened;
+ ok(win, "A Picture-in-Picture window opened.");
+
+ await assertVideoIsBeingCloned(
+ browser.browsingContext.children[0],
+ "video"
+ );
+
+ let filter = {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "urlBar",
+ };
+ await waitForTelemeryEvents(filter, PIP_URLBAR_EVENTS.length, "content");
+
+ TelemetryTestUtils.assertEvents(PIP_URLBAR_EVENTS, filter, {
+ clear: true,
+ process: "content",
+ });
+
+ let domWindowClosed = BrowserTestUtils.domWindowClosed(win);
+ pipUrlbarToggle.click();
+ await domWindowClosed;
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let iframe = content.document.getElementById("iframe");
+ iframe.remove();
+ });
+
+ await BrowserTestUtils.waitForMutationCondition(
+ pipUrlbarToggle,
+ { attributeFilter: ["hidden"] },
+ () => BrowserTestUtils.isHidden(pipUrlbarToggle)
+ );
+
+ ok(
+ BrowserTestUtils.isHidden(pipUrlbarToggle),
+ "PiP urlbar toggle is hidden because there are no videos on the page"
+ );
+
+ ({ totalPipCount } = PictureInPicture.getEligiblePipVideoCount(browser));
+ is(totalPipCount, 0, "Total PiP count is 0");
+ }
+ );
+});
+
+add_task(async function test_urlbar_toggle_switch_tabs() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_TRANSPARENT_NESTED_IFRAMES,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ await TestUtils.waitForCondition(
+ () =>
+ PictureInPicture.getEligiblePipVideoCount(browser).totalPipCount ===
+ 1,
+ "Waiting for video to register"
+ );
+
+ let { totalPipCount } =
+ PictureInPicture.getEligiblePipVideoCount(browser);
+ is(totalPipCount, 1, "Total PiP count is 1");
+
+ let pipUrlbarToggle = document.getElementById(
+ "picture-in-picture-button"
+ );
+ ok(
+ BrowserTestUtils.isVisible(pipUrlbarToggle),
+ "PiP urlbar toggle is visible because there is 1 video"
+ );
+
+ let pipActivePromise = BrowserTestUtils.waitForMutationCondition(
+ pipUrlbarToggle,
+ { attributeFilter: ["pipactive"] },
+ () => pipUrlbarToggle.hasAttribute("pipactive")
+ );
+
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ pipUrlbarToggle.click();
+ let win = await domWindowOpened;
+ ok(win, "A Picture-in-Picture window opened.");
+
+ await assertVideoIsBeingCloned(browser, "video");
+
+ await pipActivePromise;
+
+ ok(
+ pipUrlbarToggle.hasAttribute("pipactive"),
+ "We are PiP'd in this tab so the icon is active"
+ );
+
+ let newTab = BrowserTestUtils.addTab(
+ gBrowser,
+ TEST_PAGE_TRANSPARENT_NESTED_IFRAMES
+ );
+ await BrowserTestUtils.browserLoaded(newTab.linkedBrowser);
+
+ await BrowserTestUtils.switchTab(gBrowser, newTab);
+
+ await BrowserTestUtils.waitForMutationCondition(
+ pipUrlbarToggle,
+ { attributeFilter: ["pipactive"] },
+ () => !pipUrlbarToggle.hasAttribute("pipactive")
+ );
+
+ ok(
+ !pipUrlbarToggle.hasAttribute("pipactive"),
+ "After switching tabs the pip icon is not active"
+ );
+
+ BrowserTestUtils.removeTab(newTab);
+
+ await ensureMessageAndClosePiP(
+ browser,
+ "video-transparent-background",
+ win,
+ false
+ );
+ }
+ );
+});
+
+add_task(async function test_pipDisabled() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_PIP_DISABLED,
+ gBrowser,
+ },
+ async browser => {
+ Services.telemetry.clearEvents();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.respect-disablePictureInPicture",
+ true,
+ ],
+ ],
+ });
+
+ const VIDEO_ID = "with-controls";
+ await ensureVideosReady(browser);
+
+ await TestUtils.waitForCondition(
+ () =>
+ PictureInPicture.getEligiblePipVideoCount(browser).totalPipCount ===
+ 1,
+ "Waiting for video to register"
+ );
+
+ let { totalPipCount, totalPipDisabled } =
+ PictureInPicture.getEligiblePipVideoCount(browser);
+ is(totalPipCount, 1, "Total PiP count is 1");
+ is(totalPipDisabled, 1, "PiP is disabled on 1 video");
+
+ // Confirm that the toggle is hidden because we are respecting disablePictureInPicture
+ await testToggleHelper(browser, VIDEO_ID, false);
+
+ let pipUrlbarToggle = document.getElementById(
+ "picture-in-picture-button"
+ );
+ ok(
+ BrowserTestUtils.isVisible(pipUrlbarToggle),
+ "PiP urlbar toggle is visible because PiP is disabled"
+ );
+
+ pipUrlbarToggle.click();
+
+ let panel = browser.ownerDocument.querySelector("#PictureInPicturePanel");
+ await BrowserTestUtils.waitForCondition(async () => {
+ if (!panel) {
+ panel = browser.ownerDocument.querySelector("#PictureInPicturePanel");
+ }
+ return BrowserTestUtils.isVisible(panel);
+ });
+
+ let respectPipDisableSwitch = panel.querySelector(
+ "#respect-pipDisabled-switch"
+ );
+ ok(
+ !respectPipDisableSwitch.pressed,
+ "Respect PiP disabled is not pressed"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(respectPipDisableSwitch.buttonEl, {});
+ await BrowserTestUtils.waitForEvent(respectPipDisableSwitch, "toggle");
+ ok(respectPipDisableSwitch.pressed, "Respect PiP disabled is pressed");
+
+ pipUrlbarToggle.click();
+
+ await BrowserTestUtils.waitForCondition(async () => {
+ return BrowserTestUtils.isHidden(panel);
+ });
+
+ let filter = {
+ category: "pictureinpicture",
+ object: "urlBar",
+ };
+ await waitForTelemeryEvents(filter, PIP_DISABLED_EVENTS.length, "parent");
+ TelemetryTestUtils.assertEvents(PIP_DISABLED_EVENTS, filter, {
+ clear: true,
+ process: "parent",
+ });
+
+ // Confirm that the toggle is now visible because we no longer respect disablePictureInPicture
+ await testToggleHelper(browser, VIDEO_ID, true);
+
+ let pipActivePromise = BrowserTestUtils.waitForMutationCondition(
+ pipUrlbarToggle,
+ { attributeFilter: ["pipactive"] },
+ () => pipUrlbarToggle.hasAttribute("pipactive")
+ );
+
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ pipUrlbarToggle.click();
+ let win = await domWindowOpened;
+ ok(win, "A Picture-in-Picture window opened.");
+
+ await assertVideoIsBeingCloned(browser, "video");
+
+ await pipActivePromise;
+
+ ok(
+ pipUrlbarToggle.hasAttribute("pipactive"),
+ "We are PiP'd in this tab so the icon is active"
+ );
+
+ let domWindowClosed = BrowserTestUtils.domWindowClosed(win);
+ pipUrlbarToggle.click();
+ await domWindowClosed;
+
+ await BrowserTestUtils.waitForMutationCondition(
+ pipUrlbarToggle,
+ { attributeFilter: ["pipactive"] },
+ () => !pipUrlbarToggle.hasAttribute("pipactive")
+ );
+
+ ok(
+ !pipUrlbarToggle.hasAttribute("pipactive"),
+ "We closed the PiP window so the urlbar button is no longer active"
+ );
+
+ await SpecialPowers.popPrefEnv();
+ }
+ );
+});
+
+add_task(async function test_urlbar_toggle_telemetry() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_MULTIPLE_CONTEXTS,
+ gBrowser,
+ },
+ async browser => {
+ Services.telemetry.clearEvents();
+ await ensureVideosReady(browser);
+ await ensureVideosReady(browser.browsingContext.children[0]);
+
+ await TestUtils.waitForCondition(
+ () =>
+ PictureInPicture.getEligiblePipVideoCount(browser).totalPipCount ===
+ 2,
+ "Waiting for videos to register"
+ );
+
+ let { totalPipCount } =
+ PictureInPicture.getEligiblePipVideoCount(browser);
+ is(totalPipCount, 2, "Total PiP count is 2");
+
+ let pipUrlbarToggle = document.getElementById(
+ "picture-in-picture-button"
+ );
+ ok(
+ BrowserTestUtils.isHidden(pipUrlbarToggle),
+ "PiP urlbar toggle is hidden because there is more than 1 video"
+ );
+
+ // Remove one video from page so urlbar toggle will show
+ await SpecialPowers.spawn(browser, [], async () => {
+ let video = content.document.getElementById("with-controls");
+ video.remove();
+ });
+ await BrowserTestUtils.waitForMutationCondition(
+ pipUrlbarToggle,
+ { attributeFilter: ["hidden"] },
+ () => BrowserTestUtils.isVisible(pipUrlbarToggle)
+ );
+ ok(
+ BrowserTestUtils.isVisible(pipUrlbarToggle),
+ "PiP urlbar toggle is visible"
+ );
+
+ // open for first time after seeing feature callout message
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.video-toggle.has-used",
+ false,
+ ],
+ ],
+ });
+ let fakeMessage = {
+ template: "feature_callout",
+ id: "FAKE_PIP_FEATURE_CALLOUT",
+ content: {
+ screens: [{ anchors: [{ selector: "#picture-in-picture-button" }] }],
+ },
+ };
+ await ASRouter.waitForInitialized;
+ let originalASRouterState;
+ await ASRouter.setState(state => {
+ originalASRouterState = state;
+ return {
+ messages: [...state.messages, fakeMessage],
+ messageImpressions: {
+ ...state.messageImpressions,
+ [fakeMessage.id]: [Date.now()],
+ },
+ };
+ });
+
+ ({ totalPipCount } = PictureInPicture.getEligiblePipVideoCount(browser));
+ is(totalPipCount, 1, "Total PiP count is 1");
+
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ pipUrlbarToggle.click();
+ let win = await domWindowOpened;
+ ok(win, "A Picture-in-Picture window opened.");
+ await assertVideoIsBeingCloned(
+ browser.browsingContext.children[0],
+ "video"
+ );
+
+ let filter = {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "urlBar",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ FIRST_TOGGLE_AFTER_CALLOUT_EXPECTED_EVENTS.length,
+ "content"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ FIRST_TOGGLE_AFTER_CALLOUT_EXPECTED_EVENTS,
+ filter,
+ { clear: true, process: "content" }
+ );
+
+ let domWindowClosed = BrowserTestUtils.domWindowClosed(win);
+ pipUrlbarToggle.click();
+ await domWindowClosed;
+
+ await ASRouter.setState(originalASRouterState);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_videoEmptied.js b/toolkit/components/pictureinpicture/tests/browser_videoEmptied.js
new file mode 100644
index 0000000000..bc96a9ea58
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_videoEmptied.js
@@ -0,0 +1,155 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the subtitles button hides after switching to a video that does not have subtitles
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Need to make sure that the PiP window is at least the minimum height
+ let multiplier = 1;
+ while (true) {
+ if (multiplier * pipWin.innerHeight > 325) {
+ break;
+ }
+ multiplier += 0.5;
+ }
+
+ pipWin.moveTo(50, 50);
+ pipWin.resizeTo(
+ pipWin.innerWidth * multiplier,
+ pipWin.innerHeight * multiplier
+ );
+
+ let subtitlesButton = pipWin.document.querySelector("#closed-caption");
+ await TestUtils.waitForCondition(() => {
+ return !subtitlesButton.disabled;
+ }, "Waiting for subtitles button to be enabled");
+ ok(!subtitlesButton.disabled, "The subtitles button is enabled");
+
+ let emptied = SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ let video = content.document.getElementById(args.videoID);
+ info("Waiting for emptied event to be called");
+ await ContentTaskUtils.waitForEvent(video, "emptied");
+ });
+
+ await SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ let video = content.document.getElementById(args.videoID);
+ video.setAttribute("src", video.src);
+ let len = video.textTracks.length;
+ for (let i = 0; i < len; i++) {
+ video.removeChild(video.children[0]);
+ }
+ video.load();
+ });
+
+ await emptied;
+
+ await TestUtils.waitForCondition(() => {
+ return subtitlesButton.disabled;
+ }, "Waiting for subtitles button to be disabled after it was enabled");
+ ok(subtitlesButton.disabled, "The subtitles button is disabled");
+
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ );
+});
+
+/**
+ * Tests the the subtitles button shows after switching from a video with no subtitles to a video with subtitles
+ */
+add_task(async () => {
+ const videoID = "with-controls";
+ const videoID2 = "with-controls-no-tracks";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, videoID2);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Need to make sure that the PiP window is at least the minimum height
+ let multiplier = 1;
+ while (true) {
+ if (multiplier * pipWin.innerHeight > 325) {
+ break;
+ }
+ multiplier += 0.5;
+ }
+
+ pipWin.moveTo(50, 50);
+ pipWin.resizeTo(
+ pipWin.innerWidth * multiplier,
+ pipWin.innerHeight * multiplier
+ );
+
+ let subtitlesButton = pipWin.document.querySelector("#closed-caption");
+ await TestUtils.waitForCondition(() => {
+ return subtitlesButton.disabled;
+ }, "Making sure the subtitles button is disabled initially");
+ ok(subtitlesButton.disabled, "The subtitles button is disabled");
+
+ await SpecialPowers.spawn(
+ browser,
+ [{ videoID, videoID2 }],
+ async args => {
+ let video2 = content.document.getElementById(args.videoID2);
+
+ let track = video2.addTextTrack("captions", "English", "en");
+ track.mode = "showing";
+ track.addCue(
+ new content.window.VTTCue(0, 12, "[Test] This is the first cue")
+ );
+ track.addCue(
+ new content.window.VTTCue(18.7, 21.5, "This is the second cue")
+ );
+
+ video2.setAttribute("src", video2.src);
+ video2.load();
+
+ is(
+ video2.textTracks.length,
+ 1,
+ "Number of tracks loaded should be 1"
+ );
+ video2.play();
+ video2.pause();
+ }
+ );
+
+ subtitlesButton = pipWin.document.querySelector("#closed-caption");
+ await TestUtils.waitForCondition(() => {
+ return !subtitlesButton.disabled;
+ }, "Waiting for the subtitles button to be enabled after switching to a video with subtitles.");
+ ok(!subtitlesButton.disabled, "The subtitles button is enabled");
+
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ );
+});
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..7e792b2e9e
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/head.js
@@ -0,0 +1,1153 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TOGGLE_POLICIES } = ChromeUtils.importESModule(
+ "resource://gre/modules/PictureInPictureControls.sys.mjs"
+);
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+const TEST_ROOT_2 = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.org"
+);
+const TEST_PAGE = TEST_ROOT + "test-page.html";
+const TEST_PAGE_2 = TEST_ROOT_2 + "test-page.html";
+const TEST_PAGE_WITH_IFRAME = TEST_ROOT_2 + "test-page-with-iframe.html";
+const TEST_PAGE_WITH_SOUND = TEST_ROOT + "test-page-with-sound.html";
+const TEST_PAGE_WITHOUT_AUDIO = TEST_ROOT + "test-page-without-audio.html";
+const TEST_PAGE_WITH_NAN_VIDEO_DURATION =
+ TEST_ROOT + "test-page-with-nan-video-duration.html";
+const TEST_PAGE_WITH_WEBVTT = TEST_ROOT + "test-page-with-webvtt.html";
+const TEST_PAGE_MULTIPLE_CONTEXTS =
+ TEST_ROOT + "test-page-multiple-contexts.html";
+const TEST_PAGE_TRANSPARENT_NESTED_IFRAMES =
+ TEST_ROOT + "test-transparent-nested-iframes.html";
+const TEST_PAGE_PIP_DISABLED = TEST_ROOT + "test-page-pipDisabled.html";
+const WINDOW_TYPE = "Toolkit:PictureInPicture";
+const TOGGLE_POSITION_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.position";
+/* As of Bug 1811312, 80% toggle opacity is for the PiP toggle experiment control. */
+const DEFAULT_TOGGLE_OPACITY = 0.8;
+const HAS_USED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+const SHARED_DATA_KEY = "PictureInPicture:SiteOverrides";
+// Used for clearing the size and location of the PiP window
+const PLAYER_URI = "chrome://global/content/pictureinpicture/player.xhtml";
+const ACCEPTABLE_DIFFERENCE = 2;
+
+/**
+ * We currently ship with a few different variations of the
+ * Picture-in-Picture toggle. The tests for Picture-in-Picture include tests
+ * that check the style rules of various parts of the toggle. Since each toggle
+ * variation has different style rules, we introduce a structure here to
+ * describe the appearance of the toggle at different stages for the tests.
+ *
+ * The top-level structure looks like this:
+ *
+ * {
+ * rootID (String): The ID of the root element of the toggle.
+ * stages (Object): An Object representing the styles of the toggle at
+ * different stages of its use. Each property represents a different
+ * stage that can be tested. Right now, those stages are:
+ *
+ * hoverVideo:
+ * When the mouse is hovering the video but not the toggle.
+ *
+ * hoverToggle:
+ * When the mouse is hovering both the video and the toggle.
+ *
+ * Both stages must be assigned an Object with the following properties:
+ *
+ * opacities:
+ * This should be set to an Object where the key is a CSS selector for
+ * an element, and the value is a double for what the eventual opacity
+ * of that element should be set to.
+ *
+ * hidden:
+ * This should be set to an Array of CSS selector strings for elements
+ * that should be hidden during a particular stage.
+ * }
+ *
+ * DEFAULT_TOGGLE_STYLES is the set of styles for the default variation of the
+ * toggle.
+ */
+const DEFAULT_TOGGLE_STYLES = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-wrapper": DEFAULT_TOGGLE_OPACITY,
+ },
+ hidden: [".pip-expanded"],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [".pip-expanded"],
+ },
+ },
+};
+
+/**
+ * Given a browser and the ID for a <video> element, triggers
+ * Picture-in-Picture for that <video>, and resolves with the
+ * Picture-in-Picture window once it is ready to be used.
+ *
+ * If triggerFn is not specified, then open using the
+ * MozTogglePictureInPicture event.
+ *
+ * @param {Element,BrowsingContext} browser The <xul:browser> or
+ * BrowsingContext hosting the <video>
+ *
+ * @param {String} videoID The ID of the video to trigger
+ * Picture-in-Picture on.
+ *
+ * @param {boolean} triggerFn Use the given function to open the pip window,
+ * which runs in the parent process.
+ *
+ * @return Promise
+ * @resolves With the Picture-in-Picture window when ready.
+ */
+async function triggerPictureInPicture(browser, videoID, triggerFn) {
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+
+ let videoReady = null;
+ if (triggerFn) {
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ video.focus();
+ });
+
+ triggerFn();
+
+ videoReady = SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ });
+ } else {
+ videoReady = SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ let event = new content.CustomEvent("MozTogglePictureInPicture", {
+ bubbles: true,
+ });
+ video.dispatchEvent(event);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ });
+ }
+ let win = await domWindowOpened;
+ await Promise.all([
+ SimpleTest.promiseFocus(win),
+ win.promiseDocumentFlushed(() => {}),
+ videoReady,
+ ]);
+ return win;
+}
+
+/**
+ * Given a browser and the ID for a <video> element, checks that the
+ * video is showing the "This video is playing in Picture-in-Picture mode."
+ * status message overlay.
+ *
+ * @param {Element,BrowsingContext} browser The <xul:browser> or
+ * BrowsingContext hosting the <video>
+ *
+ * @param {String} videoID The ID of the video to trigger
+ * Picture-in-Picture on.
+ *
+ * @param {bool} expected True if we expect the message to be showing.
+ *
+ * @return Promise
+ * @resolves When the checks have completed.
+ */
+async function assertShowingMessage(browser, videoID, expected) {
+ let showing = await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let pipOverlay = shadowRoot.querySelector(".pictureInPictureOverlay");
+ Assert.ok(pipOverlay, "Should be able to find Picture-in-Picture overlay.");
+
+ let rect = pipOverlay.getBoundingClientRect();
+ return rect.height > 0 && rect.width > 0;
+ });
+ Assert.equal(
+ showing,
+ expected,
+ "Video should be showing the expected state."
+ );
+}
+
+/**
+ * Tests if a video is currently being cloned for a given content browser. Provides a
+ * good indicator for answering if this video is currently open in PiP.
+ *
+ * @param {Browser} browser
+ * The content browser or browsing contect that the video lives in
+ * @param {string} videoId
+ * The id associated with the video
+ *
+ * @returns {bool}
+ * Whether the video is currently being cloned (And is most likely open in PiP)
+ */
+function assertVideoIsBeingCloned(browser, selector) {
+ return SpecialPowers.spawn(browser, [selector], async slctr => {
+ let video = content.document.querySelector(slctr);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ });
+}
+
+/**
+ * Ensures that each of the videos loaded inside of a document in a
+ * <browser> have reached the HAVE_ENOUGH_DATA readyState.
+ *
+ * @param {Element} browser The <xul:browser> hosting the <video>(s) or the browsing context
+ *
+ * @return Promise
+ * @resolves When each <video> is in the HAVE_ENOUGH_DATA readyState.
+ */
+async function ensureVideosReady(browser) {
+ // PictureInPictureToggleChild waits for videos to fire their "canplay"
+ // event before considering them for the toggle, so we start by making
+ // sure each <video> has done this.
+ info(`Waiting for videos to be ready`);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let videos = this.content.document.querySelectorAll("video");
+ for (let video of videos) {
+ video.currentTime = 0;
+ if (video.readyState < content.HTMLMediaElement.HAVE_ENOUGH_DATA) {
+ info(`Waiting for 'canplaythrough' for '${video.id}'`);
+ await ContentTaskUtils.waitForEvent(video, "canplaythrough");
+ }
+ }
+ });
+}
+
+/**
+ * Tests that the toggle opacity reaches or exceeds a certain threshold within
+ * a reasonable time.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> in it.
+ * @param {String} videoID The ID of the video element that we expect the toggle
+ * to appear on.
+ * @param {String} stage The stage for which the opacity is going to change. This
+ * should be one of "hoverVideo" or "hoverToggle".
+ * @param {Object} toggleStyles Optional argument. See the documentation for the
+ * DEFAULT_TOGGLE_STYLES object for a sense of what styleRules is expected to be.
+ *
+ * @return Promise
+ * @resolves When the check has completed.
+ */
+async function toggleOpacityReachesThreshold(
+ browser,
+ videoID,
+ stage,
+ toggleStyles = DEFAULT_TOGGLE_STYLES
+) {
+ let togglePosition = Services.prefs.getStringPref(
+ TOGGLE_POSITION_PREF,
+ "right"
+ );
+ let hasUsed = Services.prefs.getBoolPref(HAS_USED_PREF, false);
+ let toggleStylesForStage = toggleStyles.stages[stage];
+ info(
+ `Testing toggle for stage ${stage} ` +
+ `in position ${togglePosition}, has used: ${hasUsed}`
+ );
+
+ let args = { videoID, toggleStylesForStage, togglePosition, hasUsed };
+ await SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID, toggleStylesForStage } = args;
+
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+
+ for (let hiddenElement of toggleStylesForStage.hidden) {
+ let el = shadowRoot.querySelector(hiddenElement);
+ ok(
+ ContentTaskUtils.isHidden(el),
+ `Expected ${hiddenElement} to be hidden.`
+ );
+ }
+
+ for (let opacityElement in toggleStylesForStage.opacities) {
+ let opacityThreshold = toggleStylesForStage.opacities[opacityElement];
+ let el = shadowRoot.querySelector(opacityElement);
+
+ await ContentTaskUtils.waitForCondition(
+ () => {
+ let opacity = parseFloat(this.content.getComputedStyle(el).opacity);
+ return opacity >= opacityThreshold;
+ },
+ `Toggle element ${opacityElement} should have eventually reached ` +
+ `target opacity ${opacityThreshold}`,
+ 100,
+ 100
+ );
+ }
+
+ ok(true, "Toggle reached target opacity.");
+ });
+}
+
+/**
+ * Tests that the toggle has the correct policy attribute set. This should be called
+ * either when the toggle is visible, or events have been queued such that the toggle
+ * will soon be visible.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> in it.
+ * @param {String} videoID The ID of the video element that we expect the toggle
+ * to appear on.
+ * @param {Number} policy Optional argument. If policy is defined, then it should
+ * be one of the values in the TOGGLE_POLICIES from PictureInPictureControls.sys.mjs.
+ * If undefined, this function will ensure no policy attribute is set.
+ *
+ * @return Promise
+ * @resolves When the check has completed.
+ */
+async function assertTogglePolicy(
+ browser,
+ videoID,
+ policy,
+ toggleStyles = DEFAULT_TOGGLE_STYLES
+) {
+ let toggleID = toggleStyles.rootID;
+ let args = { videoID, toggleID, policy };
+ await SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID, toggleID, policy } = args;
+
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
+ let toggle = shadowRoot.getElementById(toggleID);
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return controlsOverlay.classList.contains("hovering");
+ }, "Waiting for the hovering state to be set on the video.");
+
+ if (policy) {
+ const { TOGGLE_POLICY_STRINGS } = ChromeUtils.importESModule(
+ "resource://gre/modules/PictureInPictureControls.sys.mjs"
+ );
+ let policyAttr = toggle.getAttribute("policy");
+ Assert.equal(
+ policyAttr,
+ TOGGLE_POLICY_STRINGS[policy],
+ "The correct toggle policy is set."
+ );
+ } else {
+ Assert.ok(
+ !toggle.hasAttribute("policy"),
+ "No toggle policy should be set."
+ );
+ }
+ });
+}
+
+/**
+ * Tests that either all or none of the expected mousebutton events
+ * fire in web content when clicking on the page.
+ *
+ * Note: This function will only work on pages that load the
+ * click-event-helper.js script.
+ *
+ * @param {Element} browser The <xul:browser> that will receive the mouse
+ * events.
+ * @param {bool} isExpectingEvents True if we expect all of the normal
+ * mouse button events to fire. False if we expect none of them to fire.
+ * @param {bool} isExpectingClick True if the mouse events should include the
+ * "click" event, which is only included when the primary mouse button is pressed.
+ * @return Promise
+ * @resolves When the check has completed.
+ */
+async function assertSawMouseEvents(
+ browser,
+ isExpectingEvents,
+ isExpectingClick = true
+) {
+ const MOUSE_BUTTON_EVENTS = [
+ "pointerdown",
+ "mousedown",
+ "pointerup",
+ "mouseup",
+ ];
+
+ if (isExpectingClick) {
+ MOUSE_BUTTON_EVENTS.push("click");
+ }
+
+ let mouseEvents = await SpecialPowers.spawn(browser, [], async () => {
+ return this.content.wrappedJSObject.getRecordedEvents();
+ });
+
+ let expectedEvents = isExpectingEvents ? MOUSE_BUTTON_EVENTS : [];
+ Assert.deepEqual(
+ mouseEvents,
+ expectedEvents,
+ "Expected to get the right mouse events."
+ );
+}
+
+/**
+ * Tests that a click event is fire in web content when clicking on the page.
+ *
+ * Note: This function will only work on pages that load the
+ * click-event-helper.js script.
+ *
+ * @param {Element} browser The <xul:browser> that will receive the mouse
+ * events.
+ * @return Promise
+ * @resolves When the check has completed.
+ */
+async function assertSawClickEventOnly(browser) {
+ let mouseEvents = await SpecialPowers.spawn(browser, [], async () => {
+ return this.content.wrappedJSObject.getRecordedEvents();
+ });
+ Assert.deepEqual(
+ mouseEvents,
+ ["click"],
+ "Expected to get the right mouse events."
+ );
+}
+
+/**
+ * Ensures that a <video> inside of a <browser> is scrolled into view,
+ * and then returns the coordinates of its Picture-in-Picture toggle as well
+ * as whether or not the <video> element is showing the built-in controls.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video that has the toggle.
+ *
+ * @return Promise
+ * @resolves With the following Object structure:
+ * {
+ * controls: <Boolean>,
+ * }
+ *
+ * Where controls represents whether or not the video has the default control set
+ * displayed.
+ */
+async function prepareForToggleClick(browser, videoID) {
+ // Synthesize a mouse move just outside of the video to ensure that
+ // the video is in a non-hovering state. We'll go 5 pixels to the
+ // left and above the top-left corner.
+ await BrowserTestUtils.synthesizeMouse(
+ `#${videoID}`,
+ -5,
+ -5,
+ {
+ type: "mousemove",
+ },
+ browser,
+ false
+ );
+
+ // For each video, make sure it's scrolled into view, and get the rect for
+ // the toggle while we're at it.
+ let args = { videoID };
+ return SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID } = args;
+
+ let video = content.document.getElementById(videoID);
+ video.scrollIntoView({ behaviour: "instant" });
+
+ if (!video.controls) {
+ // For no-controls <video> elements, an IntersectionObserver is used
+ // to know when we the PictureInPictureChild should begin tracking
+ // mousemove events. We don't exactly know when that IntersectionObserver
+ // will fire, so we poll a special testing function that will tell us when
+ // the video that we care about is being tracked.
+ let { PictureInPictureToggleChild } = ChromeUtils.importESModule(
+ "resource://gre/actors/PictureInPictureChild.sys.mjs"
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => {
+ return PictureInPictureToggleChild.isTracking(video);
+ },
+ "Waiting for PictureInPictureToggleChild to be tracking the video.",
+ 100,
+ 100
+ );
+ }
+
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
+ await ContentTaskUtils.waitForCondition(
+ () => {
+ return !controlsOverlay.classList.contains("hovering");
+ },
+ "Waiting for the video to not be hovered.",
+ 100,
+ 100
+ );
+
+ return {
+ controls: video.controls,
+ };
+ });
+}
+
+/**
+ * Returns client rect info for the toggle if it's supposed to be visible
+ * on hover. Otherwise, returns client rect info for the video with the
+ * associated ID.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video that has the toggle.
+ *
+ * @return Promise
+ * @resolves With the following Object structure:
+ * {
+ * top: <Number>,
+ * left: <Number>,
+ * width: <Number>,
+ * height: <Number>,
+ * }
+ */
+async function getToggleClientRect(
+ browser,
+ videoID,
+ toggleStyles = DEFAULT_TOGGLE_STYLES
+) {
+ let args = { videoID, toggleID: toggleStyles.rootID };
+ return ContentTask.spawn(browser, args, async args => {
+ const { Rect } = ChromeUtils.importESModule(
+ "resource://gre/modules/Geometry.sys.mjs"
+ );
+
+ let { videoID, toggleID } = args;
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let toggle = shadowRoot.getElementById(toggleID);
+ let rect = Rect.fromRect(toggle.getBoundingClientRect());
+
+ let clickableChildren = toggle.querySelectorAll(".clickable");
+ for (let child of clickableChildren) {
+ let childRect = Rect.fromRect(child.getBoundingClientRect());
+ rect.expandToContain(childRect);
+ }
+
+ if (!rect.width && !rect.height) {
+ rect = video.getBoundingClientRect();
+ }
+
+ return {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ };
+ });
+}
+
+/**
+ * This function will hover over the middle of the video and then
+ * hover over the toggle.
+ * @param browser The current browser
+ * @param videoID The video element id
+ */
+async function hoverToggle(browser, videoID) {
+ await prepareForToggleClick(browser, videoID);
+
+ // Hover the mouse over the video to reveal the toggle.
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mousemove",
+ },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ info("Checking toggle policy");
+ await assertTogglePolicy(browser, videoID, null);
+
+ let toggleClientRect = await getToggleClientRect(browser, videoID);
+
+ info("Hovering the toggle rect now.");
+ let toggleCenterX = toggleClientRect.left + toggleClientRect.width / 2;
+ let toggleCenterY = toggleClientRect.top + toggleClientRect.height / 2;
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {
+ type: "mousemove",
+ },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+}
+
+/**
+ * Test helper for the Picture-in-Picture toggle. Loads a page, and then
+ * tests the provided video elements for the toggle both appearing and
+ * opening the Picture-in-Picture window in the expected cases.
+ *
+ * @param {String} testURL The URL of the page with the <video> elements.
+ * @param {Object} expectations An object with the following schema:
+ * <video-element-id>: {
+ * canToggle: {Boolean}
+ * policy: {Number} (optional)
+ * styleRules: {Object} (optional)
+ * }
+ * If canToggle is true, then it's expected that moving the mouse over the
+ * video and then clicking in the toggle region should open a
+ * Picture-in-Picture window. If canToggle is false, we expect that a click
+ * in this region will not result in the window opening.
+ *
+ * If policy is defined, then it should be one of the values in the
+ * TOGGLE_POLICIES from PictureInPictureControls.sys.mjs.
+ *
+ * See the documentation for the DEFAULT_TOGGLE_STYLES object for a sense
+ * of what styleRules is expected to be. If left undefined, styleRules will
+ * default to DEFAULT_TOGGLE_STYLES.
+ *
+ * @param {async Function} prepFn An optional asynchronous function to run
+ * before running the toggle test. The function is passed the opened
+ * <xul:browser> as its only argument once the testURL has finished loading.
+ *
+ * @return Promise
+ * @resolves When the test is complete and the tab with the loaded page is
+ * removed.
+ */
+async function testToggle(testURL, expectations, prepFn = async () => {}) {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: testURL,
+ },
+ async browser => {
+ await prepFn(browser);
+ await ensureVideosReady(browser);
+
+ for (let [
+ videoID,
+ { canToggle, policy, toggleStyles, shouldSeeClickEventAfterToggle },
+ ] of Object.entries(expectations)) {
+ await SimpleTest.promiseFocus(browser);
+ info(`Testing video with id: ${videoID}`);
+
+ await testToggleHelper(
+ browser,
+ videoID,
+ canToggle,
+ policy,
+ toggleStyles,
+ shouldSeeClickEventAfterToggle
+ );
+ }
+ }
+ );
+}
+
+/**
+ * Test helper for the Picture-in-Picture toggle. Given a loaded page with some
+ * videos on it, tests that the toggle behaves as expected when interacted
+ * with by the mouse.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video that has the toggle.
+ * @param {Boolean} canToggle True if we expect the toggle to be visible and
+ * clickable by the mouse for the associated video.
+ * @param {Number} policy Optional argument. If policy is defined, then it should
+ * be one of the values in the TOGGLE_POLICIES from PictureInPictureControls.sys.mjs.
+ * @param {Object} toggleStyles Optional argument. See the documentation for the
+ * DEFAULT_TOGGLE_STYLES object for a sense of what styleRules is expected to be.
+ *
+ * @return Promise
+ * @resolves When the check for the toggle is complete.
+ */
+async function testToggleHelper(
+ browser,
+ videoID,
+ canToggle,
+ policy,
+ toggleStyles,
+ shouldSeeClickEventAfterToggle
+) {
+ let { controls } = await prepareForToggleClick(browser, videoID);
+
+ // Hover the mouse over the video to reveal the toggle.
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mousemove",
+ },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ info("Checking toggle policy");
+ await assertTogglePolicy(browser, videoID, policy, toggleStyles);
+
+ if (canToggle) {
+ info("Waiting for toggle to become visible");
+ await toggleOpacityReachesThreshold(
+ browser,
+ videoID,
+ "hoverVideo",
+ toggleStyles
+ );
+ }
+
+ let toggleClientRect = await getToggleClientRect(
+ browser,
+ videoID,
+ toggleStyles
+ );
+
+ info("Hovering the toggle rect now.");
+ let toggleCenterX = toggleClientRect.left + toggleClientRect.width / 2;
+ let toggleCenterY = toggleClientRect.top + toggleClientRect.height / 2;
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {
+ type: "mousemove",
+ },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ if (canToggle) {
+ info("Waiting for toggle to reach full opacity");
+ await toggleOpacityReachesThreshold(
+ browser,
+ videoID,
+ "hoverToggle",
+ toggleStyles
+ );
+ }
+
+ // First, ensure that a non-primary mouse click is ignored.
+ info("Right-clicking on toggle.");
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ { button: 2 },
+ browser
+ );
+
+ // For videos without the built-in controls, we expect that all mouse events
+ // should have fired - otherwise, the events are all suppressed. For videos
+ // with controls, none of the events should be fired, as the controls overlay
+ // absorbs them all.
+ //
+ // Note that the right-click does not result in a "click" event firing.
+ await assertSawMouseEvents(browser, !controls, false);
+
+ // The message to open the Picture-in-Picture window would normally be sent
+ // immediately before this Promise resolved, so the window should have opened
+ // by now if it was going to happen.
+ for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) {
+ if (!win.closed) {
+ ok(false, "Found a Picture-in-Picture window unexpectedly.");
+ return;
+ }
+ }
+
+ ok(true, "No Picture-in-Picture window found.");
+
+ // Okay, now test with the primary mouse button.
+
+ if (canToggle) {
+ info(
+ "Clicking on toggle, and expecting a Picture-in-Picture window to open"
+ );
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {},
+ browser
+ );
+ let win = await domWindowOpened;
+ ok(win, "A Picture-in-Picture window opened.");
+
+ await assertVideoIsBeingCloned(browser, "#" + videoID);
+
+ await BrowserTestUtils.closeWindow(win);
+
+ // We do get a "Click" sometimes, it depends on many
+ // factors such as whether the video has control and
+ // the style of the toggle.
+ if (shouldSeeClickEventAfterToggle) {
+ await assertSawClickEventOnly(browser);
+ } else {
+ // Make sure that clicking on the toggle resulted in no mouse button events
+ // being fired in content.
+ await assertSawMouseEvents(browser, false);
+ }
+ } else {
+ info(
+ "Clicking on toggle, and expecting no Picture-in-Picture window opens"
+ );
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {},
+ browser
+ );
+
+ // If we aren't showing the toggle, we expect all mouse events to be seen.
+ await assertSawMouseEvents(browser, !controls);
+
+ // The message to open the Picture-in-Picture window would normally be sent
+ // immediately before this Promise resolved, so the window should have opened
+ // by now if it was going to happen.
+ for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) {
+ if (!win.closed) {
+ ok(false, "Found a Picture-in-Picture window unexpectedly.");
+ return;
+ }
+ }
+
+ ok(true, "No Picture-in-Picture window found.");
+ }
+
+ // Click on the very top-left pixel of the document and ensure that we
+ // see all of the mouse events for it.
+ await BrowserTestUtils.synthesizeMouseAtPoint(1, 1, {}, browser);
+ await assertSawMouseEvents(browser, true);
+}
+
+/**
+ * Helper function that ensures that a provided async function
+ * causes a window to fully enter fullscreen mode.
+ *
+ * @param window (DOM Window)
+ * The window that is expected to enter fullscreen mode.
+ * @param asyncFn (Async Function)
+ * The async function to run to trigger the fullscreen switch.
+ * @return Promise
+ * @resolves When the fullscreen entering transition completes.
+ */
+async function promiseFullscreenEntered(window, asyncFn) {
+ let entered = BrowserTestUtils.waitForEvent(
+ window,
+ "MozDOMFullscreen:Entered"
+ );
+
+ await asyncFn();
+
+ await entered;
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return !TelemetryStopwatch.running("FULLSCREEN_CHANGE_MS");
+ });
+
+ if (AppConstants.platform == "macosx") {
+ // On macOS, the fullscreen transition takes some extra time
+ // to complete, and we don't receive events for it. We need to
+ // wait for it to complete or else input events in the next test
+ // might get eaten up. This is the best we can currently do.
+ //
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ dump(`BJW promiseFullscreenEntered: waiting for 2 second timeout.\n`);
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ }
+}
+
+/**
+ * Helper function that ensures that a provided async function
+ * causes a window to fully exit fullscreen mode.
+ *
+ * @param window (DOM Window)
+ * The window that is expected to exit fullscreen mode.
+ * @param asyncFn (Async Function)
+ * The async function to run to trigger the fullscreen switch.
+ * @return Promise
+ * @resolves When the fullscreen exiting transition completes.
+ */
+async function promiseFullscreenExited(window, asyncFn) {
+ let exited = BrowserTestUtils.waitForEvent(window, "MozDOMFullscreen:Exited");
+
+ await asyncFn();
+
+ await exited;
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return !TelemetryStopwatch.running("FULLSCREEN_CHANGE_MS");
+ });
+
+ if (AppConstants.platform == "macosx") {
+ // On macOS, the fullscreen transition takes some extra time
+ // to complete, and we don't receive events for it. We need to
+ // wait for it to complete or else input events in the next test
+ // might get eaten up. This is the best we can currently do.
+ //
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ }
+}
+
+/**
+ * Helper function that ensures that the "This video is
+ * playing in Picture-in-Picture mode" message works,
+ * then closes the player window
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video that has the toggle.
+ * @param {Element} pipWin The Picture-in-Picture window that was opened
+ * @param {Boolean} iframe True if the test is on an Iframe, which modifies
+ * the test behavior
+ */
+async function ensureMessageAndClosePiP(browser, videoID, pipWin, isIframe) {
+ try {
+ await assertShowingMessage(browser, videoID, true);
+ } finally {
+ let uaWidgetUpdate = null;
+ if (isIframe) {
+ uaWidgetUpdate = SpecialPowers.spawn(browser, [], async () => {
+ await ContentTaskUtils.waitForEvent(
+ content.windowRoot,
+ "UAWidgetSetupOrChange",
+ true /* capture */
+ );
+ });
+ } else {
+ uaWidgetUpdate = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "UAWidgetSetupOrChange",
+ true /* capture */
+ );
+ }
+ await BrowserTestUtils.closeWindow(pipWin);
+ await uaWidgetUpdate;
+ }
+}
+
+/**
+ * Helper function that returns True if the specified video is paused
+ * and False if the specified video is not paused.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video to check.
+ */
+async function isVideoPaused(browser, videoID) {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).paused;
+ });
+}
+
+/**
+ * Helper function that returns True if the specified video is muted
+ * and False if the specified video is not muted.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video to check.
+ */
+async function isVideoMuted(browser, videoID) {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).muted;
+ });
+}
+
+/**
+ * Initializes videos and text tracks for the current test case.
+ * First track is the default track to be loaded onto the video.
+ * Once initialization is done, play then pause the requested video.
+ * so that text tracks are loaded.
+ * @param {Element} browser The <xul:browser> hosting the <video>
+ * @param {String} videoID The ID of the video being checked
+ * @param {Integer} defaultTrackIndex The index of the track to be loaded, or none if -1
+ * @param {String} trackMode the mode that the video's textTracks should be set to
+ */
+async function prepareVideosAndWebVTTTracks(
+ browser,
+ videoID,
+ defaultTrackIndex = 0,
+ trackMode = "showing"
+) {
+ info("Preparing video and initial text tracks");
+ await ensureVideosReady(browser);
+ await SpecialPowers.spawn(
+ browser,
+ [{ videoID, defaultTrackIndex, trackMode }],
+ async args => {
+ let video = content.document.getElementById(args.videoID);
+ let tracks = video.textTracks;
+
+ is(tracks.length, 5, "Number of tracks loaded should be 5");
+
+ // Enable track for originating video
+ if (args.defaultTrackIndex >= 0) {
+ info(`Loading track ${args.defaultTrackIndex + 1}`);
+ let track = tracks[args.defaultTrackIndex];
+ tracks.mode = args.trackMode;
+ track.mode = args.trackMode;
+ }
+
+ // Briefly play the video to load text tracks onto the pip window.
+ info("Playing video to load text tracks");
+ video.play();
+ info("Pausing video");
+ video.pause();
+ ok(video.paused, "Video should be paused before proceeding with test");
+ }
+ );
+}
+
+/**
+ * Plays originating video until the next cue is loaded.
+ * Once the next cue is loaded, pause the video.
+ * @param {Element} browser The <xul:browser> hosting the <video>
+ * @param {String} videoID The ID of the video being checked
+ * @param {Integer} textTrackIndex The index of the track to be loaded, or none if -1
+ */
+async function waitForNextCue(browser, videoID, textTrackIndex = 0) {
+ if (textTrackIndex < 0) {
+ ok(false, "Cannot wait for next cue with invalid track index");
+ }
+
+ await SpecialPowers.spawn(
+ browser,
+ [{ videoID, textTrackIndex }],
+ async args => {
+ let video = content.document.getElementById(args.videoID);
+ info("Playing video to activate next cue");
+ video.play();
+ ok(!video.paused, "Video is playing");
+
+ info("Waiting until cuechange is called");
+ await ContentTaskUtils.waitForEvent(
+ video.textTracks[args.textTrackIndex],
+ "cuechange"
+ );
+
+ info("Pausing video to read text track");
+ video.pause();
+ ok(video.paused, "Video is paused");
+ }
+ );
+}
+
+/**
+ * The PiP window saves the positon when closed and sometimes we don't want
+ * this information to persist to other tests. This function will clear the
+ * position so the PiP window will open in the default position.
+ */
+function clearSavedPosition() {
+ let xulStore = Services.xulStore;
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "left", NaN);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "top", NaN);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "width", NaN);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "height", NaN);
+}
+
+function overrideSavedPosition(left, top, width, height) {
+ let xulStore = Services.xulStore;
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "left", left);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "top", top);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "width", width);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "height", height);
+}
+
+/**
+ * Function used to filter events when waiting for the correct number
+ * telemetry events.
+ * @param {String} expected The expected string or undefined
+ * @param {String} actual The actual string
+ * @returns true if the expected is undefined or if expected matches actual
+ */
+function matches(expected, actual) {
+ if (expected === undefined) {
+ return true;
+ }
+ return expected === actual;
+}
+
+/**
+ * Function that waits for the expected number of events aftering filtering.
+ * @param {Object} filter An object containing optional filters
+ * {
+ * category: (optional) The category of the event. Ex. "pictureinpicture"
+ * method: (optional) The method of the event. Ex. "create"
+ * object: (optional) The object of the event. Ex. "player"
+ * }
+ * @param {Number} length The number of events to wait for
+ * @param {String} process Should be "content" or "parent" depending on the event
+ */
+async function waitForTelemeryEvents(filter, length, process) {
+ let {
+ category: filterCategory,
+ method: filterMethod,
+ object: filterObject,
+ } = filter;
+
+ let events = [];
+ await TestUtils.waitForCondition(
+ () => {
+ events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ )[process];
+ if (!events) {
+ return false;
+ }
+
+ let filtered = events
+ .map(([, /* timestamp */ category, method, object, value, extra]) => {
+ // We don't care about the `timestamp` value.
+ // Tests that examine that value should use `snapshotEvents` directly.
+ return [category, method, object, value, extra];
+ })
+ .filter(([category, method, object]) => {
+ return (
+ matches(filterCategory, category) &&
+ matches(filterMethod, method) &&
+ matches(filterObject, object)
+ );
+ });
+ info(JSON.stringify(filtered, null, 2));
+ return filtered && filtered.length >= length;
+ },
+ `Waiting for ${length} pictureinpicture telemetry event(s) with filter ${JSON.stringify(
+ filter,
+ null,
+ 2
+ )}`,
+ 200,
+ 100
+ );
+}
diff --git a/toolkit/components/pictureinpicture/tests/no-audio-track.webm b/toolkit/components/pictureinpicture/tests/no-audio-track.webm
new file mode 100644
index 0000000000..72b0297233
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/no-audio-track.webm
Binary files differ
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-multiple-contexts.html b/toolkit/components/pictureinpicture/tests/test-page-multiple-contexts.html
new file mode 100644
index 0000000000..420370aff2
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page-multiple-contexts.html
@@ -0,0 +1,19 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture tests</title>
+</head>
+<style>
+ video {
+ display: block;
+ border: 1px solid black;
+ }
+</style>
+<body>
+ <h1>Video in page</h1>
+ <video id="with-controls" src="test-video.mp4" controls loop="true" width="400" height="225"></video>
+ <h1>Video in frame</h1>
+ <iframe id="iframe" width="400" height="225" src="test-video.mp4"></iframe>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-page-pipDisabled.html b/toolkit/components/pictureinpicture/tests/test-page-pipDisabled.html
new file mode 100644
index 0000000000..142d2d74d2
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page-pipDisabled.html
@@ -0,0 +1,18 @@
+<!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 PiPDisabled</h1>
+ <video id="with-controls" src="test-video.mp4" controls loop="true" width="400" height="225" disablePictureInPicture="true"></video>
+</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-nan-video-duration.html b/toolkit/components/pictureinpicture/tests/test-page-with-nan-video-duration.html
new file mode 100644
index 0000000000..b16c3682a0
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page-with-nan-video-duration.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Bug 1679174</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+ </head>
+ <body>
+ <video id="nan-duration"></video>
+ <video controls id="test-video">
+ <source src="test-video.mp4" type="video/mp4">
+ </video>
+ </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-with-webvtt.html b/toolkit/components/pictureinpicture/tests/test-page-with-webvtt.html
new file mode 100644
index 0000000000..e05ad8dd2c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page-with-webvtt.html
@@ -0,0 +1,66 @@
+<!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-long.mp4" controls width="400" height="225">
+ <track
+ id="track1"
+ kind="captions"
+ label="[test] en"
+ srclang="en"
+ src="test-webvtt-1.vtt"
+ />
+ <track
+ id="track2"
+ kind="subtitles"
+ label="[test] fr"
+ srclang="fr"
+ src="test-webvtt-2.vtt"
+ />
+ <track
+ id="track3"
+ kind="subtitles"
+ label="[test] eo"
+ srclang="eo"
+ src="test-webvtt-3.vtt"
+ />
+ <track
+ id="track4"
+ kind="subtitles"
+ label="[test] zh"
+ srclang="zh"
+ src="test-webvtt-4.vtt"
+ />
+ <track
+ id="track5"
+ kind="subtitles"
+ label="[test] es"
+ srclang="es"
+ src="test-webvtt-5.vtt"
+ />
+ </video>
+
+ <video id="with-controls-no-tracks" src="test-video-long.mp4" controls width="400" height="225"></video>
+
+ <script>
+ function fireEvents() {
+ for (let videoID of ["with-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-page-without-audio.html b/toolkit/components/pictureinpicture/tests/test-page-without-audio.html
new file mode 100644
index 0000000000..862042cd59
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page-without-audio.html
@@ -0,0 +1,18 @@
+<!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 without audio track</h1>
+ <video id="without-audio" src="no-audio-track.webm" controls width="400" height="225"></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
diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-1.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-1.vtt
new file mode 100644
index 0000000000..fd16ef6d32
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-webvtt-1.vtt
@@ -0,0 +1,10 @@
+WEBVTT
+
+1
+00:00:00.000 --> 00:00:01.000
+track 1 - cue 1
+
+2
+00:00:02.000 --> 00:00:05.000
+- <b>track 1 - cue 2 bold</b>
+- <i>track 1 - cue 2 italicized<i>
diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-2.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-2.vtt
new file mode 100644
index 0000000000..21fda2b75c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-webvtt-2.vtt
@@ -0,0 +1,10 @@
+WEBVTT
+
+1
+00:00:00.000 --> 00:00:01.000
+track 2 - cue 1
+
+2
+00:00:02.000 --> 00:00:05.000
+- <b>track 2 - cue 2 bold</b>
+- <i>track 2 - cue 2 italicized<i>
diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-3.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-3.vtt
new file mode 100644
index 0000000000..0207d9e65f
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-webvtt-3.vtt
@@ -0,0 +1,11 @@
+WEBVTT
+
+Test file with multiple active cues and VTTCue.line as "auto"
+
+1
+00:00:00.000 --> 00:00:01.000
+track 3 - cue 1
+
+2
+00:00:00.000 --> 00:00:01.000
+track 3 - cue 2
diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-4.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-4.vtt
new file mode 100644
index 0000000000..9e4a540f7f
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-webvtt-4.vtt
@@ -0,0 +1,15 @@
+WEBVTT
+
+Test file with multiple active cues and VTTCue.line as an integer
+
+1
+00:00:00.000 --> 00:00:01.000 line:2
+track 4 - cue 1 - integer line
+
+2
+00:00:00.000 --> 00:00:01.000 line:3
+track 4 - cue 2 - integer line
+
+3
+00:00:00.000 --> 00:00:01.000 line:1
+track 4 - cue 3 - integer line
diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-5.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-5.vtt
new file mode 100644
index 0000000000..3a25d83529
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-webvtt-5.vtt
@@ -0,0 +1,12 @@
+WEBVTT
+
+Test file with multiple active cues and VTTCue.line as a percentage value
+
+00:00:00.000 --> 00:00:01.000 line:90%
+track 5 - cue 1 - percent line
+
+00:00:00.000 --> 00:00:01.000 line:10%
+track 5 - cue 2 - percent line
+
+00:00:00.000 --> 00:00:01.000 line:50%
+track 5 - cue 3 - percent line