<!DOCTYPE HTML>
<html>
<head>
<title>Test Video Play Time Related Permanent Telemetry Probes</title>
<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
<script type="application/javascript">

/**
 * This test is used to ensure that we accumulate time for video playback
 * correctly, and the results would be used in Telemetry probes.
 * Currently this test covers following probes
 * - VIDEO_PLAY_TIME_MS
 * - VIDEO_HDR_PLAY_TIME_MS
 * - VIDEO_HIDDEN_PLAY_TIME_MS
 * - VIDEO_HIDDEN_PLAY_TIME_PERCENTAGE
 * - VIDEO_INFERRED_DECODE_SUSPEND_PERCENTAGE
 * - VIDEO_VISIBLE_PLAY_TIME_MS
 * - MEDIA_PLAY_TIME_MS
 * - MUTED_PLAY_TIME_PERCENT
 * - AUDIBLE_PLAY_TIME_PERCENT
 */
const videoHistNames = [
  "VIDEO_PLAY_TIME_MS",
  "VIDEO_HIDDEN_PLAY_TIME_MS"
];
const videoHDRHistNames = [
  "VIDEO_HDR_PLAY_TIME_MS"
];
const videoKeyedHistNames = [
  "VIDEO_HIDDEN_PLAY_TIME_PERCENTAGE",
  "VIDEO_INFERRED_DECODE_SUSPEND_PERCENTAGE",
  "VIDEO_VISIBLE_PLAY_TIME_MS"
];
const audioKeyedHistNames = [
  "MUTED_PLAY_TIME_PERCENT",
  "AUDIBLE_PLAY_TIME_PERCENT"
];

add_task(async function setTestPref() {
  await SpecialPowers.pushPrefEnv({
    set: [["media.testing-only-events", true],
          ["media.test.video-suspend", true],
          ["media.suspend-bkgnd-video.enabled", true],
          ["media.suspend-bkgnd-video.delay-ms", 0],
          ["dom.media.silence_duration_for_audibility", 0.1]
    ]});
});

add_task(async function testTotalPlayTime() {
  const video = document.createElement('video');
  video.src = "gizmo.mp4";
  document.body.appendChild(video);

  info(`all accumulated time should be zero`);
  const videoChrome = SpecialPowers.wrap(video);
  await new Promise(r => video.onloadeddata = r);
  assertValueEqualTo(videoChrome, "totalVideoPlayTime", 0);
  assertValueEqualTo(videoChrome, "invisiblePlayTime", 0);

  info(`start accumulating play time after media starts`);
  video.autoplay = true;
  await Promise.all([
    once(video, "playing"),
    once(video, "moztotalplaytimestarted"),
  ]);
  await assertValueConstantlyIncreases(videoChrome, "totalVideoPlayTime");
  assertValueKeptUnchanged(videoChrome, "invisiblePlayTime");
  assertValueKeptUnchanged(videoChrome, "videoDecodeSuspendedTime");

  info(`should not accumulate time for paused video`);
  video.pause();
  await once(video, "moztotalplaytimepaused");
  assertValueKeptUnchanged(videoChrome, "totalVideoPlayTime");
  assertValueEqualTo(videoChrome, "totalVideoPlayTime", 0);

  info(`should start accumulating time again`);
  let rv = await Promise.all([
    onceWithTrueReturn(video, "moztotalplaytimestarted"),
    video.play().then(_ => true, _ => false),
  ]);
  ok(returnTrueWhenAllValuesAreTrue(rv), "video started again");
  await assertValueConstantlyIncreases(videoChrome, "totalVideoPlayTime");
  await cleanUpMediaAndCheckTelemetry(video);
});

