Initial implementation of PerformanceLongAnimationFrame

Design document:
https://docs.google.com/document/d/1SeMd4KbXWZf0ZnRSMvYhjSBpXPBln5xrRyTu2Gr68BY/edit

- Added IDL for PerformanceLongAnimationFrame
- Added main infrastructure to measure long frames, without support
  for scripts (yet).

How it works:

- AnimationFrameTimingMonitor, instantiated once per local root
  (widget), keeps track of whether the current task
  belongs to which local frame.

- If a task ends without pending updates, report it as a long
  frame only to frames within the same LocalRoot.

- Otherwise, keep track of the start time and report to all
  affected frames when the frame has done rendering
  (right after intersection observers, before
  painting/compositing).

Next steps:
- Add support for scripts
- Generate UKM data from this, to be examined while trying out the
  API.

Bug: 1392685
Change-Id: Ic94fd24402e6365b0043a91fdeb413892261ad29
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4259728
Reviewed-by: Yoav Weiss <yoavweiss@chromium.org>
Commit-Queue: Noam Rosenthal <nrosenthal@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1110934}
diff --git a/long-animation-frame/META.yml b/long-animation-frame/META.yml
new file mode 100644
index 0000000..769c325
--- /dev/null
+++ b/long-animation-frame/META.yml
@@ -0,0 +1,2 @@
+suggested_reviewers:
+  - noamr
diff --git a/long-animation-frame/tentative/loaf-basic.html b/long-animation-frame/tentative/loaf-basic.html
new file mode 100644
index 0000000..d289f0d
--- /dev/null
+++ b/long-animation-frame/tentative/loaf-basic.html
@@ -0,0 +1,54 @@
+<!DOCTYPE HTML>
+<meta charset=utf-8>
+<title>Long Animation Frame Timing: basic</title>
+<meta name="timeout" content="long">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/utils.js"></script>
+
+<body>
+<h1>Long Animation Frame: basic</h1>
+<div id="log"></div>
+<script>
+
+promise_test(async t => {
+    await expect_long_frame(() => busy_wait(), t);
+}, 'A long busy wait is a long animation frame');
+
+promise_test(async t => {
+    await expect_long_frame(() => requestAnimationFrame(busy_wait), t);
+}, 'A long busy wait in a requestAnimationFrame is a long animation frame');
+
+promise_test(async t => {
+    const segment_duration = very_long_frame_duration / 2;
+    const entry = await expect_long_frame(async () => {
+        busy_wait(segment_duration);
+        await new Promise(resolve => requestAnimationFrame(() => {
+            busy_wait(segment_duration)
+            resolve();
+        }));
+    }, t);
+
+    assert_greater_than_equal(entry.renderStart - entry.startTime, segment_duration);
+}, 'A long busy wait split between a task and a requestAnimationFrame is a long animation frame');
+
+promise_test(async t => {
+    const segment_duration = very_long_frame_duration / 3;
+    const entry = await expect_long_frame(async () => {
+        const element = document.createElement("div");
+        document.body.appendChild(element);
+        t.add_cleanup(() => element.remove());
+        busy_wait(segment_duration);
+        requestAnimationFrame(() => {
+            busy_wait(segment_duration);
+        });
+
+        new ResizeObserver(() => {
+            busy_wait(segment_duration);
+        }).observe(element);
+    }, t);
+    assert_greater_than_equal(entry.renderStart - entry.startTime, segment_duration);
+    assert_greater_than_equal(entry.styleAndLayoutStart - entry.renderStart, segment_duration);
+}, 'ResizeObservers should create a long-frame and affect layoutStartTime');
+</script>
+</body>
diff --git a/long-animation-frame/tentative/loaf-buffered.html b/long-animation-frame/tentative/loaf-buffered.html
new file mode 100644
index 0000000..c7ede84
--- /dev/null
+++ b/long-animation-frame/tentative/loaf-buffered.html
@@ -0,0 +1,25 @@
+<!DOCTYPE HTML>
+<meta charset=utf-8>
+<title>Long Animation Frame Timing: basic</title>
+<meta name="timeout" content="long">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/utils.js"></script>
+
+<body>
+<h1>Long Animation Frame: buffered</h1>
+<div id="log"></div>
+<script>
+promise_test(async t => {
+    busy_wait(very_long_frame_duration);
+    await new Promise(resolve => t.step_timeout(resolve, 0));
+    const entry = await new Promise(resolve => {
+        new PerformanceObserver(t.step_func((entryList) => {
+            const entry = entryList.getEntries()[0];
+            if (entry.duration >= very_long_frame_duration)
+                resolve(entry);
+        })).observe({type: 'long-animation-frame', buffered: true});
+    });
+}, 'PerformanceObserver with buffered flag can see previous long-animation-frame entries.');
+</script>
+</body>
diff --git a/long-animation-frame/tentative/loaf-iframe-popup.html b/long-animation-frame/tentative/loaf-iframe-popup.html
new file mode 100644
index 0000000..9cdb2c3
--- /dev/null
+++ b/long-animation-frame/tentative/loaf-iframe-popup.html
@@ -0,0 +1,67 @@
+<!DOCTYPE HTML>
+<meta charset=utf-8>
+<title>Long Animation Frame Timing: iframes</title>
+<body>
+
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/common/get-host-info.sub.js"></script>
+<script src="/common/utils.js"></script>
+<script src="/common/dispatcher/dispatcher.js"></script>
+<script src="resources/utils.js"></script>
+<div id="log"></div>
+<script>
+
+const host_info = get_host_info();
+const {ORIGIN, REMOTE_ORIGIN, HTTP_NOTSAMESITE_ORIGIN} = host_info;
+
+promise_test(async t => {
+    const executor = await prepare_exec_iframe(t, ORIGIN);
+    await expect_no_long_frame(() => executor.execute_script((duration) => {
+        const deadline = performance.now() + duration;
+        while (performance.now() < deadline) {}
+    }, [very_long_frame_duration]), t);
+}, 'A long busy wait without render in a same-origin iframe is not a long animation frame');
+
+promise_test(async t => {
+    const executor = await prepare_exec_iframe(t, HTTP_NOTSAMESITE_ORIGIN);
+    await expect_no_long_frame(() => executor.execute_script((duration) => {
+        const deadline = performance.now() + duration;
+        while (performance.now() < deadline) {}
+    }, [very_long_frame_duration]), t);
+}, 'A long busy wait in a cross-origin iframe is not a long animation frame');
+
+promise_test(async t => {
+    const executor = await prepare_exec_iframe(t, ORIGIN);
+    await expect_long_frame(() => executor.execute_script(async (duration) => {
+        await new Promise(resolve => window.requestAnimationFrame(resolve));
+        const deadline = performance.now() + duration;
+        while (performance.now() < deadline) {}
+    }, [very_long_frame_duration]), t);
+}, 'A long busy wait in a same-origin requestAnimationFrame is a long animation frame');
+
+promise_test(async t => {
+    const executor = await prepare_exec_popup(t, ORIGIN);
+    await expect_no_long_frame(() => executor.execute_script((duration) => {
+        const deadline = performance.now() + duration;
+        while (performance.now() < deadline) {}
+    }), t);
+}, 'A long busy wait in a same-origin popup is a not long animation frame');
+
+for (const origin of ["ORIGIN", "REMOTE_ORIGIN", "HTTP_NOTSAMESITE_ORIGIN"]) {
+    promise_test(async t => {
+        const executor = await prepare_exec_iframe(t, host_info[origin]);
+        const entry = await executor.execute_script(async (duration) => {
+            const entryPromise = new Promise(resolve => new PerformanceObserver(list => {
+                resolve(list.getEntries(0));
+            }).observe({entryTypes: ["long-animation-frame"]}));
+            const deadline = performance.now() + duration;
+            while (performance.now() < deadline) {}
+            return entryPromise;
+        }, [very_long_frame_duration]);
+    }, `frames receive own long animation frames (${origin})`);
+}
+
+
+</script>
+</body>
diff --git a/long-animation-frame/tentative/loaf-timeline.html b/long-animation-frame/tentative/loaf-timeline.html
new file mode 100644
index 0000000..c434a26
--- /dev/null
+++ b/long-animation-frame/tentative/loaf-timeline.html
@@ -0,0 +1,27 @@
+<!DOCTYPE HTML>
+<meta charset=utf-8>
+<title>Long Animation Frame Timing: basic</title>
+<meta name="timeout" content="long">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/utils.js"></script>
+
+<body>
+<h1>Long Animation Frame: basic</h1>
+<div id="log"></div>
+<script>
+promise_test(async t => {
+    busy_wait(very_long_frame_duration);
+    const is_loaf = entry => entry.duration >= very_long_frame_duration &&
+      entry.entryType == "long-animation-frame";
+
+    await new Promise(resolve => t.step_timeout(resolve, 10));
+    const entry_from_all = [...performance.getEntries()].find(is_loaf);
+    const entry_by_type = [...performance.getEntriesByType("long-animation-frame")].find(is_loaf);
+    const entry_by_name = [...performance.getEntriesByName("long-animation-frame")].find(is_loaf);
+    assert_true(!!entry_from_all, "LoAF Entry found");
+    assert_equals(entry_from_all, entry_by_type);
+    assert_equals(entry_from_all, entry_by_name);
+}, 'LoAF entries are available in the performnace timeline');
+</script>
+</body>
diff --git a/long-animation-frame/tentative/loaf-toJSON.html b/long-animation-frame/tentative/loaf-toJSON.html
new file mode 100644
index 0000000..f78e95d
--- /dev/null
+++ b/long-animation-frame/tentative/loaf-toJSON.html
@@ -0,0 +1,44 @@
+<!doctype html>
+<html>
+<head>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+</head>
+<body>
+<script>
+  promise_test(async t => {
+    window.onload = () => {
+        // Trigger a long task.
+        const begin = window.performance.now();
+        while (window.performance.now() < begin + 60);
+    };
+
+    assert_implements(window.PerformanceLongAnimationFrameTiming, 'Lon are not supported.');
+    const entry = await new Promise(resolve => new PerformanceObserver(
+      t.step_func(entryList => {
+        const entries = entryList.getEntries();
+        assert_greater_than_equal(entries.length, 1);
+        resolve(entries[0]);
+      })).observe({entryTypes: ["long-animation-frame"]}));
+
+    assert_equals(typeof(entry.toJSON), 'function');
+    const entryJSON = entry.toJSON();
+    assert_equals(typeof(entryJSON), 'object');
+    // Check attributes inheritted from PerformanceEntry.
+    const performanceEntryKeys = [
+        'name',
+        'entryType',
+        'startTime',
+        'duration',
+        'renderStart',
+        'styleAndLayoutStart'
+    ];
+    for (const key of performanceEntryKeys) {
+        assert_equals(entryJSON[key], entry[key],
+            `entry.toJSON().${key} should match entry.${key}`);
+    }
+
+  }, 'Test toJSON() in PerformanceLongAnimationFrameTiming');
+</script>
+</body>
+</html>
diff --git a/long-animation-frame/tentative/loaf-visibility.html b/long-animation-frame/tentative/loaf-visibility.html
new file mode 100644
index 0000000..97038e3
--- /dev/null
+++ b/long-animation-frame/tentative/loaf-visibility.html
@@ -0,0 +1,26 @@
+<!DOCTYPE HTML>
+<meta charset=utf-8>
+<meta name="timeout" content="long">
+<title>Long Animation Frame Timing: iframes</title>
+
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="/common/utils.js"></script>
+<script src="/page-visibility/resources/window_state_context.js"></script>
+<script src="resources/utils.js"></script>
+<body>
+    <div id="log"></div>
+<script>
+
+promise_test(async t => {
+    const {minimize, restore} = window_state_context(t);
+    await minimize();
+    expect_no_long_frame(busy_wait, t);
+    await restore();
+    expect_long_frame(busy_wait, t);
+}, 'Invisible windows do not report long animation frames');
+
+</script>
+</body>
diff --git a/long-animation-frame/tentative/resources/utils.js b/long-animation-frame/tentative/resources/utils.js
new file mode 100644
index 0000000..47c138b
--- /dev/null
+++ b/long-animation-frame/tentative/resources/utils.js
@@ -0,0 +1,76 @@
+const windowLoaded = new Promise(resolve => window.addEventListener('load', resolve));
+setup(() =>
+  assert_implements(window.PerformanceLongAnimationFrameTiming,
+    'Long animation frames are not supported.'));
+
+const very_long_frame_duration = 360;
+
+function loaf_promise() {
+  return new Promise(resolve => {
+      const observer = new PerformanceObserver(entries => {
+          const entry = entries.getEntries()[0];
+          if (entry.duration >= very_long_frame_duration)
+            resolve(entry);
+      });
+
+      observer.observe({entryTypes: ['long-animation-frame']});
+  });
+}
+
+const no_long_frame_timeout = very_long_frame_duration * 2;
+
+function busy_wait(ms_delay = very_long_frame_duration) {
+  const deadline = performance.now() + ms_delay;
+  while (performance.now() < deadline) {}
+}
+
+async function expect_long_frame(cb, t) {
+  await windowLoaded;
+  await new Promise(resolve => t.step_timeout(resolve, 0));
+  const timeout = new Promise((resolve, reject) =>
+    t.step_timeout(() => reject("timeout"), no_long_frame_timeout));
+  const receivedLongFrame = loaf_promise();
+  await cb();
+  const entry = await Promise.race([
+    receivedLongFrame,
+    timeout
+  ]);
+  return entry;
+}
+
+async function expect_no_long_frame(cb, t) {
+  await windowLoaded;
+  for (let i = 0; i < 5; ++i) {
+    const receivedLongFrame = loaf_promise();
+    await cb();
+    const result = await Promise.race([receivedLongFrame,
+        new Promise(resolve => t.step_timeout(() => resolve("timeout"),
+        no_long_frame_timeout))]);
+    if (result === "timeout")
+      return false;
+  }
+
+  throw new Error("Consistently creates long frame");
+}
+
+async function prepare_exec_iframe(t, origin) {
+  const iframe = document.createElement("iframe");
+  t.add_cleanup(() => iframe.remove());
+  const url = new URL("/common/dispatcher/remote-executor.html", origin);
+  const uuid = token();
+  url.searchParams.set("uuid", uuid);
+  iframe.src = url.href;
+  document.body.appendChild(iframe);
+  await new Promise(resolve => iframe.addEventListener("load", resolve));
+  return new RemoteContext(uuid);
+}
+
+
+async function prepare_exec_popup(t, origin) {
+  const url = new URL("/common/dispatcher/remote-executor.html", origin);
+  const uuid = token();
+  url.searchParams.set("uuid", uuid);
+  const popup = window.open(url);
+  t.add_cleanup(() => popup.close());
+  return new RemoteContext(uuid);
+}