summaryrefslogtreecommitdiffstats
path: root/testing/web-platform/tests/dom/events/scrolling
diff options
context:
space:
mode:
Diffstat (limited to 'testing/web-platform/tests/dom/events/scrolling')
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/iframe-chains.html48
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/input-text-scroll-event-when-using-arrow-keys.html71
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/overscroll-deltas.html157
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-document.html62
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-element-with-overscroll-behavior.html92
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-scrolled-element.html65
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-window.html52
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scroll_support.js280
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-after-sequence-of-scrolls.tentative.html91
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-after-snap.html87
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-for-mandatory-snap-point-after-load.html87
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-for-programmatic-scroll.html135
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-for-scrollIntoView.html124
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-to-document.html106
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-to-element-with-overscroll-behavior.html173
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-to-window.html69
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-fires-on-visual-viewport.html48
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-fires-to-iframe-inner-frame.html30
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-fires-to-iframe-window.html88
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-for-user-scroll.html64
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-handler-content-attributes.html108
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-not-fired-after-removing-scroller.tentative.html84
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-event-not-fired-on-no-scroll.html114
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-fires-to-text-input.html32
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-user-scroll-common.js145
-rw-r--r--testing/web-platform/tests/dom/events/scrolling/scrollend-with-snap-on-fractional-offset.html85
26 files changed, 2497 insertions, 0 deletions
diff --git a/testing/web-platform/tests/dom/events/scrolling/iframe-chains.html b/testing/web-platform/tests/dom/events/scrolling/iframe-chains.html
new file mode 100644
index 0000000000..fb7d674aae
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/iframe-chains.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<style>
+
+body { margin: 0; padding: 10px; }
+.space { height: 2000px; }
+
+#scroller {
+ border: 3px solid green;
+ position: absolute;
+ z-index: 0;
+ overflow: auto;
+ padding: 10px;
+ width: 250px;
+ height: 150px;
+}
+
+.ifr {
+ border: 3px solid blue;
+ width: 200px;
+ height: 100px;
+}
+
+</style>
+</head>
+<body>
+<div id=scroller>
+ <iframe srcdoc="SCROLL ME" class=ifr></iframe>
+ <div class=space></div>
+</div>
+<div class=space></div>
+<script>
+
+promise_test(async t => {
+ await new test_driver.Actions().scroll(50, 50, 0, 50).send();
+ // Allow the possibility the scroll is not fully synchronous
+ await t.step_wait(() => scroller.scrollTop === 50);
+}, "Wheel scroll in iframe chains to containing element.");
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/dom/events/scrolling/input-text-scroll-event-when-using-arrow-keys.html b/testing/web-platform/tests/dom/events/scrolling/input-text-scroll-event-when-using-arrow-keys.html
new file mode 100644
index 0000000000..f84e446527
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/input-text-scroll-event-when-using-arrow-keys.html
@@ -0,0 +1,71 @@
+<!doctype html>
+<html>
+<head>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+
+</head>
+<body onload=runTest()>
+ <p>Moving the cursor using the arrow keys into an
+ input element fires scroll events when text has to scroll into view.
+ Uses arrow keys to move forward and backwards in the input
+ element.</p>
+ <input type="text" style='width: 50px'
+ value="Fooooooooooooooooooooooooooooooooooooooooooooooooo"/>
+ <textarea rows="4" cols="4">
+ Fooooooooooooooooooooooooooooooooooooooooooooooooo
+ </textarea>
+
+ <script>
+ async function moveCursorRightInsideElement(element, value){
+ var arrowRight = '\uE014';
+ for(var i=0;i<value;i++){
+ await test_driver.send_keys(element, arrowRight);
+ }
+ }
+
+ function runTest(){
+ promise_test(async(t) => { return new Promise(async (resolve, reject) => {
+ var input = document.getElementsByTagName('input')[0];
+ function handleScroll(){
+ resolve("Scroll Event successfully fired!");
+ }
+ input.addEventListener('scroll', handleScroll, false);
+ // move cursor to the right until the text scrolls
+ while(input.scrollLeft === 0){
+ await moveCursorRightInsideElement(input, 1);
+ }
+ // if there is no scroll event fired then test will fail by timeout
+ })},
+ /*
+ Moving the cursor using the arrow keys into an input element
+ fires scroll events when text has to scroll into view.
+ Uses arrow keys to move right in the input element.
+ */
+ "Scroll event fired for <input> element.");
+
+ promise_test(async(t) => { return new Promise(async (resolve, reject) => {
+ var textarea = document.getElementsByTagName('textarea')[0];
+ function handleScroll(){
+ resolve("Scroll Event successfully fired!");
+ }
+ textarea.addEventListener('scroll', handleScroll, false);
+ // move cursor to the right until the text scrolls
+ while(textarea.scrollLeft === 0){
+ await moveCursorRightInsideElement(textarea, 1);
+ }
+ // if there is no scroll event fired then test will fail by timeout
+ })},
+ /*
+ Moving the cursor using the arrow keys into a textarea element
+ fires scroll events when text has to scroll into view.
+ Uses arrow keys to move right in the textarea element.
+ */
+ "Scroll event fired for <textarea> element.");
+ }
+ </script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/dom/events/scrolling/overscroll-deltas.html b/testing/web-platform/tests/dom/events/scrolling/overscroll-deltas.html
new file mode 100644
index 0000000000..e13e9f1cce
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/overscroll-deltas.html
@@ -0,0 +1,157 @@
+<!DOCTYPE HTML>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+ #hspacer {
+ height: 100px;
+ width: 100vw;
+ top: 0;
+ /* on the right edge of targetDiv */
+ left: 200px;
+ position: absolute;
+ }
+
+ #vspacer {
+ height: 100vh;
+ width: 100px;
+ position: absolute;
+ }
+
+ #targetDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+ }
+
+ #innerDiv {
+ width: 400px;
+ height: 400px;
+ }
+</style>
+
+<body style="margin:0" onload=runTest()>
+ <div id="targetDiv">
+ <div id="innerDiv"></div>
+ </div>
+ <div id="hspacer"></div>
+ <div id="vspacer"></div>
+</body>
+
+<script>
+ var target_div = document.getElementById('targetDiv');
+ var overscrolled_x_deltas = [];
+ var overscrolled_y_deltas = [];
+ var scrollend_received = false;
+
+ function onOverscroll(event) {
+ overscrolled_x_deltas.push(event.deltaX);
+ overscrolled_y_deltas.push(event.deltaY);
+ }
+
+ async function resetScrollers(test) {
+ await waitForScrollReset(test, target_div);
+ await waitForScrollReset(test, document.scrollingElement);
+ }
+
+ function resetOverScrollDeltas() {
+ overscrolled_x_deltas = [];
+ overscrolled_y_deltas = [];
+ }
+
+ function waitForOverscrollEventWithMinDelta(target, min_x = 0, min_y = 0) {
+ return new Promise((resolve) => {
+ target.addEventListener("overscroll", (evt) => {
+ if (evt.deltaX >= min_x && evt.deltaY >= min_y) {
+ resolve();
+ }
+ });
+ });
+ }
+
+ function unreachedScrollendListener() {
+ assert_unreached('Unexpected scrollend event');
+ }
+
+ document.addEventListener("overscroll", onOverscroll);
+
+ function runTest() {
+ promise_test(async (t) => {
+ await resetScrollers(t);
+ await waitForCompositorCommit();
+ resetOverScrollDeltas();
+
+ assert_equals(document.scrollingElement.scrollTop, 0,
+ "document should not be scrolled");
+
+ let scrollend_promise = waitForScrollendEvent(t, target_div);
+ let max_target_div_scroll_top = target_div.scrollHeight - target_div.clientHeight;
+ target_div.scrollTop = target_div.scrollHeight;
+ await scrollend_promise;
+ assert_equals(target_div.scrollTop, max_target_div_scroll_top,
+ "target_div should be fully scrolled down");
+
+ // Even though we request 300 extra pixels of scroll, the API above doesn't
+ // guarantee how much scroll delta will be generated - different browsers
+ // can consume different amounts for "touch slop" (for example). Ensure the
+ // overscroll reaches at least 250 pixels which is a fairly conservative
+ // value.
+ let overscroll_promise = waitForOverscrollEventWithMinDelta(document,
+ /*min_x*/0, /*min_y*/250);
+ scrollend_promise = waitForScrollendEvent(t, document, 2000);
+ target_div.addEventListener("scrollend", unreachedScrollendListener);
+ // Scroll target div vertically and wait for the doc to get scrollend event.
+ await scrollElementDown(target_div, target_div.clientHeight + 300);
+ await waitForCompositorCommit();
+ await overscroll_promise;
+ await scrollend_promise;
+
+ target_div.removeEventListener("scrollend", unreachedScrollendListener);
+ assert_greater_than(overscrolled_y_deltas.length, 0, "There should be at least one overscroll events when overscrolling.");
+ assert_equals(overscrolled_x_deltas.filter(function (x) { return x != 0; }).length, 0, "The deltaX attribute must be 0 when there is no scrolling in x direction.");
+ assert_less_than_equal(Math.max(...overscrolled_y_deltas), 300, "The deltaY attribute must be <= the number of pixels overscrolled (300)");
+ assert_greater_than(document.scrollingElement.scrollTop, target_div.clientHeight - 1,
+ "document is scrolled by the height of target_div");
+ }, "testing, vertical");
+
+ promise_test(async (t) => {
+ await resetScrollers(t);
+ await waitForCompositorCommit();
+ resetOverScrollDeltas();
+
+ assert_equals(document.scrollingElement.scrollLeft, 0,
+ "document should not be scrolled");
+
+ let scrollend_promise = waitForScrollendEvent(t, target_div);
+ let max_target_div_scroll_left = target_div.scrollWidth - target_div.clientWidth;
+ target_div.scrollLeft = target_div.scrollWidth;
+ await scrollend_promise;
+ assert_equals(target_div.scrollLeft, max_target_div_scroll_left,
+ "target_div should be fully scrolled right");
+
+ let overscroll_promise = waitForOverscrollEventWithMinDelta(document,
+ /*min_x*/250, /*min_y*/ 0);
+ scrollend_promise = waitForScrollendEvent(t, document, 2000);
+ target_div.addEventListener("scrollend", unreachedScrollendListener);
+ // Scroll target div horizontally and wait for the doc to get scrollend event.
+ await scrollElementLeft(target_div, target_div.clientWidth + 300);
+ await waitForCompositorCommit();
+ await overscroll_promise;
+ await scrollend_promise;
+
+ target_div.removeEventListener("scrollend", unreachedScrollendListener);
+ assert_greater_than(document.scrollingElement.scrollLeft, target_div.clientWidth - 1,
+ "document is scrolled by the height of target_div");
+ // TODO(bokan): It looks like Chrome inappropriately filters some scroll
+ // events despite |overscroll-behavior| being set to none. The overscroll
+ // amount here has been loosened but this should be fixed in Chrome.
+ // https://crbug.com/1112183.
+ assert_greater_than(overscrolled_x_deltas.length, 0, "There should be at least one overscroll events when overscrolling.");
+ assert_equals(overscrolled_y_deltas.filter(function(x){ return x!=0; }).length, 0, "The deltaY attribute must be 0 when there is no scrolling in y direction.");
+ assert_less_than_equal(Math.max(...overscrolled_x_deltas), 300, "The deltaX attribute must be <= number of pixels overscrolled (300)");
+ }, "testing, horizontal");
+ }
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-document.html b/testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-document.html
new file mode 100644
index 0000000000..c054ffca9c
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-document.html
@@ -0,0 +1,62 @@
+<!DOCTYPE HTML>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+#targetDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+}
+
+#innerDiv {
+ width: 400px;
+ height: 400px;
+}
+</style>
+
+<body style="margin:0" onload=runTest()>
+<div id="targetDiv">
+ <div id="innerDiv">
+ </div>
+</div>
+</body>
+
+<script>
+var target_div = document.getElementById('targetDiv');
+var overscrolled_x_delta = 0;
+var overscrolled_y_delta = 0;
+function onOverscroll(event) {
+ assert_false(event.cancelable);
+ // overscroll events are bubbled when the target node is document.
+ assert_true(event.bubbles);
+ overscrolled_x_delta = event.deltaX;
+ overscrolled_y_delta = event.deltaY;
+}
+document.addEventListener("overscroll", onOverscroll);
+
+function runTest() {
+ promise_test (async (t) => {
+ // Make sure that no overscroll event is sent to target_div.
+ target_div.addEventListener("overscroll",
+ t.unreached_func("target_div got unexpected overscroll event."));
+ await waitForCompositorCommit();
+
+ // Scroll up on target div and wait for the doc to get overscroll event.
+ await touchScrollInTarget(300, target_div, 'up');
+ await waitFor(() => { return overscrolled_y_delta < 0; },
+ 'Document did not receive overscroll event after scroll up on target.');
+ assert_equals(target_div.scrollTop, 0);
+
+ // Scroll left on target div and wait for the doc to get overscroll event.
+ await touchScrollInTarget(300, target_div, 'left');
+ await waitFor(() => { return overscrolled_x_delta < 0; },
+ 'Document did not receive overscroll event after scroll left on target.');
+ assert_equals(target_div.scrollLeft, 0);
+ }, 'Tests that the document gets overscroll event when no element scrolls ' +
+ 'after touch scrolling.');
+}
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-element-with-overscroll-behavior.html b/testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-element-with-overscroll-behavior.html
new file mode 100644
index 0000000000..750080e656
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-element-with-overscroll-behavior.html
@@ -0,0 +1,92 @@
+<!DOCTYPE HTML>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+#overscrollXDiv {
+ width: 600px;
+ height: 600px;
+ overflow: scroll;
+ overscroll-behavior-x: contain;
+}
+#overscrollYDiv {
+ width: 500px;
+ height: 500px;
+ overflow: scroll;
+ overscroll-behavior-y: none;
+}
+#targetDiv {
+ width: 400px;
+ height: 400px;
+ overflow: scroll;
+}
+.content {
+ width:800px;
+ height:800px;
+}
+</style>
+
+<body style="margin:0" onload=runTest()>
+<div id="overscrollXDiv">
+ <div class=content>
+ <div id="overscrollYDiv">
+ <div class=content>
+ <div id="targetDiv">
+ <div class="content">
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+</body>
+
+<script>
+var target_div = document.getElementById('targetDiv');
+var overscrolled_x_delta = 0;
+var overscrolled_y_delta = 0;
+function onOverscrollX(event) {
+ assert_false(event.cancelable);
+ assert_false(event.bubbles);
+ overscrolled_x_delta = event.deltaX;
+}
+function onOverscrollY(event) {
+ assert_false(event.cancelable);
+ assert_false(event.bubbles);
+ overscrolled_y_delta = event.deltaY;
+}
+// Test that both "onoverscroll" and addEventListener("overscroll"... work.
+document.getElementById('overscrollXDiv').onoverscroll = onOverscrollX;
+document.getElementById('overscrollYDiv').
+ addEventListener("overscroll", onOverscrollY);
+
+function runTest() {
+ promise_test (async (t) => {
+ // Make sure that no overscroll event is sent to document or target_div.
+ document.addEventListener("overscroll",
+ t.unreached_func("Document got unexpected overscroll event."));
+ target_div.addEventListener("overscroll",
+ t.unreached_func("target_div got unexpected overscroll event."));
+ await waitForCompositorCommit();
+ // Scroll up on target div and wait for the element with overscroll-y to get
+ // overscroll event.
+ await touchScrollInTarget(300, target_div, 'up');
+ await waitFor(() => { return overscrolled_y_delta < 0; },
+ 'Expected element did not receive overscroll event after scroll up on ' +
+ 'target.');
+ assert_equals(target_div.scrollTop, 0);
+
+ // Scroll left on target div and wait for the element with overscroll-x to
+ // get overscroll event.
+ await touchScrollInTarget(300, target_div, 'left');
+ await waitFor(() => { return overscrolled_x_delta < 0; },
+ 'Expected element did not receive overscroll event after scroll left ' +
+ 'on target.');
+ assert_equals(target_div.scrollLeft, 0);
+ }, 'Tests that the last element in the cut scroll chain gets overscroll ' +
+ 'event when no element scrolls by touch.');
+}
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-scrolled-element.html b/testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-scrolled-element.html
new file mode 100644
index 0000000000..cfc782a809
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-scrolled-element.html
@@ -0,0 +1,65 @@
+<!DOCTYPE HTML>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+#scrollableDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+}
+
+#innerDiv {
+ width: 400px;
+ height: 400px;
+}
+</style>
+
+<body style="margin:0" onload=runTest()>
+<div id="scrollableDiv">
+ <div id="innerDiv">
+ </div>
+</div>
+</body>
+
+<script>
+var scrolling_div = document.getElementById('scrollableDiv');
+var overscrolled_x_delta = 0;
+var overscrolled_y_delta = 0;
+function onOverscroll(event) {
+ assert_false(event.cancelable);
+ assert_false(event.bubbles);
+ overscrolled_x_delta = event.deltaX;
+ overscrolled_y_delta = event.deltaY;
+}
+scrolling_div.addEventListener("overscroll", onOverscroll);
+
+function runTest() {
+ promise_test (async (t) => {
+ // Make sure that no overscroll event is sent to document.
+ document.addEventListener("overscroll",
+ t.unreached_func("Document got unexpected overscroll event."));
+ await waitForCompositorCommit();
+
+ // Do a horizontal scroll and wait for overscroll event.
+ await touchScrollInTarget(300, scrolling_div , 'right');
+ await waitFor(() => { return overscrolled_x_delta > 0; },
+ 'Scroller did not receive overscroll event after horizontal scroll.');
+ assert_equals(scrolling_div.scrollWidth - scrolling_div.scrollLeft,
+ scrolling_div.clientWidth);
+
+ overscrolled_x_delta = 0;
+ overscrolled_y_delta = 0;
+
+ // Do a vertical scroll and wait for overscroll event.
+ await touchScrollInTarget(300, scrolling_div, 'down');
+ await waitFor(() => { return overscrolled_y_delta > 0; },
+ 'Scroller did not receive overscroll event after vertical scroll.');
+ assert_equals(scrolling_div.scrollHeight - scrolling_div.scrollTop,
+ scrolling_div.clientHeight);
+ }, 'Tests that the scrolled element gets overscroll event after fully scrolling by touch.');
+}
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-window.html b/testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-window.html
new file mode 100644
index 0000000000..ef5ae3daef
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/overscroll-event-fired-to-window.html
@@ -0,0 +1,52 @@
+<!DOCTYPE HTML>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+#targetDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+}
+
+#innerDiv {
+ width: 400px;
+ height: 400px;
+}
+</style>
+
+<body style="margin:0" onload=runTest()>
+<div id="targetDiv">
+ <div id="innerDiv">
+ </div>
+</div>
+</body>
+
+<script>
+var target_div = document.getElementById('targetDiv');
+var window_received_overscroll = false;
+
+function onOverscroll(event) {
+ assert_false(event.cancelable);
+ // overscroll events targetting document are bubbled to the window.
+ assert_true(event.bubbles);
+ window_received_overscroll = true;
+}
+window.addEventListener("overscroll", onOverscroll);
+
+function runTest() {
+ promise_test (async (t) => {
+ // Make sure that no overscroll event is sent to target_div.
+ target_div.addEventListener("overscroll",
+ t.unreached_func("target_div got unexpected overscroll event."));
+ // Scroll up on target div and wait for the window to get overscroll event.
+ await touchScrollInTarget(300, target_div, 'up');
+ await waitFor(() => { return window_received_overscroll; },
+ 'Window did not receive overscroll event after scroll up on target.');
+ }, 'Tests that the window gets overscroll event when no element scrolls' +
+ 'after touch scrolling.');
+}
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scroll_support.js b/testing/web-platform/tests/dom/events/scrolling/scroll_support.js
new file mode 100644
index 0000000000..e86ead5456
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scroll_support.js
@@ -0,0 +1,280 @@
+async function waitForEvent(eventName, test, target, timeoutMs = 500) {
+ return new Promise((resolve, reject) => {
+ const timeoutCallback = test.step_timeout(() => {
+ reject(`No ${eventName} event received for target ${target}`);
+ }, timeoutMs);
+ target.addEventListener(eventName, (evt) => {
+ clearTimeout(timeoutCallback);
+ resolve(evt);
+ }, { once: true });
+ });
+}
+
+async function waitForScrollendEvent(test, target, timeoutMs = 500) {
+ return waitForEvent("scrollend", test, target, timeoutMs);
+}
+
+async function waitForScrollendEventNoTimeout(target) {
+ return new Promise((resolve) => {
+ target.addEventListener("scrollend", resolve);
+ });
+}
+
+async function waitForPointercancelEvent(test, target, timeoutMs = 500) {
+ return waitForEvent("pointercancel", test, target, timeoutMs);
+}
+
+// Resets the scroll position to (0,0). If a scroll is required, then the
+// promise is not resolved until the scrollend event is received.
+async function waitForScrollReset(test, scroller, timeoutMs = 500) {
+ return new Promise(resolve => {
+ if (scroller.scrollTop == 0 &&
+ scroller.scrollLeft == 0) {
+ resolve();
+ } else {
+ const eventTarget =
+ scroller == document.scrollingElement ? document : scroller;
+ scroller.scrollTop = 0;
+ scroller.scrollLeft = 0;
+ waitForScrollendEvent(test, eventTarget, timeoutMs).then(resolve);
+ }
+ });
+}
+
+async function createScrollendPromiseForTarget(test,
+ target_div,
+ timeoutMs = 500) {
+ return waitForScrollendEvent(test, target_div, timeoutMs).then(evt => {
+ assert_false(evt.cancelable, 'Event is not cancelable');
+ assert_false(evt.bubbles, 'Event targeting element does not bubble');
+ });
+}
+
+function verifyNoScrollendOnDocument(test) {
+ const callback =
+ test.unreached_func("window got unexpected scrollend event.");
+ window.addEventListener('scrollend', callback);
+ test.add_cleanup(() => {
+ window.removeEventListener('scrollend', callback);
+ });
+}
+
+async function verifyScrollStopped(test, target_div) {
+ const unscaled_pause_time_in_ms = 100;
+ const x = target_div.scrollLeft;
+ const y = target_div.scrollTop;
+ return new Promise(resolve => {
+ test.step_timeout(() => {
+ assert_equals(target_div.scrollLeft, x);
+ assert_equals(target_div.scrollTop, y);
+ resolve();
+ }, unscaled_pause_time_in_ms);
+ });
+}
+
+async function resetTargetScrollState(test, target_div) {
+ if (target_div.scrollTop != 0 || target_div.scrollLeft != 0) {
+ target_div.scrollTop = 0;
+ target_div.scrollLeft = 0;
+ return waitForScrollendEvent(test, target_div);
+ }
+}
+
+const MAX_FRAME = 700;
+const MAX_UNCHANGED_FRAMES = 20;
+
+// Returns a promise that resolves when the given condition is met or rejects
+// after MAX_FRAME animation frames.
+// TODO(crbug.com/1400399): deprecate. We should not use frame based waits in
+// WPT as frame rates may vary greatly in different testing environments.
+function waitFor(condition, error_message = 'Reaches the maximum frames.') {
+ return new Promise((resolve, reject) => {
+ function tick(frames) {
+ // We requestAnimationFrame either for MAX_FRAM frames or until condition
+ // is met.
+ if (frames >= MAX_FRAME)
+ reject(error_message);
+ else if (condition())
+ resolve();
+ else
+ requestAnimationFrame(tick.bind(this, frames + 1));
+ }
+ tick(0);
+ });
+}
+
+// TODO(crbug.com/1400446): Test driver should defer sending events until the
+// browser is ready. Also the term compositor-commit is misleading as not all
+// user-agents use a compositor process.
+function waitForCompositorCommit() {
+ return new Promise((resolve) => {
+ // rAF twice.
+ window.requestAnimationFrame(() => {
+ window.requestAnimationFrame(resolve);
+ });
+ });
+}
+
+// Please don't remove this. This is necessary for chromium-based browsers. It
+// can be a no-op on user-agents that do not have a separate compositor thread.
+// TODO(crbug.com/1509054): This shouldn't be necessary if the test harness
+// deferred running the tests until after paint holding.
+async function waitForCompositorReady() {
+ const animation =
+ document.body.animate({ opacity: [ 1, 1 ] }, {duration: 1 });
+ return animation.finished;
+}
+
+function waitForNextFrame() {
+ const startTime = performance.now();
+ return new Promise(resolve => {
+ window.requestAnimationFrame((frameTime) => {
+ if (frameTime < startTime) {
+ window.requestAnimationFrame(resolve);
+ } else {
+ resolve();
+ }
+ });
+ });
+}
+
+// TODO(crbug.com/1400399): Deprecate as frame rates may vary greatly in
+// different test environments.
+function waitForAnimationEnd(getValue) {
+ var last_changed_frame = 0;
+ var last_position = getValue();
+ return new Promise((resolve, reject) => {
+ function tick(frames) {
+ // We requestAnimationFrame either for MAX_FRAME or until
+ // MAX_UNCHANGED_FRAMES with no change have been observed.
+ if (frames >= MAX_FRAME || frames - last_changed_frame > MAX_UNCHANGED_FRAMES) {
+ resolve();
+ } else {
+ current_value = getValue();
+ if (last_position != current_value) {
+ last_changed_frame = frames;
+ last_position = current_value;
+ }
+ requestAnimationFrame(tick.bind(this, frames + 1));
+ }
+ }
+ tick(0);
+ })
+}
+
+// Scrolls in target according to move_path with pauses in between
+// The move_path should contains coordinates that are within target boundaries.
+// Keep in mind that 0,0 is the center of the target element and is also
+// the pointerDown position.
+// pointerUp() is fired after sequence of moves.
+function touchScrollInTargetSequentiallyWithPause(target, move_path, pause_time_in_ms = 100) {
+ const test_driver_actions = new test_driver.Actions()
+ .addPointer("pointer1", "touch")
+ .pointerMove(0, 0, {origin: target})
+ .pointerDown();
+
+ const substeps = 5;
+ let x = 0;
+ let y = 0;
+ // Do each move in 5 steps
+ for(let move of move_path) {
+ let step_x = (move.x - x) / substeps;
+ let step_y = (move.y - y) / substeps;
+ for(let step = 0; step < substeps; step++) {
+ x += step_x;
+ y += step_y;
+ test_driver_actions.pointerMove(x, y, {origin: target});
+ }
+ test_driver_actions.pause(pause_time_in_ms); // To prevent inertial scroll
+ }
+
+ return test_driver_actions.pointerUp().send();
+}
+
+function touchScrollInTarget(pixels_to_scroll, target, direction, pause_time_in_ms = 100) {
+ var x_delta = 0;
+ var y_delta = 0;
+ const num_movs = 5;
+ if (direction == "down") {
+ y_delta = -1 * pixels_to_scroll / num_movs;
+ } else if (direction == "up") {
+ y_delta = pixels_to_scroll / num_movs;
+ } else if (direction == "right") {
+ x_delta = -1 * pixels_to_scroll / num_movs;
+ } else if (direction == "left") {
+ x_delta = pixels_to_scroll / num_movs;
+ } else {
+ throw("scroll direction '" + direction + "' is not expected, direction should be 'down', 'up', 'left' or 'right'");
+ }
+ return new test_driver.Actions()
+ .addPointer("pointer1", "touch")
+ .pointerMove(0, 0, {origin: target})
+ .pointerDown()
+ .pointerMove(x_delta, y_delta, {origin: target})
+ .pointerMove(2 * x_delta, 2 * y_delta, {origin: target})
+ .pointerMove(3 * x_delta, 3 * y_delta, {origin: target})
+ .pointerMove(4 * x_delta, 4 * y_delta, {origin: target})
+ .pointerMove(5 * x_delta, 5 * y_delta, {origin: target})
+ .pause(pause_time_in_ms)
+ .pointerUp()
+ .send();
+}
+
+// Trigger fling by doing pointerUp right after pointerMoves.
+function touchFlingInTarget(pixels_to_scroll, target, direction) {
+ touchScrollInTarget(pixels_to_scroll, target, direction, 0 /* pause_time */);
+}
+
+function mouseActionsInTarget(target, origin, delta, pause_time_in_ms = 100) {
+ return new test_driver.Actions()
+ .addPointer("pointer1", "mouse")
+ .pointerMove(origin.x, origin.y, { origin: target })
+ .pointerDown()
+ .pointerMove(origin.x + delta.x, origin.y + delta.y, { origin: target })
+ .pointerMove(origin.x + delta.x * 2, origin.y + delta.y * 2, { origin: target })
+ .pause(pause_time_in_ms)
+ .pointerUp()
+ .send();
+}
+
+// Returns a promise that resolves when the given condition holds for 10
+// animation frames or rejects if the condition changes to false within 10
+// animation frames.
+// TODO(crbug.com/1400399): Deprecate as frame rates may very greatly in
+// different test environments.
+function conditionHolds(condition, error_message = 'Condition is not true anymore.') {
+ const MAX_FRAME = 10;
+ return new Promise((resolve, reject) => {
+ function tick(frames) {
+ // We requestAnimationFrame either for 10 frames or until condition is
+ // violated.
+ if (frames >= MAX_FRAME)
+ resolve();
+ else if (!condition())
+ reject(error_message);
+ else
+ requestAnimationFrame(tick.bind(this, frames + 1));
+ }
+ tick(0);
+ });
+}
+
+function scrollElementDown(element, scroll_amount) {
+ let x = 0;
+ let y = 0;
+ let delta_x = 0;
+ let delta_y = scroll_amount;
+ let actions = new test_driver.Actions()
+ .scroll(x, y, delta_x, delta_y, {origin: element});
+ return actions.send();
+}
+
+function scrollElementLeft(element, scroll_amount) {
+ let x = 0;
+ let y = 0;
+ let delta_x = scroll_amount;
+ let delta_y = 0;
+ let actions = new test_driver.Actions()
+ .scroll(x, y, delta_x, delta_y, {origin: element});
+ return actions.send();
+}
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-after-sequence-of-scrolls.tentative.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-after-sequence-of-scrolls.tentative.html
new file mode 100644
index 0000000000..dab6dcc9bd
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-after-sequence-of-scrolls.tentative.html
@@ -0,0 +1,91 @@
+<!DOCTYPE HTML>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+#targetDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+}
+
+#innerDiv {
+ width: 4000px;
+ height: 4000px;
+}
+</style>
+
+<body style="margin:0" onload=runTest()>
+<div id="targetDiv">
+ <div id="innerDiv">
+ </div>
+</div>
+</body>
+
+<script>
+const target_div = document.getElementById('targetDiv');
+
+async function testWithMovePath(t, move_path) {
+ // Skip the test on a Mac as they do not support touch screens.
+ const isMac = navigator.platform.toUpperCase().indexOf('MAC')>=0;
+ if (isMac)
+ return;
+
+ await resetTargetScrollState(t, target_div);
+ await waitForCompositorReady();
+
+ verifyNoScrollendOnDocument(t);
+
+ let scrollend_count = 0;
+ const scrollend_listener = () => { scrollend_count += 1; };
+ target_div.addEventListener("scrollend", scrollend_listener);
+ t.add_cleanup(() => { target_div.removeEventListener('scrollend', scrollend_listener); });
+
+ const pointercancel_listener = () => {
+ assert_equals(scrollend_count, 0, 'scrollend should happen after pointercancel.');
+ };
+ target_div.addEventListener("pointercancel", pointercancel_listener);
+ t.add_cleanup(() => { target_div.removeEventListener('pointercancel', pointercancel_listener); });
+
+ // Because we have several pointer moves, we choose bigger timeout.
+ const timeoutMs = 3000;
+ const targetPointercancelPromise = waitForPointercancelEvent(t, target_div, timeoutMs);
+ const targetScrollendPromise = createScrollendPromiseForTarget(t, target_div, timeoutMs);
+
+ await touchScrollInTargetSequentiallyWithPause(target_div, move_path);
+
+ // Because we start scrolling after pointerdown, there is no pointerup, instead the target
+ // will receive a pointercancel, so we wait for pointercancel, and then continue.
+ await targetPointercancelPromise;
+ await targetScrollendPromise;
+ await verifyScrollStopped(t, target_div);
+ assert_equals(scrollend_count, 1, 'Only one scrollend event should be fired');
+}
+
+function runTest() {
+ promise_test (async (t) => {
+ // Scroll down & up & down on target div and wait for the target_div to get scrollend event.
+ const move_path = [
+ { x: 0, y: -80 }, // Scroll down
+ { x: 0, y: -40 }, // Scroll up
+ { x: 0, y: -80 }, // Scroll down
+ ];
+ await testWithMovePath(t, move_path);
+ }, "Move down, up and down again, receive scrollend event only once");
+
+ promise_test (async (t) => {
+ // Scroll right & left & right on target div and wait for the target_div to get scrollend event.
+ const move_path = [
+ { x: -80, y: 0 }, // Scroll right
+ { x: -40, y: 0 }, // Scroll left
+ { x: -80, y: 0 }, // Scroll right
+ ];
+ await testWithMovePath(t, move_path);
+ }, "Move right, left and right again, receive scrollend event only once");
+
+}
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-after-snap.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-after-snap.html
new file mode 100644
index 0000000000..03079ddc6c
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-after-snap.html
@@ -0,0 +1,87 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+div {
+ position: absolute;
+}
+#scroller {
+ width: 500px;
+ height: 500px;
+ overflow: scroll;
+ scroll-snap-type: both mandatory;
+ border: solid black 5px;
+}
+#space {
+ width: 2000px;
+ height: 2000px;
+}
+.target {
+ width: 200px;
+ height: 200px;
+ scroll-snap-align: start;
+ background-color: blue;
+}
+</style>
+
+<body style="margin:0" onload=runTests()>
+ <div id="scroller">
+ <div id="space"></div>
+ <div class="target" style="left: 0px; top: 0px;"></div>
+ <div class="target" style="left: 80px; top: 80px;"></div>
+ <div class="target" style="left: 200px; top: 200px;"></div>
+ </div>
+</body>
+
+<script>
+var scroller = document.getElementById("scroller");
+var space = document.getElementById("space");
+const MAX_FRAME_COUNT = 700;
+const MAX_UNCHANGED_FRAME = 20;
+
+function scrollTop() {
+ return scroller.scrollTop;
+}
+
+var scroll_arrived_after_scroll_end = false;
+var scroll_end_arrived = false;
+scroller.addEventListener("scroll", () => {
+ if (scroll_end_arrived)
+ scroll_arrived_after_scroll_end = true;
+});
+scroller.addEventListener("scrollend", () => {
+ scroll_end_arrived = true;
+});
+
+function runTests() {
+ promise_test (async () => {
+ await waitForCompositorCommit();
+ await touchScrollInTarget(100, scroller, 'down');
+ // Wait for the scroll snap animation to finish.
+ await waitForAnimationEnd(scrollTop);
+ await waitFor(() => { return scroll_end_arrived; });
+ // Verify that scroll snap animation has finished before firing scrollend event.
+ assert_false(scroll_arrived_after_scroll_end);
+ }, "Tests that scrollend is fired after scroll snap animation completion.");
+
+ promise_test (async () => {
+ // Reset scroll state.
+ scroller.scrollTo(0, 0);
+ await waitForCompositorCommit();
+ scroll_end_arrived = false;
+ scroll_arrived_after_scroll_end = false;
+
+ await touchFlingInTarget(50, scroller, 'down');
+ // Wait for the scroll snap animation to finish.
+ await waitForAnimationEnd(scrollTop);
+ await waitFor(() => { return scroll_end_arrived; });
+ // Verify that scroll snap animation has finished before firing scrollend event.
+ assert_false(scroll_arrived_after_scroll_end);
+ }, "Tests that scrollend is fired after fling snap animation completion.");
+}
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-for-mandatory-snap-point-after-load.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-for-mandatory-snap-point-after-load.html
new file mode 100644
index 0000000000..f379113420
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-for-mandatory-snap-point-after-load.html
@@ -0,0 +1,87 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width">
+ <script src="/resources/testharness.js"></script>
+ <script src="/resources/testharnessreport.js"></script>
+ <script src="/resources/testdriver.js"></script>
+ <script src="/resources/testdriver-actions.js"></script>
+ <script src="/resources/testdriver-vendor.js"></script>
+ <script src="scroll_support.js"></script>
+ <title>scrollend + mandatory scroll snap test</title>
+
+ <style>
+ #root {
+ width: 400px;
+ height: 400px;
+ overflow: auto;
+ scroll-snap-type: y mandatory;
+ border: 1px solid black;
+ --page-height: 400px;
+ }
+
+ #scroller {
+ height: 200px;
+ width: 200px;
+ overflow: auto;
+ border: 1px solid black;
+ --page-height: 200px;
+ }
+
+ .page {
+ height: var(--page-height);
+ scroll-snap-align: start;
+ }
+
+ .hidden {
+ display: none;
+ }
+ </style>
+</head>
+
+<body onload="runTests()">
+<div id="root" class="hidden">
+ <h1>scrollend + mandatory scroll snap test</h1>
+ <div id="scroller">
+ <div class="page">
+ <p>Page 1</p>
+ </div>
+ <div class="page">
+ <p>Page 2</p>
+ </div>
+ <div class="page">
+ <p>Page 3</p>
+ </div>
+ </div>
+
+ <div class="page">
+ <p>Page A</p>
+ </div>
+ <div class="page">
+ <p>Page B</p>
+ </div>
+ <div class="page">
+ <p>Page C</p>
+ </div>
+</div>
+
+<script>
+ function runTests() {
+ const root_div = document.getElementById("root");
+
+ promise_test(async (t) => {
+ const targetScrollendPromise = createScrollendPromiseForTarget(t, root_div);
+
+ await waitForNextFrame();
+ root_div.classList.remove("hidden");
+ await waitForNextFrame();
+
+ await targetScrollendPromise;
+ await verifyScrollStopped(t, root_div);
+ }, "scrollend event fired after load for mandatory snap point");
+ }
+</script>
+</body>
+
+</html> \ No newline at end of file
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-for-programmatic-scroll.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-for-programmatic-scroll.html
new file mode 100644
index 0000000000..c6569e0beb
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-for-programmatic-scroll.html
@@ -0,0 +1,135 @@
+<!DOCTYPE HTML>
+<meta name="timeout" content="long">
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+html {
+ height: 3000px;
+ width: 3000px;
+}
+#targetDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+}
+
+#innerDiv {
+ width: 400px;
+ height: 400px;
+}
+</style>
+
+<body style="margin:0" onload=runTest()>
+<div id="targetDiv">
+ <div id="innerDiv">
+ </div>
+</div>
+</body>
+<script>
+var element_scrollend_arrived = false;
+var document_scrollend_arrived = false;
+
+function onElementScrollEnd(event) {
+ assert_false(event.cancelable);
+ assert_false(event.bubbles);
+ element_scrollend_arrived = true;
+}
+
+function onDocumentScrollEnd(event) {
+ assert_false(event.cancelable);
+ // scrollend events are bubbled when the target node is document.
+ assert_true(event.bubbles);
+ document_scrollend_arrived = true;
+}
+
+function callScrollFunction([scrollTarget, scrollFunction, args]) {
+ scrollTarget[scrollFunction](args);
+}
+
+function runTest() {
+ let root_element = document.scrollingElement;
+ let target_div = document.getElementById("targetDiv");
+
+ promise_test (async (t) => {
+ await waitForCompositorCommit();
+ target_div.addEventListener("scrollend", onElementScrollEnd);
+ document.addEventListener("scrollend", onDocumentScrollEnd);
+
+ let test_cases = [
+ [target_div, 200, 200, [target_div, "scrollTo", { top: 200, left: 200, behavior: "auto" }]],
+ [target_div, 0, 0, [target_div, "scrollTo", { top: 0, left: 0, behavior: "smooth" }]],
+ [root_element, 200, 200, [root_element, "scrollTo", { top: 200, left: 200, behavior: "auto" }]],
+ [root_element, 0, 0, [root_element, "scrollTo", { top: 0, left: 0, behavior: "smooth" }]],
+ [target_div, 200, 200, [target_div, "scrollBy", { top: 200, left: 200, behavior: "auto" }]],
+ [target_div, 0, 0, [target_div, "scrollBy", { top: -200, left: -200, behavior: "smooth" }]],
+ [root_element, 200, 200, [root_element, "scrollBy", { top: 200, left: 200, behavior: "auto" }]],
+ [root_element, 0, 0, [root_element, "scrollBy", { top: -200, left: -200, behavior: "smooth" }]]
+ ];
+
+ for(i = 0; i < test_cases.length; i++) {
+ let t = test_cases[i];
+ let target = t[0];
+ let expected_x = t[1];
+ let expected_y = t[2];
+ let scroll_datas = t[3];
+
+ callScrollFunction(scroll_datas);
+ await waitFor(() => { return element_scrollend_arrived || document_scrollend_arrived; }, target.tagName + "." + scroll_datas[1] + " did not receive scrollend event.");
+ if (target == root_element)
+ assert_false(element_scrollend_arrived);
+ else
+ assert_false(document_scrollend_arrived);
+ assert_equals(target.scrollLeft, expected_x, target.tagName + "." + scroll_datas[1] + " scrollLeft");
+ assert_equals(target.scrollTop, expected_y, target.tagName + "." + scroll_datas[1] + " scrollTop");
+
+ element_scrollend_arrived = false;
+ document_scrollend_arrived = false;
+ }
+ }, "Tests scrollend event for calling scroll functions.");
+
+ promise_test(async (t) => {
+ await waitForCompositorCommit();
+
+ let test_cases = [
+ [target_div, "scrollTop"],
+ [target_div, "scrollLeft"],
+ [root_element, "scrollTop"],
+ [root_element, "scrollLeft"]
+ ];
+ for (i = 0; i < test_cases.length; i++) {
+ let t = test_cases[i];
+ let target = t[0];
+ let attribute = t[1];
+ let position = 200;
+
+ target.style.scrollBehavior = "smooth";
+ target[attribute] = position;
+ await waitFor(() => { return element_scrollend_arrived || document_scrollend_arrived; }, target.tagName + "." + attribute + " did not receive scrollend event.");
+ if (target == root_element)
+ assert_false(element_scrollend_arrived);
+ else
+ assert_false(document_scrollend_arrived);
+ assert_equals(target[attribute], position, target.tagName + "." + attribute + " ");
+ element_scrollend_arrived = false;
+ document_scrollend_arrived = false;
+
+ await waitForCompositorCommit();
+ target.style.scrollBehavior = "auto";
+ target[attribute] = 0;
+ await waitFor(() => { return element_scrollend_arrived || document_scrollend_arrived; }, target.tagName + "." + attribute + " did not receive scrollend event.");
+ if (target == root_element)
+ assert_false(element_scrollend_arrived);
+ else
+ assert_false(document_scrollend_arrived);
+ assert_equals(target[attribute], 0, target.tagName + "." + attribute + " ");
+ element_scrollend_arrived = false;
+ document_scrollend_arrived = false;
+ }
+ }, "Tests scrollend event for changing scroll attributes.");
+}
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-for-scrollIntoView.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-for-scrollIntoView.html
new file mode 100644
index 0000000000..8782b1dfee
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-for-scrollIntoView.html
@@ -0,0 +1,124 @@
+<!DOCTYPE HTML>
+<meta name="timeout" content="long">
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+html {
+ height: 3000px;
+ width: 3000px;
+}
+#targetDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+}
+
+#innerDiv {
+ width: 400px;
+ height: 400px;
+}
+</style>
+
+<body style="margin:0" onload=runTest()>
+<div id="targetDiv">
+ <div id="innerDiv">
+ </div>
+</div>
+</body>
+<script>
+var element_scrollend_arrived = false;
+var document_scrollend_arrived = false;
+
+function onElementScrollEnd(event) {
+ assert_false(event.cancelable);
+ assert_false(event.bubbles);
+ element_scrollend_arrived = true;
+}
+
+function onDocumentScrollEnd(event) {
+ assert_false(event.cancelable);
+ // scrollend events are bubbled when the target node is document.
+ assert_true(event.bubbles);
+ document_scrollend_arrived = true;
+}
+
+function callScrollFunction([scrollTarget, scrollFunction, args]) {
+ scrollTarget[scrollFunction](args);
+}
+
+function runTest() {
+ let root_element = document.scrollingElement;
+ let target_div = document.getElementById("targetDiv");
+ let inner_div = document.getElementById("innerDiv");
+
+ // Get expected position for root_element scrollIntoView.
+ root_element.scrollTo(10000, 10000);
+ let max_root_x = root_element.scrollLeft;
+ let max_root_y = root_element.scrollTop;
+ root_element.scrollTo(0, 0);
+
+ target_div.scrollTo(10000, 10000);
+ let max_element_x = target_div.scrollLeft;
+ let max_element_y = target_div.scrollTop;
+ target_div.scrollTo(0, 0);
+
+ promise_test (async (t) => {
+ await waitForCompositorCommit();
+ target_div.addEventListener("scrollend", onElementScrollEnd);
+ document.addEventListener("scrollend", onDocumentScrollEnd);
+
+ let test_cases = [
+ [target_div, max_element_x, max_element_y, [inner_div, "scrollIntoView", { inline: "end", block: "end", behavior: "auto" }]],
+ [target_div, 0, 0, [inner_div, "scrollIntoView", { inline: "start", block: "start", behavior: "smooth" }]],
+ [root_element, max_root_x, max_root_y, [root_element, "scrollIntoView", { inline: "end", block: "end", behavior: "smooth" }]],
+ [root_element, 0, 0, [root_element, "scrollIntoView", { inline: "start", block: "start", behavior: "smooth" }]]
+ ];
+
+ for(i = 0; i < test_cases.length; i++) {
+ let t = test_cases[i];
+ let target = t[0];
+ let expected_x = t[1];
+ let expected_y = t[2];
+ let scroll_datas = t[3];
+
+ callScrollFunction(scroll_datas);
+ await waitFor(() => { return element_scrollend_arrived || document_scrollend_arrived; }, target.tagName + ".scrollIntoView did not receive scrollend event.");
+ if (target == root_element)
+ assert_false(element_scrollend_arrived);
+ else
+ assert_false(document_scrollend_arrived);
+ assert_equals(target.scrollLeft, expected_x, target.tagName + ".scrollIntoView scrollLeft");
+ assert_equals(target.scrollTop, expected_y, target.tagName + ".scrollIntoView scrollTop");
+
+ element_scrollend_arrived = false;
+ document_scrollend_arrived = false;
+ }
+ }, "Tests scrollend event for scrollIntoView.");
+
+ promise_test(async (t) => {
+ document.body.removeChild(target_div);
+ let out_div = document.createElement("div");
+ out_div.style = "width: 100px; height:100px; overflow:scroll; scroll-behavior:smooth;";
+ out_div.appendChild(target_div);
+ document.body.appendChild(out_div);
+ await waitForCompositorCommit();
+
+ element_scrollend_arrived = false;
+ document_scrollend_arrived = false;
+ inner_div.scrollIntoView({ inline: "end", block: "end", behavior: "auto" });
+ await waitFor(() => { return element_scrollend_arrived || document_scrollend_arrived; }, "Nested scrollIntoView did not receive scrollend event.");
+ assert_equals(root_element.scrollLeft, 0, "Nested scrollIntoView root_element scrollLeft");
+ assert_equals(root_element.scrollTop, 0, "Nested scrollIntoView root_element scrollTop");
+ assert_equals(out_div.scrollLeft, 100, "Nested scrollIntoView out_div scrollLeft");
+ assert_equals(out_div.scrollTop, 100, "Nested scrollIntoView out_div scrollTop");
+ assert_equals(target_div.scrollLeft, max_element_x, "Nested scrollIntoView target_div scrollLeft");
+ assert_equals(target_div.scrollTop, max_element_y, "Nested scrollIntoView target_div scrollTop");
+ assert_false(document_scrollend_arrived);
+ }, "Tests scrollend event for nested scrollIntoView.");
+}
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-to-document.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-to-document.html
new file mode 100644
index 0000000000..797c2eb53d
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-to-document.html
@@ -0,0 +1,106 @@
+<!DOCTYPE HTML>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+ #hspacer {
+ height: 100px;
+ width: 100vw;
+ top: 0;
+ left: 200px;
+ /* on the right edge od targetDiv */
+ position: absolute;
+ }
+
+ #vspacer {
+ height: 100vh;
+ width: 100px;
+ position: absolute;
+ }
+
+ #targetDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+ }
+
+ #innerDiv {
+ width: 400px;
+ height: 400px;
+ }
+</style>
+
+<body style="margin:0" onload=runTest()>
+ <div id="targetDiv">
+ <div id="innerDiv"></div>
+ </div>
+ <div id="hspacer"></div>
+ <div id="vspacer"></div>
+</body>
+
+<script>
+ var target_div = document.getElementById('targetDiv');
+ async function resetScrollers(test) {
+ await waitForScrollReset(test, target_div);
+ await waitForScrollReset(test, document.scrollingElement);
+ }
+
+ function fail() {
+ assert_true(false);
+ }
+
+ function runTest() {
+ promise_test(async (t) => {
+ await resetScrollers(t);
+ await waitForCompositorCommit();
+
+ assert_equals(document.scrollingElement.scrollTop, 0,
+ "document should not be scrolled");
+
+ let scrollend_promise = waitForScrollendEvent(t, target_div);
+ let max_target_div_scroll_top = target_div.scrollHeight - target_div.clientHeight;
+ target_div.scrollTo({ top: target_div.scrollHeight, left: 0 });
+ await scrollend_promise;
+ assert_approx_equals(target_div.scrollTop, max_target_div_scroll_top, 1,
+ "target_div should be fully scrolled down");
+
+ scrollend_promise = waitForScrollendEvent(t, document, 2000);
+ target_div.addEventListener("scrollend", fail);
+ // Scroll up on target div and wait for the doc to get scrollend event.
+ await scrollElementDown(target_div, target_div.clientHeight + 25);
+ await scrollend_promise;
+
+ target_div.removeEventListener("scrollend", fail);
+ assert_greater_than(document.scrollingElement.scrollTop, target_div.clientHeight - 1,
+ "document is scrolled by the height of target_div");
+ }, "testing, vertical");
+
+ promise_test(async (t) => {
+ await resetScrollers(t);
+ await waitForCompositorCommit();
+
+ assert_equals(document.scrollingElement.scrollLeft, 0,
+ "document should not be scrolled");
+
+ let scrollend_promise = waitForScrollendEvent(t, target_div);
+ let max_target_div_scroll_left = target_div.scrollWidth - target_div.clientWidth;
+ target_div.scrollTo({ left: target_div.scrollWidth, top: 0 });
+ await scrollend_promise;
+ assert_approx_equals(target_div.scrollLeft, max_target_div_scroll_left, 1,
+ "target_div should be fully scrolled right");
+
+ scrollend_promise = waitForScrollendEvent(t, document, 2000);
+ target_div.addEventListener("scrollend", fail);
+ await scrollElementLeft(target_div, target_div.clientWidth + 25);
+ await scrollend_promise;
+
+ target_div.removeEventListener("scrollend", fail);
+ assert_greater_than(document.scrollingElement.scrollLeft, target_div.clientWidth - 1,
+ "document is scrolled by the height of target_div");
+ }, "testing, horizontal");
+ }
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-to-element-with-overscroll-behavior.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-to-element-with-overscroll-behavior.html
new file mode 100644
index 0000000000..edda88e7cb
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-to-element-with-overscroll-behavior.html
@@ -0,0 +1,173 @@
+<!DOCTYPE HTML>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+ #overscrollXDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+ overscroll-behavior-x: contain;
+ border: solid 1px black;
+ display: grid;
+ /* Places content and targetXDiv beside each other. */
+ grid-template-columns: 500px 100px;
+ }
+
+ #overscrollYDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+ overscroll-behavior-y: none;
+ border: solid 1px black;
+ }
+
+ #targetXDiv {
+ width: 100px;
+ height: 100px;
+ overflow: scroll;
+ border: solid 1px black;
+ }
+
+ #targetYDiv {
+ width: 100px;
+ height: 100px;
+ overflow: scroll;
+ border: solid 1px black;
+ }
+
+ .content {
+ width: 500px;
+ height: 500px;
+ }
+
+ #spacer {
+ height: 200vh;
+ width: 200vw;
+ border: solid 1px black;
+ }
+</style>
+
+<body style="margin:0" onload=runTest()>
+ <div id="overscrollXDiv">
+ <div class="content"></div>
+ <div id="targetXDiv">
+ <div class="content">
+ </div>
+ </div>
+ </div>
+ <div id="overscrollYDiv">
+ <div class="content"></div>
+ <!-- Place targetYDiv below content so that is in view when
+ overscrollYDiv is scrolled all the way down -->
+ <div id="targetYDiv">
+ <div class="content">
+ </div>
+ </div>
+ </div>
+ <div id="spacer"></div>
+</body>
+
+<script>
+ var horizontal_scrollend_arrived = false;
+ var vertical_scrollend_arrived = false;
+ let scrollers = [document.scrollingElement, targetXDiv, targetYDiv,
+ overscrollXDiv, overscrollYDiv];
+
+ async function resetScrollers(test) {
+ for (const scroller of scrollers) {
+ await resetTargetScrollState(test, scroller);
+ }
+ }
+ function onHorizontalScrollEnd(event) {
+ assert_false(event.cancelable);
+ assert_false(event.bubbles);
+ horizontal_scrollend_arrived = true;
+ }
+ function onVerticalScrollEnd(event) {
+ assert_false(event.cancelable);
+ assert_false(event.bubbles);
+ vertical_scrollend_arrived = true;
+ }
+ // Test that both "onscrollend" and addEventListener("scrollend"... work.
+ document.getElementById('overscrollXDiv').onscrollend = onHorizontalScrollEnd;
+ document.getElementById('overscrollYDiv').
+ addEventListener("scrollend", onVerticalScrollEnd);
+
+ function runTest() {
+ promise_test(async (t) => {
+ await resetScrollers(t);
+ await waitForCompositorCommit();
+
+ // Make sure that no scrollend event is sent to document.
+ document.addEventListener("scrollend",
+ t.unreached_func("Document got unexpected scrollend event."));
+ let scrollend_promise;
+
+ scrollend_promise = waitForScrollendEvent(t, targetXDiv, 2000);
+ targetXDiv.scrollLeft = targetXDiv.scrollWidth;
+ await scrollend_promise;
+
+ scrollend_promise = waitForScrollendEvent(t, overscrollXDiv, 2000);
+ overscrollXDiv.scrollLeft = overscrollXDiv.scrollWidth;
+ await scrollend_promise;
+ horizontal_scrollend_arrived = false;
+
+ assert_equals(targetXDiv.scrollLeft,
+ targetXDiv.scrollWidth - targetXDiv.clientWidth);
+ assert_equals(overscrollXDiv.scrollLeft,
+ overscrollXDiv.scrollWidth - overscrollXDiv.clientWidth);
+ // Attempt to scroll targetXDiv further to the right.
+ // targetXDiv and overscrollXDiv are already fully scrolled right but the
+ // scroll should not propagate to the document because of
+ // overscroll-behavior-x: contain on overscrollXDiv.
+ let touchEndPromise = new Promise((resolve) => {
+ targetXDiv.addEventListener("touchend", resolve);
+ });
+ await touchScrollInTarget(100, targetXDiv, 'right');
+ // The scrollend event should never be fired before the gesture has
+ // completed.
+ await touchEndPromise;
+
+ scrollend_promise = waitForScrollendEvent(t, targetYDiv, 2000);
+ targetYDiv.scrollTop = targetXDiv.scrollHeight;
+ await scrollend_promise;
+
+ scrollend_promise = waitForScrollendEvent(t, overscrollYDiv, 2000);
+ overscrollYDiv.scrollTop = overscrollYDiv.scrollHeight;
+ await scrollend_promise;
+ vertical_scrollend_arrived = false;
+
+ assert_equals(targetYDiv.scrollTop,
+ targetYDiv.scrollHeight - targetYDiv.clientHeight);
+ assert_equals(overscrollYDiv.scrollTop,
+ overscrollYDiv.scrollHeight - overscrollYDiv.clientHeight);
+ // Attempt to scroll targetYDiv further down.
+ // targetYDiv and overscrollYDiv are already fully scrolled down but the
+ // scroll should not propagate to the document because of
+ // overscroll-behavior-y: none on overscrollYDiv.
+ touchEndPromise = new Promise((resolve) => {
+ targetYDiv.addEventListener("touchend", resolve);
+ });
+ await touchScrollInTarget(50, targetYDiv, 'down');
+ // The scrollend event should never be fired before the gesture has
+ // completed.
+ await touchEndPromise;
+
+ // Ensure we wait at least a tick after the touch end.
+ await waitForCompositorCommit();
+
+ // We should not trigger a scrollend event for a scroll that did not
+ // change the scroll position.
+ assert_equals(horizontal_scrollend_arrived, false,
+ "overscrollXDiv should not receive scrollend");
+ assert_equals(vertical_scrollend_arrived, false,
+ "overscrollYDiv should not receive scrollend");
+ }, "Tests that the scroll is not propagated beyond div with non-auto " +
+ "overscroll-behavior.");
+ }
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-to-window.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-to-window.html
new file mode 100644
index 0000000000..d2fd6f4d31
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fired-to-window.html
@@ -0,0 +1,69 @@
+<!DOCTYPE HTML>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+ #spacer {
+ height: 100vh;
+ width: 100px;
+ }
+ #targetDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+ }
+ #innerDiv {
+ width: 400px;
+ height: 400px;
+ }
+</style>
+
+<body style="margin:0" onload=runTest()>
+ <div id="targetDiv">
+ <div id="innerDiv"></div>
+ </div>
+ <div id="spacer"></div>
+</body>
+
+<script>
+ var target_div = document.getElementById('targetDiv');
+ async function resetScrollers(test) {
+ await waitForScrollReset(test, target_div);
+ await waitForScrollReset(test, document.scrollingElement);
+ }
+
+ function fail() {
+ assert_true(false);
+ }
+
+ function runTest() {
+ promise_test(async (t) => {
+ await resetScrollers(t);
+ await waitForCompositorCommit();
+
+ assert_equals(document.scrollingElement.scrollTop, 0,
+ "document should not be scrolled");
+
+ let scrollend_promise = waitForScrollendEventNoTimeout(target_div);
+ let max_target_div_scroll_top = target_div.scrollHeight - target_div.clientHeight;
+ target_div.scrollTo({ top: target_div.scrollHeight, left: 0 });
+ await scrollend_promise;
+ assert_approx_equals(target_div.scrollTop, max_target_div_scroll_top, 1,
+ "target_div should be fully scrolled down");
+
+ scrollend_promise = waitForScrollendEventNoTimeout(window);
+ target_div.addEventListener("scrollend", fail);
+ // Scroll up on target div and wait for the doc to get scrollend event.
+ await scrollElementDown(target_div, target_div.clientHeight + 25);
+ await scrollend_promise;
+
+ target_div.removeEventListener("scrollend", fail);
+ assert_greater_than(document.scrollingElement.scrollTop, target_div.clientHeight - 1,
+ "document is scrolled by the height of target_div");
+ }, "testing, vertical");
+ }
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fires-on-visual-viewport.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fires-on-visual-viewport.html
new file mode 100644
index 0000000000..5e3af7966e
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fires-on-visual-viewport.html
@@ -0,0 +1,48 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="/visual-viewport/viewport_support.js"></script>
+<script src="/dom/events/scrolling/scroll_support.js"></script>
+</head>
+<body>
+<style>
+ .large {
+ height: 200vh;
+ width: 200vw;
+ border: solid 1px black;
+ }
+</style>
+<div class="large"></div>
+<script>
+ window.onload = () => {
+ promise_test(async () => {
+ await waitForCompositorCommit();
+
+ await pinchZoomIn();
+ assert_greater_than(visualViewport.scale, 1, "page should be zoomed in.");
+
+ const preScrollVisualViewportOffsetTop = visualViewport.offsetTop;
+ const preScrollWindowScrollOffset = window.scrollY;
+ const scrollend_promise = new Promise((resolve) => {
+ visualViewport.addEventListener("scrollend", resolve);
+ });
+
+ const scrollAmount = 50;
+ await touchScrollInTarget(scrollAmount, document.documentElement, "up");
+ await scrollend_promise;
+
+ assert_less_than(visualViewport.offsetTop, preScrollVisualViewportOffsetTop,
+ `visualViewport should be scrolled.`);
+ assert_equals(window.scrollY, preScrollWindowScrollOffset,
+ "the window should not scroll.");
+ }, "scrollend fires when visual viewport is panned.");
+ }
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fires-to-iframe-inner-frame.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fires-to-iframe-inner-frame.html
new file mode 100644
index 0000000000..115e583c06
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fires-to-iframe-inner-frame.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html>
+ <style>
+ body {
+ margin: 0px;
+ }
+ #spacer {
+ height: 120vh;
+ width: 120vw;
+ border: solid 1px black;
+ }
+ #scroller {
+ height: 200px;
+ width: 200px;
+ overflow: scroll;
+ border: solid 1px red;
+ }
+ #inner-spacer {
+ height: 400px;
+ width: 400px;
+ border: solid 1px black;
+ }
+ </style>
+ <body>
+ <div id="scroller" tabindex=0>
+ <div id="inner-spacer"></div>
+ </div>
+ <div id="spacer"></div>
+ </body>
+</html>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fires-to-iframe-window.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fires-to-iframe-window.html
new file mode 100644
index 0000000000..4e53158087
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-fires-to-iframe-window.html
@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+ <script src="/resources/testharness.js"></script>
+ <script src="/resources/testharnessreport.js"></script>
+ <script src="/resources/testdriver.js"></script>
+ <script src="/resources/testdriver-actions.js"></script>
+ <script src="/resources/testdriver-vendor.js"></script>
+ <script src="scroll_support.js"></script>
+ <script src="scrollend-user-scroll-common.js"></script>
+ <style>
+ #targetDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+ }
+
+ #innerDiv {
+ width: 400px;
+ height: 400px;
+ }
+
+ iframe {
+ height: 300px;
+ width: 300px;
+ }
+ </style>
+</head>
+
+<body style="margin:0" onload=runTest()>
+ <iframe id="frame" src="scrollend-event-fires-to-iframe-inner-frame.html"></iframe>
+</body>
+
+<script>
+ function runTest() {
+ let target_div = frame.contentDocument.getElementById("scroller");
+ //Tests for scrollend events on an element within an iframe.
+ promise_test(async (t) => {
+ await test_scrollend_on_touch_drag(t, target_div);
+ }, 'Tests that the target_div within iframe gets scrollend event when touch ' +
+ 'dragging.');
+
+ promise_test(async (t) => {
+ await test_scrollend_on_scrollbar_gutter_click(t, target_div);
+ }, 'Tests that the target_div within iframe gets scrollend event when ' +
+ 'clicking scrollbar.');
+
+ // Same issue as previous test.
+ promise_test(async (t) => {
+ await test_scrollend_on_scrollbar_thumb_drag(t, target_div);
+ }, 'Tests that the target_div within iframe gets scrollend event when ' +
+ 'dragging the scrollbar thumb.');
+
+ promise_test(async (t) => {
+ await test_scrollend_on_mousewheel_scroll(t, target_div);
+ }, 'Tests that the target_div within iframe gets scrollend event when mouse ' +
+ 'wheel scrolling.');
+
+ promise_test(async (t) => {
+ await test_scrollend_on_keyboard_scroll(t, target_div);
+ }, 'Tests that the target_div within iframe gets scrollend event when ' +
+ 'sending DOWN key to the target.');
+
+ // Test for scrollend events on the iframe's window.
+ // TODO: add similar tests with different input modes.
+ promise_test(async (t) => {
+ let scroller = frame.contentDocument.scrollingElement;
+
+ await waitForScrollReset(t, scroller);
+ await waitForCompositorReady();
+
+ const targetScrollendPromise = waitForScrollendEventNoTimeout(frame.contentDocument);
+ verifyNoScrollendOnDocument(t);
+
+ let x = target_div.getBoundingClientRect().width + 20;
+ let y = 20;
+ let dy = 30;
+ await new test_driver.Actions().scroll(x, y, 0, dy).send();
+ await targetScrollendPromise;
+ assert_equals(scroller.scrollTop, dy, 'window scrolled by mousewheel');
+ }, 'scrollend fires to iframe window on mousewheelscroll');
+ }
+
+</script>
+
+</html> \ No newline at end of file
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-for-user-scroll.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-for-user-scroll.html
new file mode 100644
index 0000000000..a06843a35e
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-for-user-scroll.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<script src="scrollend-user-scroll-common.js"></script>
+<style>
+ #targetDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+ }
+
+ #innerDiv {
+ width: 400px;
+ height: 400px;
+ }
+</style>
+</head>
+<body style="margin:0" onload=runTest()>
+ <div id="targetDiv">
+ <div id="innerDiv">
+ </div>
+ </div>
+</body>
+
+<script>
+var target_div = document.getElementById('targetDiv');
+
+function runTest() {
+ promise_test(async (t) => {
+ await test_scrollend_on_touch_drag(t, target_div);
+ }, 'Tests that the target_div gets scrollend event when touch dragging.');
+
+ promise_test(async (t) => {
+ await test_scrollend_on_scrollbar_gutter_click(t, target_div);
+ }, 'Tests that the target_div gets scrollend event when clicking ' +
+ 'scrollbar.');
+
+ // Same issue as previous test.
+ promise_test(async (t) => {
+ await test_scrollend_on_scrollbar_thumb_drag(t, target_div);
+ }, 'Tests that the target_div gets scrollend event when dragging the ' +
+ 'scrollbar thumb.');
+
+ promise_test(async (t) => {
+ await test_scrollend_on_mousewheel_scroll(t, target_div);
+ }, 'Tests that the target_div gets scrollend event when mouse wheel ' +
+ 'scrolling.');
+
+ promise_test(async (t) => {
+ await test_scrollend_on_keyboard_scroll(t, target_div);
+ }, 'Tests that the target_div gets scrollend event when sending DOWN key ' +
+ 'to the target.');
+}
+
+
+</script>
+</html>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-handler-content-attributes.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-handler-content-attributes.html
new file mode 100644
index 0000000000..47f563c39b
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-handler-content-attributes.html
@@ -0,0 +1,108 @@
+<!DOCTYPE HTML>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+html, body {
+ margin: 0
+}
+
+body {
+ height: 3000px;
+ width: 3000px;
+}
+
+#targetDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+}
+
+#innerDiv {
+ width: 400px;
+ height: 400px;
+}
+</style>
+
+<body onload=runTest() onscrollend="failOnScrollEnd(event)">
+<div id="targetDiv" onscrollend="onElementScrollEnd(event)">
+ <div id="innerDiv">
+ </div>
+</div>
+</body>
+<script>
+let element_scrollend_arrived = false;
+
+function onElementScrollEnd(event) {
+ assert_false(event.cancelable);
+ assert_false(event.bubbles);
+ element_scrollend_arrived = true;
+}
+
+function failOnScrollEnd(event) {
+ assert_true(false, "Scrollend should not be called on: " + event.target);
+}
+
+function runTest() {
+ let target_div = document.getElementById("targetDiv");
+
+ promise_test (async (t) => {
+ await waitForCompositorCommit();
+
+ target_div.scrollTo({top: 200, left: 200});
+ await waitFor(() => { return element_scrollend_arrived; },
+ target_div.tagName + " did not receive scrollend event.");
+ assert_equals(target_div.scrollLeft, 200, target_div.tagName + " scrollLeft");
+ assert_equals(target_div.scrollTop, 200, target_div.tagName + " scrollTop");
+ }, "Tests scrollend event is handled by event handler content attribute.");
+
+ promise_test (async (t) => {
+ await waitForCompositorCommit();
+
+ document.scrollingElement.scrollTo({top: 200, left: 200});
+ // The document body onscrollend event handler content attribute will fail
+ // here, if it is fired.
+ await waitForCompositorCommit();
+ assert_equals(document.scrollingElement.scrollLeft, 200,
+ "Document scrolled on horizontal axis");
+ assert_equals(document.scrollingElement.scrollTop, 200,
+ "Document scrolled on vertical axis");
+ }, "Tests scrollend event is not fired to document body event handler content attribute.");
+
+ promise_test (async (t) => {
+ await waitForCompositorCommit();
+
+ // Reset the scroll position.
+ document.scrollingElement.scrollTo({top: 0, left: 0});
+
+ let scrollend_event = new Promise(resolve => document.onscrollend = resolve);
+ document.scrollingElement.scrollTo({top: 200, left: 200});
+ await scrollend_event;
+
+ assert_equals(document.scrollingElement.scrollTop, 200,
+ "Document scrolled on horizontal axis");
+ assert_equals(document.scrollingElement.scrollLeft, 200,
+ "Document scrolled on vertical axis");
+ }, "Tests scrollend event is fired to document event handler property");
+
+ promise_test (async (t) => {
+ await waitForCompositorCommit();
+
+ // Reset the scroll position.
+ target_div.scrollTo({top: 0, left: 0});
+
+ let scrollend_event = new Promise(resolve => target_div.onscrollend = resolve);
+ target_div.scrollTo({top: 200, left: 200});
+ await scrollend_event;
+
+ assert_equals(target_div.scrollLeft, 200,
+ target_div.tagName + " scrolled on horizontal axis");
+ assert_equals(target_div.scrollLeft, 200,
+ target_div.tagName + " scrolled on vertical axis");
+ }, "Tests scrollend event is fired to element event handler property");
+}
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-not-fired-after-removing-scroller.tentative.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-not-fired-after-removing-scroller.tentative.html
new file mode 100644
index 0000000000..95447fbd12
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-not-fired-after-removing-scroller.tentative.html
@@ -0,0 +1,84 @@
+<!DOCTYPE HTML>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+#rootDiv {
+ width: 500px;
+ height: 500px;
+}
+
+#targetDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+}
+
+#innerDiv {
+ width: 500px;
+ height: 4000px;
+}
+</style>
+
+<body style="margin:0" onload=runTest()>
+</body>
+
+<script>
+let scrollend_arrived = false;
+
+async function setupHtmlAndScrollAndRemoveElement(element_to_remove_id) {
+ document.body.innerHTML=`
+ <div id="rootDiv">
+ <div id="targetDiv">
+ <div id="innerDiv">
+ </div>
+ </div>
+ </div>
+ `;
+ await waitForCompositorCommit();
+
+ const target_div = document.getElementById('targetDiv');
+ const element_to_remove = document.getElementById(element_to_remove_id);
+ let reached_half_scroll = false;
+ scrollend_arrived = false;
+
+ target_div.addEventListener("scrollend", () => {
+ scrollend_arrived = true;
+ });
+
+ target_div.onscroll = () => {
+ // Remove the element after reached half of the scroll offset,
+ if(target_div.scrollTop >= 1000) {
+ reached_half_scroll = true;
+ element_to_remove.remove();
+ }
+ };
+
+ target_div.scrollTo({top:2000, left:0, behavior:"smooth"});
+ await waitFor(() => {return reached_half_scroll; },
+ "target_div never reached scroll offset of 1000");
+ await waitForCompositorCommit();
+}
+
+function runTest() {
+ promise_test (async (t) => {
+ await setupHtmlAndScrollAndRemoveElement("rootDiv");
+ await conditionHolds(() => { return !scrollend_arrived; });
+ }, "No scrollend is received after removing parent div");
+
+ promise_test (async (t) => {
+ await setupHtmlAndScrollAndRemoveElement("targetDiv");
+ await conditionHolds(() => { return !scrollend_arrived; });
+ }, "No scrollend is received after removing scrolling element");
+
+ promise_test (async (t) => {
+ await setupHtmlAndScrollAndRemoveElement("innerDiv");
+ await waitFor(() => { return scrollend_arrived; },
+ 'target_div did not receive scrollend event after vertical scroll.');
+ }, "scrollend is received after removing descendant div");
+}
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-event-not-fired-on-no-scroll.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-not-fired-on-no-scroll.html
new file mode 100644
index 0000000000..870e15546f
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-event-not-fired-on-no-scroll.html
@@ -0,0 +1,114 @@
+<!DOCTYPE HTML>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="scroll_support.js"></script>
+<style>
+ #spacer {
+ height: 100vh;
+ width: 100px;
+ position: relative;
+ }
+
+ #targetDiv {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+ }
+
+ #innerDiv {
+ width: 400px;
+ height: 400px;
+ }
+</style>
+
+<body style="margin:0" onload=runTest()>
+ <div id="targetDiv">
+ <!-- This test uses button elements as a consistent mechanism for
+ ensuring that focus is on the correct scrolling element when
+ scrolling via keys -->
+ <button id="targetButton">target</button>
+ <div id="innerDiv"></div>
+ </div>
+ <button id="docButton">doc</button>
+ <div id="spacer"></div>
+</body>
+
+<script>
+ var target_div = document.getElementById('targetDiv');
+
+ async function resetScrollers(test) {
+ await waitForScrollReset(test, target_div);
+ await waitForScrollReset(test, document.scrollingElement);
+ }
+
+ function getBoundingClientRect(element) {
+ if (element == document) {
+ return document.documentElement.getBoundingClientRect();
+ }
+ return element.getBoundingClientRect();
+ }
+
+ async function upwardScroll(scrolling_element, button_element, scroll_type) {
+ if (scroll_type == "wheel") {
+ let x = 0;
+ let y = 0;
+ let delta_x = 0;
+ let delta_y = -50;
+ let actions = new test_driver.Actions()
+ .scroll(x, y, delta_x, delta_y, {origin: scrolling_element});
+ await actions.send();
+ } else if (scroll_type == "keys") {
+ const num_keydowns = 5;
+ const arrowUp = '\uE013';
+ for (let i = 0; i < num_keydowns; i++) {
+ await test_driver.send_keys(button_element, arrowUp);
+ }
+ }
+ }
+
+ async function testScrollendNotFiredOnNoScroll(test, scrolling_element,
+ listening_element,
+ button_element, scroll_type) {
+ await resetScrollers(test);
+ await waitForCompositorCommit();
+
+ assert_greater_than(scrolling_element.scrollHeight,
+ scrolling_element.clientHeight);
+ assert_equals(scrolling_element.scrollTop, 0);
+
+ let scrollend_promise = waitForScrollendEvent(test, listening_element).then(
+ (/*resolve*/) => {
+ assert_true(false, "no scroll, so no scrollend expected");
+ },
+ (/*reject*/) => { /* Did not see scrollend, which is okay. */ }
+ );
+ await upwardScroll(scrolling_element, button_element, scroll_type);
+ await scrollend_promise;
+ }
+
+ function runTest() {
+ promise_test(async (t) => {
+ await testScrollendNotFiredOnNoScroll(t, target_div, target_div,
+ targetButton, "wheel");
+ }, "No scroll via wheel on div shouldn't fire scrollend.");
+
+ promise_test(async (t) => {
+ await testScrollendNotFiredOnNoScroll(t, target_div, target_div,
+ targetButton, "keys");
+ }, "No scroll via keys on div shouldn't fire scrollend.");
+
+ promise_test(async (t) => {
+ await testScrollendNotFiredOnNoScroll(t, document.scrollingElement,
+ document, docButton, "wheel");
+ }, "No scroll via wheel on document shouldn't fire scrollend.");
+
+ promise_test(async (t) => {
+ await testScrollendNotFiredOnNoScroll(t, document.scrollingElement,
+ document, docButton, "keys");
+ }, "No scroll via keys on document shouldn't fire scrollend.")
+ }
+</script>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-fires-to-text-input.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-fires-to-text-input.html
new file mode 100644
index 0000000000..edc75d9121
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-fires-to-text-input.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <script src="/resources/testharness.js"></script>
+ <script src="/resources/testharnessreport.js"></script>
+ </head>
+ <body>
+ <style>
+ #inputscroller {
+ width: 100px;
+ height: 50px;
+ }
+ </style>
+ <input type="text" id="inputscroller"
+ value="qwertyuiopasddfghjklzxcvbnmqwertyuiopasddfghjklzxcvbnmqwer">
+ <script>
+ promise_test(async() => {
+ const inputscroller = document.getElementById("inputscroller");
+ assert_equals(inputscroller.scrollLeft, 0,
+ "text input field is not initially scrolled.");
+
+ const scrollend_promise = new Promise((resolve) => {
+ inputscroller.addEventListener("scrollend", resolve);
+ });
+ inputscroller.scrollLeft = 10;
+ await scrollend_promise;
+ assert_equals(inputscroller.scrollLeft, 10,
+ "text input field is scrolled by the correct amount");
+ }, "scrolled input field should receive scrollend.");
+ </script>
+ </body>
+</html>
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-user-scroll-common.js b/testing/web-platform/tests/dom/events/scrolling/scrollend-user-scroll-common.js
new file mode 100644
index 0000000000..5c278784d8
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-user-scroll-common.js
@@ -0,0 +1,145 @@
+
+async function test_scrollend_on_touch_drag(t, target_div) {
+ // Skip the test on a Mac as they do not support touch screens.
+ const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
+ if (isMac)
+ return;
+
+ await resetTargetScrollState(t, target_div);
+ await waitForCompositorReady();
+
+ const targetScrollendPromise = waitForScrollendEventNoTimeout(target_div);
+ verifyNoScrollendOnDocument(t);
+
+ let scrollend_count = 0;
+ const scrollend_listener = () => {
+ scrollend_count += 1;
+ };
+ target_div.addEventListener("scrollend", scrollend_listener);
+ t.add_cleanup(() => {
+ target_div.removeEventListener('scrollend', scrollend_listener);
+ });
+
+ // Perform a touch drag on target div and wait for target_div to get
+ // a scrollend event.
+ await new test_driver.Actions()
+ .addPointer('TestPointer', 'touch')
+ .pointerMove(0, 0, { origin: target_div }) // 0, 0 is center of element.
+ .pointerDown()
+ .addTick()
+ .pointerMove(0, -40, { origin: target_div }) // Drag up to move down.
+ .addTick()
+ .pause(200) // Prevent inertial scroll.
+ .pointerMove(0, -60, { origin: target_div })
+ .addTick()
+ .pause(200) // Prevent inertial scroll.
+ .pointerUp()
+ .send();
+
+ await targetScrollendPromise;
+
+ assert_true(target_div.scrollTop > 0);
+ await verifyScrollStopped(t, target_div);
+ assert_equals(scrollend_count, 1);
+}
+
+async function test_scrollend_on_scrollbar_gutter_click(t, target_div) {
+ // Skip test on platforms that do not have a visible scrollbar (e.g.
+ // overlay scrollbar).
+ const scrollbar_width = target_div.offsetWidth - target_div.clientWidth;
+ if (scrollbar_width == 0)
+ return;
+
+ await resetTargetScrollState(t, target_div);
+ await waitForCompositorReady();
+
+ const targetScrollendPromise = waitForScrollendEventNoTimeout(target_div);
+ verifyNoScrollendOnDocument(t);
+
+ const bounds = target_div.getBoundingClientRect();
+ // Some versions of webdriver have been known to frown at non-int arguments
+ // to pointerMove.
+ const x = bounds.right - Math.round(scrollbar_width / 2);
+ const y = bounds.bottom - 20;
+ await new test_driver.Actions()
+ .addPointer('TestPointer', 'mouse')
+ .pointerMove(x, y, { origin: 'viewport' })
+ .pointerDown()
+ .addTick()
+ .pointerUp()
+ .send();
+
+ await targetScrollendPromise;
+ assert_true(target_div.scrollTop > 0);
+ await verifyScrollStopped(t, target_div);
+}
+
+// Same issue as previous test.
+async function test_scrollend_on_scrollbar_thumb_drag(t, target_div) {
+ // Skip test on platforms that do not have a visible scrollbar (e.g.
+ // overlay scrollbar).
+ const scrollbar_width = target_div.offsetWidth - target_div.clientWidth;
+ if (scrollbar_width == 0)
+ return;
+
+ await resetTargetScrollState(t, target_div);
+ await waitForCompositorReady();
+
+ const targetScrollendPromise = waitForScrollendEventNoTimeout(target_div);
+ verifyNoScrollendOnDocument(t);
+
+ const bounds = target_div.getBoundingClientRect();
+ // Some versions of webdriver have been known to frown at non-int arguments
+ // to pointerMove.
+ const x = bounds.right - Math.round(scrollbar_width / 2);
+ const y = bounds.top + 30;
+ const dy = 30;
+ await new test_driver.Actions()
+ .addPointer('TestPointer', 'mouse')
+ .pointerMove(x, y, { origin: 'viewport' })
+ .pointerDown()
+ .pointerMove(x, y + dy, { origin: 'viewport' })
+ .addTick()
+ .pointerUp()
+ .send();
+
+ await targetScrollendPromise;
+ assert_true(target_div.scrollTop > 0);
+ await verifyScrollStopped(t, target_div);
+}
+
+async function test_scrollend_on_mousewheel_scroll(t, target_div) {
+ await resetTargetScrollState(t, target_div);
+ await waitForCompositorReady();
+
+ const targetScrollendPromise = waitForScrollendEventNoTimeout(target_div);
+ verifyNoScrollendOnDocument(t);
+
+ const x = 0;
+ const y = 0;
+ const dx = 0;
+ const dy = 40;
+ const duration_ms = 10;
+ await new test_driver.Actions()
+ .scroll(x, y, dx, dy, { origin: target_div }, duration_ms)
+ .send();
+
+ await targetScrollendPromise;
+ assert_true(target_div.scrollTop > 0);
+ await verifyScrollStopped(t, target_div);
+}
+
+async function test_scrollend_on_keyboard_scroll(t, target_div) {
+ await resetTargetScrollState(t, target_div);
+ await waitForCompositorReady();
+
+ verifyNoScrollendOnDocument(t);
+ const targetScrollendPromise = waitForScrollendEventNoTimeout(target_div);
+
+ target_div.focus();
+ window.test_driver.send_keys(target_div, '\ue015');
+
+ await targetScrollendPromise;
+ assert_true(target_div.scrollTop > 0);
+ await verifyScrollStopped(t, target_div);
+}
diff --git a/testing/web-platform/tests/dom/events/scrolling/scrollend-with-snap-on-fractional-offset.html b/testing/web-platform/tests/dom/events/scrolling/scrollend-with-snap-on-fractional-offset.html
new file mode 100644
index 0000000000..d1f50304ad
--- /dev/null
+++ b/testing/web-platform/tests/dom/events/scrolling/scrollend-with-snap-on-fractional-offset.html
@@ -0,0 +1,85 @@
+<!DOCTYPE html>
+<html>
+ <script src="/resources/testharness.js"></script>
+ <script src="/resources/testharnessreport.js"></script>
+ <script src="scroll_support.js"></script>
+ <body>
+ <style>
+ .scroller {
+ scroll-snap-type: x mandatory;
+ overflow-x: auto;
+ overflow-y: hidden;
+ position: relative;
+ height: 500px;
+ width: 500px;
+ }
+
+ .box {
+ scroll-snap-align: start;
+ width: 400px;
+ position: absolute;
+ top: 200px;
+ }
+
+ #box1 {
+ background-color: red;
+ height: 500px;
+ }
+
+ #box2 {
+ background-color: yellow;
+ height: 300px;
+ left: 700.5px;
+ }
+
+ #box3 {
+ background-color: blue;
+ height: 100px;
+ left: 1400px;
+ }
+ </style>
+ <div id="scroller" class="scroller">
+ <div class="box" id="box1">1</div>
+ <div class="box" id="box2">2</div>
+ <div class="box" id="box3">3</div>
+ </div>
+ <script>
+ let scrollendCount = 0;
+ scroller.addEventListener('scrollend', () => {
+ scroller.style.maxHeight = null;
+ scroller.style.maxHeight = `${box2.clientHeight}px`;
+ scrollendCount += 1;
+ });
+ promise_test(async (test) => {
+ // This test aims to verify that scrollend fires correctly (i.e. once)
+ // when the target snap position is not a whole number. In this case, we
+ // expect to snap to the left edge of box2 which is at a fractional
+ // offset from the scroller's origin (left: 700.5px).
+ // The scroll offset resulting from the snap may not be fractional
+ // (e.g. if the browser does not support fractional scroll offsets) so
+ // we verify the scroll offset with assert_approx_equals.
+ assert_equals(scroller.scrollLeft, 0,
+ "test precondition: scroller is not scrolled.");
+ const expected_scroll_left = box2.offsetLeft;
+ const target_offset = box2.offsetLeft + box2.clientWidth / 2;
+
+ let scrollend_promise = waitForScrollendEvent(test, scroller);
+ scroller.scrollTo( { left: target_offset });
+ await scrollend_promise;
+
+ // Instead of a time-based wait for errant scrollends, we wait a frame
+ // and then scroll back to 0.
+ await waitForCompositorCommit();
+ assert_approx_equals(scroller.scrollLeft, expected_scroll_left, 1,
+ "scroller snaps to the left edge of box 2");
+
+ scrollend_promise = waitForScrollendEvent(test, scroller);
+ scroller.scrollTo({ left: 0 });
+ await scrollend_promise;
+ assert_equals(scroller.scrollLeft, 0,
+ "scroller should be scrolled back to 0.");
+ assert_equals(scrollendCount, 2, "exactly 2 scrollends should be seen");
+ }, "snap to fractional offset fires scrollend exactly once.");
+ </script>
+ </body>
+</html> \ No newline at end of file