// The testHDRPlayTime task will only pass on platforms that accurately report
// color depth in their VideoInfo structures. Presently, that is only true for
// macOS.
const {AppConstants} = ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
const reportsColorDepthFromVideoData = (AppConstants.platform == "macosx");
if (reportsColorDepthFromVideoData) {
  add_task(async function testHDRPlayTime() {
    // This task is different from the others because the HTMLMediaElement does
    // not expose a chrome property for video hdr play time. But we do capture
    // telemety for VIDEO_HDR_PLAY_TIME_MS. To ensure that this telemetry is
    // generated, this task follows the same structure as the other tasks, but
    // doesn't actually check the properties of the video player, other than to
    // confirm that video has played for at least some time.
    const video = document.createElement('video');
    video.src = "TestPatternHDR.mp4"; // This is an HDR video with no audio.
    document.body.appendChild(video);

    info(`load the HDR video`);
    const videoChrome = SpecialPowers.wrap(video);
    await new Promise(r => video.onloadeddata = r);

    info(`start accumulating play time after media starts`);
    video.autoplay = true;
    await Promise.all([
      once(video, "playing"),
      once(video, "moztotalplaytimestarted"),
    ]);
    // Check that we have at least some video play time, because the
    // HDR play time telemetry is emitted by the same process.
    await assertValueConstantlyIncreases(videoChrome, "totalVideoPlayTime");
    await cleanUpMediaAndCheckTelemetry(video, {hasVideo: true, hasAudio: false, hasVideoHDR: true});
  });
}

add_task(async function testVisiblePlayTime() {
  const video = document.createElement('video');
  video.src = "gizmo.mp4";
  document.body.appendChild(video);

  info(`all accumulated time should be zero`);
  const videoChrome = SpecialPowers.wrap(video);
  await new Promise(r => video.onloadeddata = r);
  assertValueEqualTo(videoChrome, "totalVideoPlayTime", 0);
  assertValueEqualTo(videoChrome, "visiblePlayTime", 0);
  assertValueEqualTo(videoChrome, "invisiblePlayTime", 0);

  info(`start accumulating play time after media starts`);
  video.autoplay = true;
  await Promise.all([
    once(video, "playing"),
    once(video, "moztotalplaytimestarted"),
  ]);
  await assertValueConstantlyIncreases(videoChrome, "totalVideoPlayTime");
  await assertValueConstantlyIncreases(videoChrome, "visiblePlayTime");
  assertValueKeptUnchanged(videoChrome, "invisiblePlayTime");

  info(`make video invisible`);
  video.style.display = "none";
  await once(video, "mozinvisibleplaytimestarted");
  await assertValueConstantlyIncreases(videoChrome, "totalVideoPlayTime");
  await assertValueConstantlyIncreases(videoChrome, "invisiblePlayTime");
  assertValueKeptUnchanged(videoChrome, "visiblePlayTime");
  await cleanUpMediaAndCheckTelemetry(video);
});

