summaryrefslogtreecommitdiffstats
path: root/toolkit/components/pictureinpicture/tests/head.js
blob: 7e792b2e9ea7bf3a1c5a43b6712507cf9dbf5930 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
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
  );
}