add_task(async function testAudibleAudioPlayTime() {
  const audio = document.createElement('audio');
  audio.src = "tone2s-silence4s-tone2s.opus";
  audio.controls = true;
  audio.loop = true;
  document.body.appendChild(audio);

  info(`all accumulated time should be zero`);
  const audioChrome = SpecialPowers.wrap(audio);
  await new Promise(r => audio.onloadeddata = r);
  assertValueEqualTo(audioChrome, "totalVideoPlayTime", 0);
  assertValueEqualTo(audioChrome, "totalAudioPlayTime", 0);
  assertValueEqualTo(audioChrome, "mutedPlayTime", 0);
  assertValueEqualTo(audioChrome, "audiblePlayTime", 0);

  info(`start accumulating play time after media starts`);
  await Promise.all([
    audio.play(),
    once(audio, "moztotalplaytimestarted"),
  ]);
  await assertValueConstantlyIncreases(audioChrome, "totalAudioPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "audiblePlayTime");
  assertValueKeptUnchanged(audioChrome, "mutedPlayTime");
  assertValueKeptUnchanged(audioChrome, "totalVideoPlayTime");

  info(`audio becomes inaudible for 4s`);
  await once(audio, "mozinaudibleaudioplaytimestarted");

  await assertValueConstantlyIncreases(audioChrome, "totalAudioPlayTime");
  assertValueKeptUnchanged(audioChrome, "audiblePlayTime");
  assertValueKeptUnchanged(audioChrome, "mutedPlayTime");
  assertValueKeptUnchanged(audioChrome, "totalVideoPlayTime");

  info(`audio becomes audible after 4s`);
  await once(audio, "mozinaudibleaudioplaytimepaused");

  await assertValueConstantlyIncreases(audioChrome, "totalAudioPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "audiblePlayTime");
  assertValueKeptUnchanged(audioChrome, "mutedPlayTime");
  assertValueKeptUnchanged(audioChrome, "totalVideoPlayTime");

  await cleanUpMediaAndCheckTelemetry(audio, {hasVideo: false});
});

add_task(async function testHiddenPlayTime() {
  const invisibleReasons = ["notInTree", "notInConnectedTree", "invisibleInDisplay"];
  for (let reason of invisibleReasons) {
    const video = document.createElement('video');
    video.src = "gizmo.mp4";
    video.loop = true;
    info(`invisible video due to '${reason}'`);

    if (reason == "notInConnectedTree") {
      let disconnected = document.createElement("div")
      disconnected.appendChild(video);
    } else if (reason == "invisibleInDisplay") {
      document.body.appendChild(video);
      video.style.display = "none";
    } else if (reason == "notInTree") {
      // video is already created in the `notInTree` situation.
    } else {
      ok(false, "undefined reason");
    }

    info(`start invisible video should start accumulating timers`);
    const videoChrome = SpecialPowers.wrap(video);
    let rv = await Promise.all([
      onceWithTrueReturn(video, "mozinvisibleplaytimestarted"),
      video.play().then(_ => true, _ => false),
    ]);
    ok(returnTrueWhenAllValuesAreTrue(rv), "video started playing");
    await assertValueConstantlyIncreases(videoChrome, "invisiblePlayTime");

    info(`should not accumulate time for paused video`);
    video.pause();
    await once(video, "mozinvisibleplaytimepaused");
    assertValueKeptUnchanged(videoChrome, "invisiblePlayTime");

    info(`should start accumulating time again`);
    rv = await Promise.all([
      onceWithTrueReturn(video, "mozinvisibleplaytimestarted"),
      video.play().then(_ => true, _ => false),
    ]);
    ok(returnTrueWhenAllValuesAreTrue(rv), "video started again");
    await assertValueConstantlyIncreases(videoChrome, "invisiblePlayTime");

    info(`make video visible should stop accumulating invisible related time`);
    if (reason == "notInTree" || reason == "notInConnectedTree") {
      document.body.appendChild(video);
    } else if (reason == "invisibleInDisplay") {
      video.style.display = "block";
    } else {
      ok(false, "undefined reason");
    }
    await once(video, "mozinvisibleplaytimepaused");
    assertValueKeptUnchanged(videoChrome, "invisiblePlayTime");
    await cleanUpMediaAndCheckTelemetry(video);
  }
});

add_task(async function testAudioProbesWithoutAudio() {
  const video = document.createElement('video');
  video.src = "gizmo-noaudio.mp4";
  video.loop = true;
  document.body.appendChild(video);

  info(`all accumulated time should be zero`);
  const videoChrome = SpecialPowers.wrap(video);
  await new Promise(r => video.onloadeddata = r);
  assertValueEqualTo(videoChrome, "totalVideoPlayTime", 0);
  assertValueEqualTo(videoChrome, "totalAudioPlayTime", 0);
  assertValueEqualTo(videoChrome, "mutedPlayTime", 0);
  assertValueEqualTo(videoChrome, "audiblePlayTime", 0);

  info(`start accumulating play time after media starts`);
  await Promise.all([
    video.play(),
    once(video, "moztotalplaytimestarted"),
  ]);

  async function checkInvariants() {
    await assertValueConstantlyIncreases(videoChrome, "totalVideoPlayTime");
    assertValueKeptUnchanged(videoChrome, "audiblePlayTime");
    assertValueKeptUnchanged(videoChrome, "mutedPlayTime");
    assertValueKeptUnchanged(videoChrome, "totalAudioPlayTime");
  }

  checkInvariants();

  video.muted = true;

  checkInvariants();

  video.currentTime = 0.0;
  await once(video, "seeked");

  checkInvariants();

  video.muted = false;

  checkInvariants();

  video.volume = 0.0;

  checkInvariants();

  video.volume = 1.0;

  checkInvariants();

  video.muted = true;

  checkInvariants();

  video.currentTime = 0.0;

  checkInvariants();

  await cleanUpMediaAndCheckTelemetry(video, {hasAudio: false});
});

add_task(async function testMutedAudioPlayTime() {
  const audio = document.createElement('audio');
  audio.src = "gizmo.mp4";
  audio.controls = true;
  audio.loop = true;
  document.body.appendChild(audio);

  info(`all accumulated time should be zero`);
  const audioChrome = SpecialPowers.wrap(audio);
  await new Promise(r => audio.onloadeddata = r);
  assertValueEqualTo(audioChrome, "totalVideoPlayTime", 0);
  assertValueEqualTo(audioChrome, "totalAudioPlayTime", 0);
  assertValueEqualTo(audioChrome, "mutedPlayTime", 0);
  assertValueEqualTo(audioChrome, "audiblePlayTime", 0);

  info(`start accumulating play time after media starts`);
  await Promise.all([
    audio.play(),
    once(audio, "moztotalplaytimestarted"),
  ]);
  await assertValueConstantlyIncreases(audioChrome, "totalAudioPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "audiblePlayTime");
  assertValueKeptUnchanged(audioChrome, "mutedPlayTime");
  assertValueKeptUnchanged(audioChrome, "totalVideoPlayTime");

  audio.muted = true;
  await once(audio, "mozmutedaudioplaytimestarted");

  await assertValueConstantlyIncreases(audioChrome, "totalAudioPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "mutedPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "audiblePlayTime");
  assertValueKeptUnchanged(audioChrome, "totalVideoPlayTime");

  audio.currentTime = 0.0;
  await once(audio, "seeked");

  await assertValueConstantlyIncreases(audioChrome, "totalAudioPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "mutedPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "audiblePlayTime");
  assertValueKeptUnchanged(audioChrome, "totalVideoPlayTime");

  audio.muted = false;
  await once(audio, "mozmutedeaudioplaytimepaused");

  await assertValueConstantlyIncreases(audioChrome, "totalAudioPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "audiblePlayTime");
  assertValueKeptUnchanged(audioChrome, "mutedPlayTime");
  assertValueKeptUnchanged(audioChrome, "totalVideoPlayTime");

  audio.volume = 0.0;
  await once(audio, "mozmutedaudioplaytimestarted");

  await assertValueConstantlyIncreases(audioChrome, "totalAudioPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "mutedPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "audiblePlayTime");
  assertValueKeptUnchanged(audioChrome, "totalVideoPlayTime");

  audio.volume = 1.0;
  await once(audio, "mozmutedeaudioplaytimepaused");

  await assertValueConstantlyIncreases(audioChrome, "totalAudioPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "audiblePlayTime");
  assertValueKeptUnchanged(audioChrome, "mutedPlayTime");
  assertValueKeptUnchanged(audioChrome, "totalVideoPlayTime");

  audio.muted = true;
  await once(audio, "mozmutedaudioplaytimestarted");

  await assertValueConstantlyIncreases(audioChrome, "totalAudioPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "mutedPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "audiblePlayTime");
  assertValueKeptUnchanged(audioChrome, "totalVideoPlayTime");

  audio.currentTime = 0.0;

  await assertValueConstantlyIncreases(audioChrome, "totalAudioPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "mutedPlayTime");
  await assertValueConstantlyIncreases(audioChrome, "audiblePlayTime");
  assertValueKeptUnchanged(audioChrome, "totalVideoPlayTime");

  // The media has a video track, but it's being played back in an
  // HTMLAudioElement, without video frame location.
  await cleanUpMediaAndCheckTelemetry(audio, {hasVideo: false});
});

// Note that video suspended time is not always align with the invisible play
// time even if `media.suspend-bkgnd-video.delay-ms` is `0`, because not all
// invisible videos would be suspended under current strategy.
add_task(async function testDecodeSuspendedTime() {
  const video = document.createElement('video');
  video.src = "gizmo.mp4";
  video.loop = true;
  document.body.appendChild(video);

  info(`start video should start accumulating timers`);
  const videoChrome = SpecialPowers.wrap(video);
  let rv = await Promise.all([
    onceWithTrueReturn(video, "moztotalplaytimestarted"),
    video.play().then(_ => true, _ => false),
  ]);
  ok(returnTrueWhenAllValuesAreTrue(rv), "video started playing");
  await assertValueConstantlyIncreases(videoChrome, "totalVideoPlayTime");
  assertValueKeptUnchanged(videoChrome, "invisiblePlayTime");
  assertValueKeptUnchanged(videoChrome, "videoDecodeSuspendedTime");

  info(`make it invisible and force to suspend decoding`);
  video.setVisible(false);
  await once(video, "mozvideodecodesuspendedstarted");
  await assertValueConstantlyIncreases(videoChrome, "totalVideoPlayTime");
  await assertValueConstantlyIncreases(videoChrome, "invisiblePlayTime");
  await assertValueConstantlyIncreases(videoChrome, "videoDecodeSuspendedTime");

  info(`make it visible and resume decoding`);
  video.setVisible(true);
  await Promise.all([
    once(video, "mozinvisibleplaytimepaused"),
    once(video, "mozvideodecodesuspendedpaused"),
  ]);
  await assertValueConstantlyIncreases(videoChrome, "totalVideoPlayTime");
  assertValueKeptUnchanged(videoChrome, "invisiblePlayTime");
  assertValueKeptUnchanged(videoChrome, "videoDecodeSuspendedTime");
  await cleanUpMediaAndCheckTelemetry(video);
});

add_task(async function reuseSameElementForPlayback() {
  const video = document.createElement('video');
  video.src = "gizmo.mp4";
  document.body.appendChild(video);

  info(`start accumulating play time after media starts`);
  const videoChrome = SpecialPowers.wrap(video);
  let rv = await Promise.all([
    onceWithTrueReturn(video, "moztotalplaytimestarted"),
    video.play().then(_ => true, _ => false),
  ]);
  ok(returnTrueWhenAllValuesAreTrue(rv), "video started again");
  await assertValueConstantlyIncreases(videoChrome, "totalVideoPlayTime");

  info(`reset its src and all accumulated value should be reset after then`);
  // After setting its src to nothing, that would trigger a failed load and set
  // the error. If the following step tries to set the new resource and `play()`
  // , then they should be done after receving the `error` from that failed load
  // first.
  await Promise.all([
    once(video, "error"),
    cleanUpMediaAndCheckTelemetry(video),
  ]);
  // video doesn't have a decoder, so the return value would be -1 (error).
  assertValueEqualTo(videoChrome, "totalVideoPlayTime", -1);
  assertValueEqualTo(videoChrome, "invisiblePlayTime", -1);

  info(`resue same element, make it visible and start playback again`);
  video.src = "gizmo.mp4";
  rv = await Promise.all([
    onceWithTrueReturn(video, "moztotalplaytimestarted"),
    video.play().then(_ => true, _ => false),
  ]);
  ok(returnTrueWhenAllValuesAreTrue(rv), "video started");
  await assertValueConstantlyIncreases(videoChrome, "totalVideoPlayTime");
  await cleanUpMediaAndCheckTelemetry(video);
});

add_task(async function testNoReportedTelemetryResult() {
  info(`No result for empty video`);
  const video = document.createElement('video');
  assertAllProbeRelatedAttributesKeptUnchanged(video);
  await assertNoReportedTelemetryResult(video);

  info(`No result for video which hasn't started playing`);
  video.src = "gizmo.mp4";
  document.body.appendChild(video);
  ok(await once(video, "loadeddata").then(_ => true), "video loaded data");
  assertAllProbeRelatedAttributesKeptUnchanged(video);
  await assertNoReportedTelemetryResult(video);

  info(`No result for video with error`);
  video.src = "filedoesnotexist.mp4";
  ok(await video.play().then(_ => false, _ => true), "video failed to play");
  ok(video.error != undefined, "video got error");
  assertAllProbeRelatedAttributesKeptUnchanged(video);
  await assertNoReportedTelemetryResult(video);
});

/**
 * Following are helper functions
 */
async function cleanUpMediaAndCheckTelemetry(media, { reportExpected = true, hasVideo = true, hasAudio = true, hasVideoHDR = false } = {}) {
  media.src = "";
  await checkReportedTelemetry(media, reportExpected, hasVideo, hasAudio, hasVideoHDR);
}

async function assertNoReportedTelemetryResult(media) {
  await checkReportedTelemetry(media, false, true, true);
}

async function checkReportedTelemetry(media, reportExpected, hasVideo, hasAudio, hasVideoHDR) {
  const reportResultPromise = once(media, "mozreportedtelemetry");
  info(`check telemetry result, reportExpected=${reportExpected}`);
  if (reportExpected) {
    await reportResultPromise;
  }
  for (const name of videoHistNames) {
    try {
      const hist = SpecialPowers.Services.telemetry.getHistogramById(name);
      /**
       * Histogram's snapshot looks like that
       * {
       *    "bucket_count": X,
       *    "histogram_type": Y,
       *    "sum": Z,
       *    "range": [min, max],
       *    "values": { "value1" : "num1", "value2" : "num2", ...}
       * }
       */
      const entriesNums = Object.entries(hist.snapshot().values).length;
      if (reportExpected && hasVideo) {
        ok(entriesNums > 0, `Reported result for ${name}`);
      } else {
        ok(entriesNums == 0, `Reported nothing for ${name}`);
      }
      hist.clear();
    } catch (e) {
      ok(false , `histogram '${name}' doesn't exist`);
    }
  }
  // videoHDRHistNames are checked for total time, not for number of samples.
  for (const name of videoHDRHistNames) {
    try {
      const hist = SpecialPowers.Services.telemetry.getHistogramById(name);
      const totalTimeMS = hist.snapshot().sum;
      if (reportExpected && hasVideoHDR) {
        ok(totalTimeMS > 0, `Reported some time for ${name}`);
      } else {
        ok(totalTimeMS == 0, `Reported no time for ${name}`);
      }
      hist.clear();
    } catch (e) {
      ok(false , `histogram '${name}' doesn't exist`);
    }
  }
  for (const name of videoKeyedHistNames) {
    try {
      const hist = SpecialPowers.Services.telemetry.getKeyedHistogramById(name);
      /**
       * Keyed Histogram's snapshot looks like that
       * {
       *    "Key1" : {
       *      "bucket_count": X,
       *      "histogram_type": Y,
       *      "sum": Z,
       *      "range": [min, max],
       *      "values": { "value1" : "num1", "value2" : "num2", ...}
       *    },
       *    "Key2" : {...},
       * }
       */
      const items = Object.entries(hist.snapshot());
      if (items.length) {
        for (const [key, value] of items) {
          const entriesNums = Object.entries(value.values).length;
          ok(reportExpected && entriesNums > 0, `Reported ${key} for ${name}`);
        }
      } else if (reportExpected) {
        ok(!hasVideo, `No video telemetry reported but no video track in the media`);
      } else {
        ok(true, `No video telemetry expected, none reported`);
      }
      // Avoid to pollute next test task.
      hist.clear();
    } catch (e) {
      ok(false , `keyed histogram '${name}' doesn't exist`);
    }
  }

  // In any case, the combined probe MEDIA_PLAY_TIME_MS should be reported, if
  // expected
  {
    const hist =
      SpecialPowers.Services.telemetry.getKeyedHistogramById("MEDIA_PLAY_TIME_MS");
    const items = Object.entries(hist.snapshot());
    if (items.length) {
      for (const item of items) {
        ok(item[0].includes("V") != -1 || !hasVideo, "Video time is reported if video was present");
      }
      hist.clear();
    } else {
      ok(!reportExpected, "MEDIA_PLAY_TIME_MS should always be reported if a report is expected");
    }
  }

  for (const name of audioKeyedHistNames) {
    try {
      const hist = SpecialPowers.Services.telemetry.getKeyedHistogramById(name);
      const items = Object.entries(hist.snapshot());
      if (items.length) {
        for (const [key, value] of items) {
          const entriesNums = Object.entries(value.values).length;
          ok(reportExpected && entriesNums > 0, `Reported ${key} for ${name}`);
        }
      } else {
        ok(!reportExpected || !hasAudio, `No audio telemetry expected, none reported`);
      }
      // Avoid to pollute next test task.
      hist.clear();
    } catch (e) {
      ok(false , `keyed histogram '${name}' doesn't exist`);
    }
  }
}

function once(target, name) {
  return new Promise(r => target.addEventListener(name, r, { once: true }));
}

function onceWithTrueReturn(target, name) {
  return once(target, name).then(_ => true);
}

function returnTrueWhenAllValuesAreTrue(arr) {
  for (let val of arr) {
    if (!val) {
      return false;
    }
  }
  return true;
}

// Block the main thread for a number of milliseconds
function blockMainThread(durationMS) {
  const start = Date.now();
  while (Date.now() - start < durationMS) { /* spin */ }
}

// Allows comparing two values from the system clocks that are not gathered
// atomically. Allow up to 1ms of fuzzing when lhs and rhs are seconds.
function timeFuzzyEquals(lhs, rhs, str) {
  ok(Math.abs(lhs - rhs) < 1e-3, str);
}

function assertAttributeDefined(mediaChrome, checkType) {
  ok(mediaChrome[checkType] != undefined, `${checkType} exists`);
}

function assertValueEqualTo(mediaChrome, checkType, expectedValue) {
  assertAttributeDefined(mediaChrome, checkType);
  is(mediaChrome[checkType], expectedValue, `${checkType} equals to ${expectedValue}`);
}

async function assertValueConstantlyIncreases(mediaChrome, checkType) {
  assertAttributeDefined(mediaChrome, checkType);
  const valueSnapshot = mediaChrome[checkType];
  // 30ms is long enough to have a low-resolution system clock tick, but short
  // enough to not slow the test down.
  blockMainThread(30);
  const current = mediaChrome[checkType];
  ok(current > valueSnapshot, `${checkType} keeps increasing (${current} > ${valueSnapshot})`);
}

function assertValueKeptUnchanged(mediaChrome, checkType) {
  assertAttributeDefined(mediaChrome, checkType);
  const valueSnapshot = mediaChrome[checkType];
  // 30ms is long enough to have a low-resolution system clock tick, but short
  // enough to not slow the test down.
  blockMainThread(30);
  const newValue = mediaChrome[checkType];
  timeFuzzyEquals(newValue, valueSnapshot, `${checkType} keeps unchanged (${newValue} vs. ${valueSnapshot})`);
}

function assertAllProbeRelatedAttributesKeptUnchanged(video) {
  const videoChrome = SpecialPowers.wrap(video);
  assertValueKeptUnchanged(videoChrome, "totalVideoPlayTime");
  assertValueKeptUnchanged(videoChrome, "invisiblePlayTime");
  assertValueKeptUnchanged(videoChrome, "videoDecodeSuspendedTime");
}

</script>
</head>
<body>
</body>
</html>