summaryrefslogtreecommitdiffstats
path: root/testing/web-platform/tests/scroll-animations/scroll-timelines
diff options
context:
space:
mode:
Diffstat (limited to 'testing/web-platform/tests/scroll-animations/scroll-timelines')
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-ref.html45
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-animatable-interface.html66
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-delay-crash.html31
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-display-none.html75
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-offsets-crash.html32
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden-ref.html45
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden.html64
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller-ref.html37
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller.html60
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-transform.html68
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/cancel-animation.html214
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/constructor-no-document.html19
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/constructor.html95
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-nan.html80
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-root-scroller.html49
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-writing-modes.html148
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property-ref.html34
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property.html46
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/effect-updateTiming.html630
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/finish-animation.html393
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/idlharness.window.js16
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/intrinsic-iteration-duration.tentative.html78
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/layout-changes-on-percentage-based-timeline.html84
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/null-scroll-source-crash.html24
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/pause-animation.html178
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/play-animation.html276
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay-ref.html45
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay.tentative.html69
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/reverse-animation.html164
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-fill-modes.tentative.html137
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-phases.tentative.html555
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-inactive-timeline.html170
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation.html160
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-in-removed-iframe-crash.html20
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-invalidation.html133
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-range.html185
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-snapshotting.html44
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/set-current-time-before-play.html75
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/setting-current-time.html286
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/setting-playback-rate.html298
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/setting-start-time.html401
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/setting-timeline.tentative.html429
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/source-quirks-mode.html36
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/testcommon.js124
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline-cancel-one.html84
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline.html79
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/update-playback-rate.html178
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/updating-the-finished-state.html565
48 files changed, 7124 insertions, 0 deletions
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-ref.html
new file mode 100644
index 0000000000..9158715321
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-ref.html
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<title>Reference for Web Animation with scroll timeline tests</title>
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translate(0, 100px);
+ opacity: 0.5;
+ will-change: transform; /* force compositing */
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"></div>
+</div>
+
+<script>
+ window.addEventListener('load', function() {
+ // Move the scroller to halfway.
+ const scroller = document.getElementById("scroller");
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-animatable-interface.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-animatable-interface.html
new file mode 100644
index 0000000000..b04aaf2d33
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-animatable-interface.html
@@ -0,0 +1,66 @@
+<html class="reftest-wait">
+<title>Scroll-linked animation with Animatable interface</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="ScrollTimeline should work with animatable
+interface">
+<link rel="match" href="animation-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+ /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"><p>Scrolling Contents</p></div>
+</div>
+
+<script>
+ const scroller = document.getElementById('scroller');
+ const scroll_timeline = new ScrollTimeline({source: scroller});
+ const box = document.getElementById('box');
+ const animation = box.animate(
+ [
+ { transform: 'translateY(0)', opacity: 1 },
+ { transform: 'translateY(200px)', opacity: 0 }
+ ], {
+ timeline: scroll_timeline
+ }
+ );
+
+ animation.ready.then(() => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-delay-crash.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-delay-crash.html
new file mode 100644
index 0000000000..9d821f9e20
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-delay-crash.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/web-animations-1/#the-effecttiming-dictionaries">
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<div class="scroller">
+ <div class="contents"></div>
+</div>
+<script>
+ // Test passes if it does not crash.
+ // Scroll timeline animations are progress-based and not compatible with
+ // delays specified in milliseconds.
+ const timeline = new ScrollTimeline();
+ const options = {
+ timeline: timeline,
+ endDelay: 200
+ };
+ const keyframes = { opacity: [0, 1]};
+ const element = document.querySelector('.contents');
+ element.animate(keyframes, options);
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-display-none.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-display-none.html
new file mode 100644
index 0000000000..a62916833c
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-display-none.html
@@ -0,0 +1,75 @@
+<html class="reftest-wait">
+<title>Scroll timeline with Web Animation and transition from display:none to display:block</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Scroll timeline should properly handle going from display:none to display:block">
+<link rel="match" href="animation-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ .removed {
+ display: none;
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"><p>Scrolling Contents</p></div>
+</div>
+
+<script>
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateY(0)', opacity: 1 },
+ { transform: 'translateY(200px)', opacity: 0 }
+ ], {
+ duration: 1000,
+ }
+ );
+
+ const scroller = document.getElementById('scroller');
+ scroller.classList.add('removed');
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const animation = new Animation(effect, timeline);
+ animation.play();
+
+ waitForAnimationFrames(2).then(_ => {
+ scroller.classList.remove('removed');
+ animation.ready.then(() => {
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-offsets-crash.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-offsets-crash.html
new file mode 100644
index 0000000000..d4d1a55214
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-offsets-crash.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scrolltimeline-interface">
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<div class="scroller">
+ <div class="contents"></div>
+</div>
+<script>
+ // Test passes if it does not crash.
+ // Scroll timeline animations are progress-based and not compatible with
+ // delays specified in milliseconds.
+ const scroller = document.querySelector('.scroller');
+ const animation = new Animation();
+ const timeline = animation.timeline;
+ const duration = timeline.duration;
+ const options = {
+ source: scroller,
+ scrollOffsets: [new CSSMathInvert(duration)]
+ };
+ const scroll_timeline = new ScrollTimeline(options);
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden-ref.html
new file mode 100644
index 0000000000..c045f1a1c9
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden-ref.html
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<title>Scroll timeline with Web Animation using a scroller with overflow hidden</title>
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translate(0, 100px);
+ opacity: 0.5;
+ will-change: transform; /* force compositing */
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: hidden;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"></div>
+</div>
+
+<script>
+ window.addEventListener('load', function() {
+ // Move the scroller to halfway.
+ const scroller = document.getElementById("scroller");
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden.html
new file mode 100644
index 0000000000..bc7611d05a
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden.html
@@ -0,0 +1,64 @@
+<html class="reftest-wait">
+<title>Scroll timeline with Web Animation using a scroller with overflow hidden</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Web animation correctly updates values when using a overflow: hidden on the scroller being used as the source for the ScrollTimeline">
+<link rel="match" href="animation-with-overflow-hidden-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: hidden;
+ height: 100px;
+ width: 100px;
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"></div>
+</div>
+
+<script>
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ {transform: 'translateY(0)', opacity: 1},
+ {transform: 'translateY(200px)', opacity: 0}
+ ]
+ );
+
+ const scroller = document.getElementById('scroller');
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const animation = new Animation(effect, timeline);
+ animation.play();
+
+ animation.ready.then(() => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller-ref.html
new file mode 100644
index 0000000000..58435be631
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller-ref.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<title>Reference for Scroll timeline with Web Animation using the root scroller</title>
+<style>
+ html {
+ min-height: 100%;
+ min-width: 100%;
+ padding-bottom: 100px;
+ padding-right: 100px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translate(0, 100px);
+ opacity: 0.5;
+ will-change: transform; /* force compositing */
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"><p>Covered Contents</p></div>
+
+<script>
+ window.addEventListener('load', function() {
+ // Move the scroller to halfway.
+ const scroller = document.scrollingElement;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller.html
new file mode 100644
index 0000000000..6ba1a22445
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller.html
@@ -0,0 +1,60 @@
+<html class="reftest-wait">
+<title>Scroll timeline with Web Animation using the root scroller</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Web animation correctly updates values when using the root scroller as the source for the ScrollTimeline">
+<link rel="match" href="animation-with-root-scroller-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ html {
+ min-height: 100%;
+ min-width: 100%;
+ padding-bottom: 100px;
+ padding-right: 100px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"><p>Covered Contents</p></div>
+
+<script>
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ {transform: 'translateY(0)', opacity: 1},
+ {transform: 'translateY(200px)', opacity: 0}
+ ], {
+ duration: 1000,
+ }
+ );
+
+ const scroller = document.scrollingElement;
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const animation = new Animation(effect, timeline);
+ animation.play();
+
+ animation.ready.then(() => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-transform.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-transform.html
new file mode 100644
index 0000000000..f741cc634d
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-transform.html
@@ -0,0 +1,68 @@
+<html class="reftest-wait">
+<title>Basic use of scroll timeline with Web Animation</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Should be able to use the scroll timeline to drive the animation timing">
+<link rel="match" href="animation-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"><p>Scrolling Contents</p></div>
+</div>
+
+<script>
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateY(0)', opacity: 1},
+ { transform: 'translateY(200px)', opacity: 0}
+ ], {
+ duration: 1000,
+ }
+ );
+
+ const scroller = document.getElementById('scroller');
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const animation = new Animation(effect, timeline);
+ animation.play();
+
+ animation.ready.then(() => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/cancel-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/cancel-animation.html
new file mode 100644
index 0000000000..7daf76a7a5
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/cancel-animation.html
@@ -0,0 +1,214 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Canceling an animation</title>
+<link rel="help"
+ href="https://drafts.csswg.org/web-animations/#canceling-an-animation-section">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.cancel();
+
+ assert_equals(animation.startTime, null,
+ 'The start time of a canceled animation should be unresolved');
+ assert_equals(animation.currentTime, null,
+ 'The hold time of a canceled animation should be unresolved');
+}, 'Canceling an animation should cause its start time and hold time to be'
+ + ' unresolved');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ const retPromise = animation.ready.then(() => {
+ assert_unreached('ready promise was fulfilled');
+ }).catch(err => {
+ assert_equals(err.name, 'AbortError',
+ 'ready promise is rejected with AbortError');
+ });
+
+ animation.cancel();
+
+ return retPromise;
+}, 'A play-pending ready promise should be rejected when the animation is'
+ + ' canceled');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ await animation.ready;
+
+ // Make it pause-pending
+ animation.pause();
+
+ // We need to store the original ready promise since cancel() will
+ // replace it
+ const originalPromise = animation.ready;
+ animation.cancel();
+
+ await promise_rejects_dom(t, 'AbortError', originalPromise,
+ 'Cancel should abort ready promise');
+}, 'A pause-pending ready promise should be rejected when the animation is'
+ + ' canceled');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.cancel();
+ const promiseResult = await animation.ready;
+ assert_equals(promiseResult, animation);
+}, 'When an animation is canceled, it should create a resolved Promise');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ const promise = animation.ready;
+ animation.cancel();
+ assert_not_equals(animation.ready, promise);
+ promise_rejects_dom(t, 'AbortError', promise,
+ 'Cancel should abort ready promise');
+}, 'The ready promise should be replaced when the animation is canceled');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.playState, 'idle',
+ 'The animation should be initially idle');
+
+ animation.finished.then(t.step_func(() => {
+ assert_unreached('Finished promise should not resolve');
+ }), t.step_func(() => {
+ assert_unreached('Finished promise should not reject');
+ }));
+
+ animation.cancel();
+
+ return waitForAnimationFrames(3);
+}, 'The finished promise should NOT be rejected if the animation is already'
+ + ' idle');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.playState, 'idle',
+ 'The animation should be initially idle');
+
+ animation.oncancel = t.step_func(() => {
+ assert_unreached('Cancel event should not be fired');
+ });
+
+ animation.cancel();
+
+ return waitForAnimationFrames(3);
+}, 'The cancel event should NOT be fired if the animation is already idle');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.effect.target.remove();
+
+ const eventWatcher = new EventWatcher(t, animation, 'cancel');
+
+ await animation.ready;
+ animation.cancel();
+
+ await eventWatcher.wait_for('cancel');
+
+ assert_equals(animation.effect.target.parentNode, null,
+ 'cancel event should be fired for the animation on an orphaned element');
+}, 'Canceling an animation should fire cancel event on orphaned element');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ await animation.ready;
+
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ animation.cancel();
+ assert_equals(animation.startTime, null,
+ 'The start time of a canceled animation should be unresolved');
+ assert_equals(animation.currentTime, null,
+ 'The current time of a canceled animation should be unresolved');
+}, 'Canceling an animation with inactive timeline should cause its start time'
+ + ' and hold time to be unresolved');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ await animation.ready;
+
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+
+ const eventWatcher = new EventWatcher(t, animation, 'cancel');
+ animation.cancel();
+ const cancelEvent = await eventWatcher.wait_for('cancel');
+
+ assert_equals(cancelEvent.currentTime, null,
+ 'event.currentTime should be unresolved when the timeline is inactive.');
+ assert_equals(cancelEvent.timelineTime, null,
+ 'event.timelineTime should be unresolved when the timeline is inactive');
+}, 'oncancel event is fired when the timeline is inactive.');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor-no-document.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor-no-document.html
new file mode 100644
index 0000000000..d2cc590bc7
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor-no-document.html
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>ScrollTimeline constructor - no document</title>
+<link rel="help" href="https://wicg.github.io/scroll-animations/#scrolltimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<script>
+'use strict';
+
+test(function() {
+ document.documentElement.remove();
+ assert_equals(document.scrollingElement, null);
+
+ const timeline = new ScrollTimeline();
+ assert_equals(timeline.source, null);
+ assert_equals(timeline.currentTime, null);
+}, 'The source can be null if the document.scrollingElement does not exist');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor.html
new file mode 100644
index 0000000000..88c6a453ec
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor.html
@@ -0,0 +1,95 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>ScrollTimeline constructor</title>
+ <link rel="help" href="https://wicg.github.io/scroll-animations/#scrolltimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<style>
+.scroller {
+ height: 100px;
+ width: 100px;
+ overflow: scroll;
+}
+
+.content {
+ height: 500px;
+ width: 500px;
+}
+</style>
+
+<div class="scroller">
+ <div class="content"></div>
+</div>
+
+<script>
+'use strict';
+
+function formatOffset(v) {
+ if (typeof(v) == 'object')
+ return `${v.constructor.name}(${v.toString()})`;
+ return `'${v.toString()}'`;
+}
+
+function assert_offsets_equal(a, b) {
+ assert_equals(formatOffset(a), formatOffset(b));
+}
+
+// source
+
+test(t => {
+ const scroller = document.querySelector('.scroller');
+ assert_equals(
+ new ScrollTimeline({source: scroller}).source, scroller);
+}, 'A ScrollTimeline can be created with a source');
+
+test(t => {
+ const div = document.createElement('div');
+ assert_equals(new ScrollTimeline({source: div}).source, div);
+}, 'A ScrollTimeline can be created with a non-scrolling source');
+
+test(t => {
+ assert_equals(new ScrollTimeline({source: null}).source, null);
+}, 'A ScrollTimeline created with a null source should have no source');
+
+test(t => {
+ assert_equals(new ScrollTimeline().source, document.scrollingElement);
+}, 'A ScrollTimeline created without a source should use the ' +
+ 'document.scrollingElement');
+
+// axis
+
+test(t => {
+ assert_equals(new ScrollTimeline().axis, 'block');
+}, 'A ScrollTimeline created with the default axis should default to ' +
+ `'block'`);
+
+const gValidAxisValues = [
+ 'block',
+ 'inline',
+ 'x',
+ 'y',
+];
+
+for (let axis of gValidAxisValues) {
+ test(function() {
+ const scrollTimeline =
+ new ScrollTimeline({axis: axis});
+ assert_equals(scrollTimeline.axis, axis);
+ }, `'${axis}' is a valid axis value`);
+}
+
+test(t => {
+ let constructorFunc = function() {
+ new ScrollTimeline({axis: 'nonsense'})
+ };
+ assert_throws_js(TypeError, constructorFunc);
+
+ // 'auto' for axis was previously in the spec, but was removed. Make
+ // sure that implementations do not support it.
+ constructorFunc = function() {
+ new ScrollTimeline({axis: 'auto'})
+ };
+ assert_throws_js(TypeError, constructorFunc);
+}, 'Creating a ScrollTimeline with an invalid axis value should throw');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-nan.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-nan.html
new file mode 100644
index 0000000000..440b1f413e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-nan.html
@@ -0,0 +1,80 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>ScrollTimeline current time algorithm - NaN cases</title>
+<link rel="help" href="https://wicg.github.io/scroll-animations/#current-time-algorithm">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<style>
+.scroller {
+ height: 100px;
+ width: 100px;
+ overflow: auto;
+}
+
+.content {
+ height: 500px;
+ width: 500px;
+}
+</style>
+
+<div id='inlineScroller' class='scroller' style='display: inline;'>
+ <div class='content'></div>
+</div>
+<script>
+'use strict';
+
+test(function() {
+ const scroller = document.querySelector('#inlineScroller');
+ const scrollTimeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+
+ assert_equals(scrollTimeline.currentTime, null);
+}, 'currentTime should be null for a display: inline source');
+</script>
+
+<div id='displayNoneScroller' class='scroller' style='display: none;'>
+ <div class='content'></div>
+</div>
+<script>
+test(function() {
+ const scroller = document.querySelector('#displayNoneScroller');
+ const scrollTimeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+
+ assert_equals(scrollTimeline.currentTime, null);
+}, 'currentTime should be null for a display: none source');
+</script>
+
+<script>
+test(function() {
+ const scroller = document.createElement('div');
+ const content = document.createElement('div');
+
+ scroller.style.overflow = 'auto';
+ scroller.style.height = '100px';
+ scroller.style.width = '100px';
+ content.style.height = '250px';
+ content.style.width = '250px';
+
+ scroller.appendChild(content);
+
+ const scrollTimeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+
+ assert_equals(scrollTimeline.currentTime, null);
+}, 'currentTime should be null for an unattached source');
+</script>
+
+<div id='notAScroller' class='scroller' style='overflow: visible;'>
+ <div class='content'></div>
+</div>
+<script>
+test(function() {
+ const scroller = document.querySelector('#notAScroller');
+ const scrollTimeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+
+ assert_equals(scrollTimeline.currentTime, null);
+}, 'currentTime should be null when the source is not a scroller');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-root-scroller.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-root-scroller.html
new file mode 100644
index 0000000000..be1d62bec5
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-root-scroller.html
@@ -0,0 +1,49 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>ScrollTimeline current time algorithm - root scroller</title>
+<link rel="help" href="https://wicg.github.io/scroll-animations/#current-time-algorithm">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="./testcommon.js"></script>
+
+<style>
+html {
+ /* Ensure the document is scrollable. */
+ min-height: 100%;
+ min-width: 100%;
+ padding-bottom: 100px;
+ padding-right: 100px;
+}
+</style>
+
+<script>
+promise_test(async t => {
+ const scroller = document.scrollingElement;
+ // Allow layout to finish, otherwise the scroller isn't set up by the time
+ // we check the currentTime of the scroll timeline.
+ await waitForNextFrame();
+
+ const blockScrollTimeline = new ScrollTimeline(
+ { source: scroller, axis: 'block' });
+ const inlineScrollTimeline = new ScrollTimeline(
+ { source: scroller, axis: 'inline' });
+
+ // Wait for new animation frame which allows the timeline to fully initialize
+ await waitForNextFrame();
+
+ // Unscrolled, both timelines should read a currentTime of 0.
+ assert_percents_equal(blockScrollTimeline.currentTime, 0);
+ assert_percents_equal(inlineScrollTimeline.currentTime, 0);
+
+ // Now do some scrolling and make sure that the ScrollTimelines update.
+ scroller.scrollTop = 50;
+ scroller.scrollLeft = 75;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(blockScrollTimeline.currentTime, 50);
+ assert_percents_equal(inlineScrollTimeline.currentTime, 75);
+}, 'currentTime calculates the correct time for a document.scrollingElement source');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-writing-modes.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-writing-modes.html
new file mode 100644
index 0000000000..748cda2f89
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-writing-modes.html
@@ -0,0 +1,148 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>ScrollTimeline current time algorithm - interaction with writing modes</title>
+<link rel="help" href="https://wicg.github.io/scroll-animations/#current-time-algorithm">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="./testcommon.js"></script>
+
+<body></body>
+
+<script>
+'use strict';
+
+promise_test(async t => {
+ const scrollerOverrides = new Map([['direction', 'rtl']]);
+ const scroller = setupScrollTimelineTest(scrollerOverrides);
+ const verticalScrollRange = scroller.scrollHeight - scroller.clientHeight;
+ const horizontalScrollRange = scroller.scrollWidth - scroller.clientWidth;
+
+ const blockScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'block'});
+ const inlineScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'inline'});
+ const horizontalScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'x'});
+ const verticalScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'y'});
+
+ // Unscrolled, all timelines should read a current time of 0 even though the
+ // X-axis will have started at the right hand side for rtl.
+ assert_percents_equal(blockScrollTimeline.currentTime, 0,
+ 'Unscrolled block timeline');
+ assert_percents_equal(inlineScrollTimeline.currentTime, 0,
+ 'Unscrolled inline timeline');
+ assert_percents_equal(horizontalScrollTimeline.currentTime, 0,
+ 'Unscrolled horizontal timeline');
+ assert_percents_equal(verticalScrollTimeline.currentTime, 0,
+ 'Unscrolled vertical timeline');
+
+ // The offset in the inline/horizontal direction should be inverted. The
+ // block/vertical direction should be unaffected.
+ scroller.scrollTop = 0.1 * verticalScrollRange;
+ scroller.scrollLeft = -0.8 * horizontalScrollRange;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(blockScrollTimeline.currentTime, 10,
+ 'Scrolled block timeline');
+ assert_percents_equal(inlineScrollTimeline.currentTime, 80,
+ 'Scrolled inline timeline');
+ assert_percents_equal(horizontalScrollTimeline.currentTime, 80,
+ 'Scrolled horizontal timeline');
+ assert_percents_equal(verticalScrollTimeline.currentTime, 10,
+ 'Scrolled vertical timeline');
+}, 'currentTime handles direction: rtl correctly');
+
+promise_test(async t => {
+ const scrollerOverrides = new Map([['writing-mode', 'vertical-rl']]);
+ const scroller = setupScrollTimelineTest(scrollerOverrides);
+ const verticalScrollRange = scroller.scrollHeight - scroller.clientHeight;
+ const horizontalScrollRange = scroller.scrollWidth - scroller.clientWidth;
+
+ const blockScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'block'});
+ const inlineScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'inline'});
+ const horizontalScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'x'});
+ const verticalScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'y'});
+
+ // Unscrolled, all timelines should read a current time of 0 even though the
+ // X-axis will have started at the right hand side for vertical-rl.
+ assert_percents_equal(blockScrollTimeline.currentTime, 0,
+ 'Unscrolled block timeline');
+ assert_percents_equal(inlineScrollTimeline.currentTime, 0,
+ 'Unscrolled inline timeline');
+ assert_percents_equal(horizontalScrollTimeline.currentTime, 0,
+ 'Unscrolled horizontal timeline');
+ assert_percents_equal(verticalScrollTimeline.currentTime, 0,
+ 'Unscrolled vertical timeline');
+
+ // For vertical-rl, the X-axis starts on the right-hand-side and is the block
+ // axis. The Y-axis is normal but is the inline axis. For the
+ // horizontal/vertical cases, horizontal starts on the right-hand-side and
+ // vertical is normal.
+ scroller.scrollTop = 0.1 * verticalScrollRange;
+ scroller.scrollLeft = -0.8 * horizontalScrollRange;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(blockScrollTimeline.currentTime, 80,
+ 'Scrolled block timeline');
+ assert_percents_equal(inlineScrollTimeline.currentTime, 10,
+ 'Scrolled inline timeline');
+ assert_percents_equal(horizontalScrollTimeline.currentTime, 80,
+ 'Scrolled horizontal timeline');
+ assert_percents_equal(verticalScrollTimeline.currentTime, 10,
+ 'Scrolled vertical timeline');
+}, 'currentTime handles writing-mode: vertical-rl correctly');
+
+promise_test(async t => {
+ const scrollerOverrides = new Map([['writing-mode', 'vertical-lr']]);
+ const scroller = setupScrollTimelineTest(scrollerOverrides);
+ const verticalScrollRange = scroller.scrollHeight - scroller.clientHeight;
+ const horizontalScrollRange = scroller.scrollWidth - scroller.clientWidth;
+
+ const blockScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'block'});
+ const inlineScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'inline'});
+ const horizontalScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'x'});
+ const verticalScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'y'});
+
+ // Unscrolled, all timelines should read a current time of 0.
+ assert_percents_equal(blockScrollTimeline.currentTime, 0,
+ 'Unscrolled block timeline');
+ assert_percents_equal(inlineScrollTimeline.currentTime, 0,
+ 'Unscrolled inline timeline');
+ assert_percents_equal(horizontalScrollTimeline.currentTime, 0,
+ 'Unscrolled horizontal timeline');
+ assert_percents_equal(verticalScrollTimeline.currentTime, 0,
+ 'Unscrolled vertical timeline');
+
+ // For vertical-lr, both axes start at their 'normal' positions but the X-axis
+ // is the block direction and the Y-axis is the inline direction. This does
+ // not affect horizontal/vertical.
+ scroller.scrollTop = 0.1 * verticalScrollRange;
+ scroller.scrollLeft = 0.2 * horizontalScrollRange;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(blockScrollTimeline.currentTime, 20,
+ 'Scrolled block timeline');
+ assert_percents_equal(inlineScrollTimeline.currentTime, 10,
+ 'Scrolled inline timeline');
+ assert_percents_equal(horizontalScrollTimeline.currentTime, 20,
+ 'Scrolled horizontal timeline');
+ assert_percents_equal(verticalScrollTimeline.currentTime, 10,
+ 'Scrolled vertical timeline');
+}, 'currentTime handles writing-mode: vertical-lr correctly');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property-ref.html
new file mode 100644
index 0000000000..66e29cde65
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property-ref.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+ <link rel="help" src="https://github.com/w3c/csswg-drafts/issues/7759">
+ <script src="/web-animations/testcommon.js"></script>
+ <script src="/common/reftest-wait.js"></script>
+ <style>
+ html {
+ overflow: hidden;
+ }
+ .spacer {
+ height: 300vh;
+ }
+ .box {
+ position: fixed;
+ left: 0;
+ top: 0;
+ width: 100px;
+ height: 100px;
+ background: black;
+ border: solid red;
+ translate: 100px;
+ will-change: transform;
+ }
+ </style>
+</head>
+<body>
+ <div class="box"></div>
+ <div class='spacer'></div>
+</body>
+<script>
+ waitForCompositorReady().then(takeScreenshot);
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property.html
new file mode 100644
index 0000000000..d6fdda6752
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property.html
@@ -0,0 +1,46 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+ <link rel="help" src="https://github.com/w3c/csswg-drafts/issues/7759">
+ <link rel="match" href="custom-property-ref.html">
+ <script src="/web-animations/testcommon.js"></script>
+ <script src="/common/reftest-wait.js"></script>
+ <style>
+ html {
+ overflow: hidden;
+ }
+ .spacer {
+ height: 300vh;
+ }
+ .box {
+ position: fixed;
+ left: 0;
+ top: 0;
+ width: 100px;
+ height: 100px;
+ background: black;
+ border: solid red;
+ animation: move auto linear;
+ animation-timeline: scroll();
+ }
+
+ @keyframes move {
+ to {
+ translate: var(--adjustment);
+ }
+ }
+ </style>
+</head>
+<body>
+ <div class="box"></div>
+ <div class='spacer'></div>
+</body>
+<script>
+ scroller = document.scrollingElement;
+ scroller.scrollTop
+ = scroller.scrollHeight - scroller.clientHeight;
+ document.documentElement.style.setProperty(
+ '--adjustment', `100px`);
+ waitForCompositorReady().then(takeScreenshot);
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/effect-updateTiming.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/effect-updateTiming.html
new file mode 100644
index 0000000000..0c7a546572
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/effect-updateTiming.html
@@ -0,0 +1,630 @@
+<!doctype html>
+<meta charset=utf-8>
+<title>Scroll based animation: AnimationEffect.updateTiming</title>
+<!-- Adapted to progressed based scroll animations from "wpt\web-animations\interfaces\AnimationEffect\updateTiming.html" -->
+<link rel="help" href="https://drafts.csswg.org/web-animations-1/#dom-animationeffect-updatetiming">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<script src="/web-animations/resources/easing-tests.js"></script>
+<script src="/web-animations/resources/timing-tests.js"></script>
+<style>
+ .scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+ }
+ .contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+<body>
+<div id="log"></div>
+<script>
+'use strict';
+
+// ------------------------------
+// delay
+// ------------------------------
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, {duration: 1000, delay: 200})
+ anim.play();
+
+ assert_equals(anim.effect.getTiming().delay, 200, 'initial delay 200');
+ assert_equals(anim.effect.getComputedTiming().delay, 200,
+ 'getComputedTiming() initially delay 200');
+
+ anim.effect.updateTiming({ delay: 100 });
+ assert_equals(anim.effect.getTiming().delay, 100, 'set delay 100');
+ assert_equals(anim.effect.getComputedTiming().delay, 100,
+ 'getComputedTiming() after set delay 100');
+}, 'Allows setting the delay to a positive number');
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, {duration: 100, delay: -100})
+ anim.play();
+ anim.effect.updateTiming({ delay: -100 });
+ assert_equals(anim.effect.getTiming().delay, -100, 'set delay -100');
+ assert_equals(anim.effect.getComputedTiming().delay, -100,
+ 'getComputedTiming() after set delay -100');
+ assert_percents_equal(anim.effect.getComputedTiming().endTime, 0,
+ 'getComputedTiming().endTime after set delay -100');
+}, 'Allows setting the delay to a negative number');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, {duration: 100})
+ anim.play();
+ await anim.ready;
+ anim.effect.updateTiming({ delay: 100 });
+ assert_equals(anim.effect.getComputedTiming().progress, null);
+ assert_equals(anim.effect.getComputedTiming().currentIteration, null);
+}, 'Allows setting the delay of an animation in progress: positive delay that'
+ + ' causes the animation to be no longer in-effect');
+
+promise_test(async t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(t, { fill: 'both', duration: 100 });
+ anim.play();
+ await anim.ready;
+ anim.effect.updateTiming({ delay: -50 });
+ assert_equals(anim.effect.getComputedTiming().progress, 0.5);
+}, 'Allows setting the delay of an animation in progress: negative delay that'
+ + ' seeks into the active interval');
+
+promise_test(async t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(t, { fill: 'both', duration: 100 });
+ anim.play();
+ await anim.ready;
+ anim.effect.updateTiming({ delay: -100 });
+ assert_equals(anim.effect.getComputedTiming().progress, 1);
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 0);
+}, 'Allows setting the delay of an animation in progress: large negative delay'
+ + ' that causes the animation to be finished');
+
+for (const invalid of gBadDelayValues) {
+ test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t)
+ anim.play();
+ assert_throws_js(TypeError, () => {
+ anim.effect.updateTiming({ delay: invalid });
+ });
+ }, `Throws when setting invalid delay value: ${invalid}`);
+}
+
+
+// ------------------------------
+// endDelay
+// ------------------------------
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 });
+ anim.play();
+ await anim.ready;
+ anim.effect.updateTiming({ endDelay: 123.45 });
+ assert_time_equals_literal(anim.effect.getTiming().endDelay, 123.45,
+ 'set endDelay 123.45');
+ assert_time_equals_literal(anim.effect.getComputedTiming().endDelay, 123.45,
+ 'getComputedTiming() after set endDelay 123.45');
+}, 'Allows setting the endDelay to a positive number');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 });
+ anim.play();
+ await anim.ready;
+ anim.effect.updateTiming({ endDelay: -1000 });
+ assert_equals(anim.effect.getTiming().endDelay, -1000, 'set endDelay -1000');
+ assert_equals(anim.effect.getComputedTiming().endDelay, -1000,
+ 'getComputedTiming() after set endDelay -1000');
+}, 'Allows setting the endDelay to a negative number');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 });
+ anim.play();
+ await anim.ready;
+ assert_throws_js(TypeError, () => {
+ anim.effect.updateTiming({ endDelay: Infinity });
+ });
+}, 'Throws when setting the endDelay to infinity');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 });
+ anim.play();
+ await anim.ready;
+ assert_throws_js(TypeError, () => {
+ anim.effect.updateTiming({ endDelay: -Infinity });
+ });
+}, 'Throws when setting the endDelay to negative infinity');
+
+
+// ------------------------------
+// fill
+// ------------------------------
+
+for (const fill of ['none', 'forwards', 'backwards', 'both']) {
+ test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 100 })
+ anim.play();
+ anim.effect.updateTiming({ fill });
+ assert_equals(anim.effect.getTiming().fill, fill, 'set fill ' + fill);
+ assert_equals(anim.effect.getComputedTiming().fill, fill,
+ 'getComputedTiming() after set fill ' + fill);
+ }, `Allows setting the fill to '${fill}'`);
+}
+
+
+// ------------------------------
+// iterationStart
+// ------------------------------
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { iterationStart: 0.2,
+ iterations: 1,
+ fill: 'both',
+ duration: 100,
+ delay: 1 })
+ anim.play();
+ await anim.ready;
+ anim.effect.updateTiming({ iterationStart: 2.5 });
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.5);
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 2);
+}, 'Allows setting the iterationStart of an animation in progress:'
+ + ' backwards-filling');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { iterationStart: 0.2,
+ iterations: 1,
+ fill: 'both',
+ duration: 100,
+ delay: 0 })
+ anim.play();
+ await anim.ready;
+ anim.effect.updateTiming({ iterationStart: 2.5 });
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.5);
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 2);
+}, 'Allows setting the iterationStart of an animation in progress:'
+ + ' active phase');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { iterationStart: 0.3,
+ iterations: 1,
+ fill: 'both',
+ duration: 200,
+ delay: 0 })
+ anim.play();
+ await anim.ready;
+ assert_percents_equal(anim.currentTime, 0);
+ assert_percents_equal(anim.effect.getComputedTiming().localTime, 0,
+ "localTime");
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.3);
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 0);
+
+ anim.finish();
+ assert_percents_equal(anim.currentTime, 100);
+ assert_percents_equal(anim.effect.getComputedTiming().localTime, 100,
+ "localTime");
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.3);
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 1);
+
+ anim.effect.updateTiming({ iterationStart: 2.5 });
+ assert_percents_equal(anim.currentTime, 100);
+ assert_percents_equal(anim.effect.getComputedTiming().localTime, 100,
+ "localTime");
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.5);
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 3);
+}, 'Allows setting the iterationStart of an animation in progress:'
+ + ' forwards-filling');
+
+for (const invalid of gBadIterationStartValues) {
+ test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t)
+ anim.play();
+ assert_throws_js(TypeError, () => {
+ anim.effect.updateTiming({ iterationStart: invalid });
+ }, `setting ${invalid}`);
+ }, `Throws when setting invalid iterationStart value: ${invalid}`);
+}
+
+// ------------------------------
+// iterations
+// ------------------------------
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 });
+ anim.play();
+ anim.effect.updateTiming({ iterations: 2 });
+ assert_equals(anim.effect.getTiming().iterations, 2, 'set duration 2');
+ assert_equals(anim.effect.getComputedTiming().iterations, 2,
+ 'getComputedTiming() after set iterations 2');
+}, 'Allows setting iterations to a double value');
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 });
+ anim.play();
+ assert_throws_js(TypeError, () => {
+ anim.effect.updateTiming({ iterations: Infinity });
+ }, "test");
+}, `Throws when setting iterations to Infinity`);
+
+
+// progress based animations behave a bit differently than time based animations
+// when changing iterations.
+test(t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(
+ t, { duration: 100000, fill: 'both' });
+ anim.play();
+ anim.finish();
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress when animation is finished');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 100,
+ 'duration when animation is finished');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 0,
+ 'current iteration when animation is finished');
+
+ anim.effect.updateTiming({ iterations: 2 });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after adding an iteration');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 50,
+ 'duration after adding an iteration');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 1,
+ 'current iteration after adding an iteration');
+
+ anim.effect.updateTiming({ iterations: 4 });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after setting iterations to 4');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 25,
+ 'duration after setting iterations to 4');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 3,
+ 'current iteration after setting iterations to 4');
+
+ anim.effect.updateTiming({ iterations: 0 });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 0,
+ 'progress after setting iterations to zero');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 0,
+ 'duration after setting iterations to zero');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 0,
+ 'current iteration after setting iterations to zero');
+}, 'Allows setting the iterations of an animation in progress');
+
+// Added test for checking duration "auto"
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { fill: 'both' });
+ anim.play();
+ anim.finish();
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress when animation is finished');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 100,
+ 'duration when animation is finished');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 0,
+ 'current iteration when animation is finished');
+
+ anim.effect.updateTiming({ iterations: 2 });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after adding an iteration');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 50,
+ 'duration after adding an iteration');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 1,
+ 'current iteration after adding an iteration');
+
+ anim.effect.updateTiming({ iterations: 4 });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after setting iterations to 4');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 25,
+ 'duration after setting iterations to 4');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 3,
+ 'current iteration after setting iterations to 4');
+
+ anim.effect.updateTiming({ iterations: 0 });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 0,
+ 'progress after setting iterations to zero');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 0,
+ 'duration after setting iterations to zero');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 0,
+ 'current iteration after setting iterations to zero');
+}, 'Allows setting the iterations of an animation in progress with duration ' +
+ '"auto"');
+
+
+// ------------------------------
+// duration
+// ------------------------------
+// adapted for progress based animations
+const gGoodDurationValuesForProgressBased = [
+ // until duration returns a CSSNumberish which can handle percentages, 100%
+ // will be represented as 100
+ { specified: 123.45, computed: 100 },
+ { specified: 'auto', computed: 100 },
+];
+
+for (const duration of gGoodDurationValuesForProgressBased) {
+ test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, 2000);
+ anim.play();
+ anim.effect.updateTiming({ duration: duration.specified });
+ if (typeof duration.specified === 'number') {
+ assert_time_equals_literal(anim.effect.getTiming().duration,
+ duration.specified,
+ 'Updates specified duration');
+ } else {
+ assert_equals(anim.effect.getTiming().duration, duration.specified,
+ 'Updates specified duration');
+ }
+ assert_percents_equal(anim.effect.getComputedTiming().duration,
+ duration.computed,
+ 'Updates computed duration');
+ }, `Allows setting the duration to ${duration.specified}`);
+}
+
+// adapted for progress based animations
+const gBadDurationValuesForProgressBased = [
+ -1, NaN, Infinity, -Infinity, 'abc', '100'
+];
+
+for (const invalid of gBadDurationValuesForProgressBased) {
+ test(t => {
+ assert_throws_js(TypeError, () => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: invalid })
+ anim.play();
+ });
+ }, 'Throws when setting invalid duration: '
+ + (typeof invalid === 'string' ? `"${invalid}"` : invalid));
+}
+
+test(t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(
+ t, { duration: 100000, fill: 'both' });
+ anim.play();
+ anim.finish();
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress when animation is finished');
+ anim.effect.updateTiming({ duration: anim.effect.getTiming().duration * 2 });
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 1,
+ 'progress after doubling the duration');
+ anim.effect.updateTiming({ duration: 0 });
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after setting duration to zero');
+ anim.effect.updateTiming({ duration: 'auto' });
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after setting duration to \'auto\'');
+}, 'Allows setting the duration of an animation in progress');
+
+promise_test(t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(
+ t, { duration: 100000, fill: 'both' });
+ anim.play();
+ return anim.ready.then(() => {
+ const originalStartTime = anim.startTime;
+ const originalCurrentTime = anim.currentTime;
+ assert_percents_equal(
+ anim.effect.getComputedTiming().duration,
+ 100,
+ 'Initial duration should be as set on KeyframeEffect'
+ );
+
+ anim.effect.updateTiming({ duration: 200000 });
+ assert_percents_equal(
+ anim.effect.getComputedTiming().duration,
+ 100,
+ 'Effect duration should remain at 100% for progress based animations'
+ );
+ assert_percents_equal(anim.startTime, originalStartTime,
+ 'startTime should be unaffected by changing effect ' +
+ 'duration');
+
+ assert_percents_equal(anim.currentTime, originalCurrentTime,
+ 'currentTime should be unaffected by changing ' +
+ 'effect duration');
+ });
+}, 'Allows setting the duration of an animation in progress such that the' +
+ ' the start and current time do not change');
+
+
+// ------------------------------
+// direction
+// ------------------------------
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 });
+ anim.play();
+
+ const directions = ['normal', 'reverse', 'alternate', 'alternate-reverse'];
+ for (const direction of directions) {
+ anim.effect.updateTiming({ direction: direction });
+ assert_equals(anim.effect.getTiming().direction, direction,
+ `set direction to ${direction}`);
+ }
+}, 'Allows setting the direction to each of the possible keywords');
+
+promise_test(async t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(
+ t, { duration: 10000, direction: 'normal' });
+
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ anim.play();
+ await anim.ready;
+ scroller.scrollTop = maxScroll * 0.07
+ await waitForNextFrame();
+
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.07,
+ 'progress before updating direction');
+
+ anim.effect.updateTiming({ direction: 'reverse' });
+
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.93,
+ 'progress after updating direction');
+}, 'Allows setting the direction of an animation in progress from \'normal\' ' +
+ 'to \'reverse\'');
+
+promise_test(async t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(
+ t, { duration: 10000, direction: 'normal' });
+ anim.play();
+ await anim.ready;
+ assert_equals(anim.effect.getComputedTiming().progress, 0,
+ 'progress before updating direction');
+
+ anim.effect.updateTiming({ direction: 'reverse' });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after updating direction');
+}, 'Allows setting the direction of an animation in progress from \'normal\' to'
+ + ' \'reverse\' while at start of active interval');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { fill: 'backwards',
+ duration: 10000,
+ delay: 10000,
+ direction: 'normal' });
+ anim.play();
+ await anim.ready;
+ assert_equals(anim.effect.getComputedTiming().progress, 0,
+ 'progress before updating direction');
+
+ anim.effect.updateTiming({ direction: 'reverse' });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after updating direction');
+}, 'Allows setting the direction of an animation in progress from \'normal\' to'
+ + ' \'reverse\' while filling backwards');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { iterations: 2,
+ duration: 10000,
+ direction: 'normal' });
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ anim.play();
+ await anim.ready;
+ scroller.scrollTop = maxScroll * 0.17 // 34% through first iteration
+ await waitForNextFrame();
+
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.34,
+ 'progress before updating direction');
+
+ anim.effect.updateTiming({ direction: 'alternate' });
+
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.34,
+ 'progress after updating direction');
+}, 'Allows setting the direction of an animation in progress from \'normal\' to'
+ + ' \'alternate\'');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { iterations: 2,
+ duration: 10000,
+ direction: 'alternate' });
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ anim.play();
+ await anim.ready;
+ scroller.scrollTop = maxScroll * 0.17
+ await waitForNextFrame();
+ // anim.currentTime = 17000; // 34% through first iteration
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.34,
+ 'progress before updating direction');
+
+ anim.effect.updateTiming({ direction: 'alternate-reverse' });
+
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.66,
+ 'progress after updating direction');
+}, 'Allows setting the direction of an animation in progress from \'alternate\''
+ + ' to \'alternate-reverse\'');
+
+
+// ------------------------------
+// easing
+// ------------------------------
+
+async function assert_progress(animation, scrollPercent, easingFunction) {
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = maxScroll * scrollPercent
+ await waitForNextFrame();
+ assert_approx_equals(animation.effect.getComputedTiming().progress,
+ easingFunction(scrollPercent),
+ 0.01,
+ 'The progress of the animation should be approximately'
+ + ` ${easingFunction(scrollPercent)} at ${scrollPercent}%`);
+}
+
+for (const options of gEasingTests) {
+ promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 100000,
+ fill: 'forwards' });
+ anim.play();
+ anim.effect.updateTiming({ easing: options.easing });
+ assert_equals(anim.effect.getTiming().easing,
+ options.serialization || options.easing);
+
+ const easing = options.easingFunction;
+ await assert_progress(anim, 0, easing);
+ await assert_progress(anim, 0.25, easing);
+ await assert_progress(anim, 0.5, easing);
+ await assert_progress(anim, 0.75, easing);
+ await assert_progress(anim, 1, easing);
+ }, `Allows setting the easing to a ${options.desc}`);
+}
+
+for (const easing of gRoundtripEasings) {
+ test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t);
+ anim.play();
+ anim.effect.updateTiming({ easing: easing });
+ assert_equals(anim.effect.getTiming().easing, easing);
+ }, `Updates the specified value when setting the easing to '${easing}'`);
+}
+
+// Because of the delay being so large, this progress based animation is always
+// in the finished state with progress 1. Included here because it is in the
+// original test file for time based animations.
+promise_test(async t => {
+ const delay = 1000000;
+
+ const anim = createScrollLinkedAnimationWithTiming(t,
+ { duration: 1000000, fill: 'both', delay: delay, easing: 'steps(2, start)' });
+
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ anim.play();
+ await anim.ready;
+
+ anim.effect.updateTiming({ easing: 'steps(2, end)' });
+ assert_equals(anim.effect.getComputedTiming().progress, 0,
+ 'easing replace to steps(2, end) at before phase');
+
+ scroller.scrollTop = maxScroll * 0.875
+ await waitForNextFrame();
+
+ assert_equals(anim.effect.getComputedTiming().progress, 0.5,
+ 'change currentTime to active phase');
+
+ anim.effect.updateTiming({ easing: 'steps(2, start)' });
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'easing replace to steps(2, start) at active phase');
+
+ scroller.scrollTop = maxScroll * 1.25
+ await waitForNextFrame();
+
+ anim.effect.updateTiming({ easing: 'steps(2, end)' });
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'easing replace to steps(2, end) again at after phase');
+}, 'Allows setting the easing of an animation in progress');
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/finish-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/finish-animation.html
new file mode 100644
index 0000000000..3faff63dc9
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/finish-animation.html
@@ -0,0 +1,393 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Finishing an animation</title>
+<link rel="help"
+ href="https://drafts.csswg.org/web-animations/#finishing-an-animation-section">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 200px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<div id="log"></div>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.playbackRate = 0;
+
+ assert_throws_dom('InvalidStateError', () => {
+ animation.finish();
+ });
+}, 'Finishing an animation with a zero playback rate throws');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.finish();
+
+ assert_percents_equal(animation.currentTime, 100,
+ 'After finishing, the currentTime should be set to the end of the'
+ + ' active duration');
+}, 'Finishing an animation seeks to the end time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ // 1% past effect end
+ animation.currentTime = CSSNumericValue.parse("101%");
+ animation.finish();
+
+ assert_percents_equal(animation.currentTime, 100,
+ 'After finishing, the currentTime should be set back to the end of the'
+ + ' active duration');
+}, 'Finishing an animation with a current time past the effect end jumps'
+ + ' back to the end');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.play();
+ scroller.scrollTop = maxScroll;
+ await animation.finished;
+
+ animation.playbackRate = -1;
+ animation.finish();
+
+ assert_percents_equal(animation.currentTime, 0,
+ 'After finishing a reversed animation the ' +
+ 'currentTime should be set to zero');
+}, 'Finishing a reversed animation jumps to zero time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("100%");
+ await animation.finished;
+
+ animation.playbackRate = -1;
+ animation.currentTime = CSSNumericValue.parse("-1000%");
+ animation.finish();
+
+ assert_percents_equal(animation.currentTime, 0,
+ 'After finishing a reversed animation the ' +
+ 'currentTime should be set back to zero');
+}, 'Finishing a reversed animation with a current time less than zero'
+ + ' makes it jump back to zero');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.playbackRate = 0.5;
+ animation.finish();
+
+ assert_false(animation.pending);
+ assert_equals(animation.playState, 'finished',
+ 'The play state of a play-pending animation should become ' +
+ '"finished"');
+ assert_percents_equal(animation.startTime,
+ animation.timeline.currentTime.value - 100 / 0.5,
+ 'The start time of a play-pending animation should ' +
+ 'be set');
+}, 'Finishing an animation while play-pending resolves the pending'
+ + ' task immediately');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ await runAndWaitForFrameUpdate(() => {
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ });
+ animation.play();
+ animation.finish();
+
+ await animation.finished;
+
+ assert_true(animation.pending);
+ assert_equals(animation.playState, 'finished',
+ 'The play state of a play-pending animation should become ' +
+ '"finished"');
+ assert_percents_equal(animation.currentTime, 100,
+ 'The current time of a play-pending animation should ' +
+ 'be set to the end of the active duration');
+ assert_equals(animation.startTime, null,
+ 'The start time of a finished play-pending animation should ' +
+ 'be unresolved');
+}, 'Finishing an animation attached to inactive timeline while play-pending '
+ + 'doesn\'t resolves the pending task');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ let resolvedFinished = false;
+ animation.finished.then(() => {
+ resolvedFinished = true;
+ });
+
+ await animation.ready;
+
+ animation.finish();
+ await Promise.resolve();
+
+ assert_true(resolvedFinished, 'finished promise should be resolved');
+}, 'Finishing an animation resolves the finished promise synchronously');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ const promise = animation.ready;
+ let readyResolved = false;
+
+ animation.finish();
+ animation.ready.then(() => { readyResolved = true; });
+
+ const promiseResult = await animation.finished;
+ await animation.ready;
+
+ assert_equals(promiseResult, animation);
+ assert_equals(animation.ready, promise);
+ assert_true(readyResolved);
+}, 'A pending ready promise is resolved and not replaced when the animation'
+ + ' is finished');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.effect.target.remove();
+
+ const eventWatcher = new EventWatcher(t, animation, 'finish');
+
+ await animation.ready;
+ animation.finish();
+
+ await eventWatcher.wait_for('finish');
+ assert_equals(animation.effect.target.parentNode, null,
+ 'finish event should be fired for the animation on an orphaned element');
+}, 'Finishing an animation fires finish event on orphaned element');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ const originalFinishPromise = animation.finished;
+
+ animation.cancel();
+ assert_equals(animation.startTime, null);
+ assert_equals(animation.currentTime, null);
+
+ const resolvedFinishPromise = animation.finished;
+ assert_not_equals(originalFinishPromise, resolvedFinishPromise,
+ 'Canceling an animation should create a new finished promise');
+
+ animation.finish();
+ assert_equals(animation.playState, 'finished',
+ 'The play state of a canceled animation should become ' +
+ '"finished"');
+ assert_percents_equal(animation.startTime,
+ animation.timeline.currentTime.value - 100,
+ 'The start time of a finished animation should be set');
+ assert_percents_equal(animation.currentTime, 100,
+ 'Hold time should be set to end boundary of the ' +
+ 'animation');
+
+}, 'Finishing a canceled animation sets the current and start times');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ const eventWatcher = new EventWatcher(t, animation, 'finish');
+ animation.finish();
+ await animation.finished;
+ const finishEvent = await eventWatcher.wait_for('finish');
+ assert_equals(animation.playState, 'finished',
+ 'Animation is finished.');
+ assert_percents_equal(animation.currentTime, 100,
+ 'The current time is the end of the active duration in finished state.');
+ assert_percents_equal(animation.startTime, -75,
+ 'The start time is calculated to match the current time.');
+ assert_percents_equal(finishEvent.currentTime, 100,
+ 'event.currentTime is the animation current time.');
+ assert_percents_equal(finishEvent.timelineTime, 25,
+ 'event.timelineTime is timeline.currentTime');
+}, 'Finishing idle animation produces correct state and fires finish event.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ animation.finish();
+ assert_equals(animation.playState, 'paused', 'Animation is paused.');
+}, 'Finishing idle animation attached to inactive timeline pauses the ' +
+ 'animation.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ animation.play();
+ await animation.ready;
+
+ const eventWatcher = new EventWatcher(t, animation, 'finish');
+ animation.finish();
+ await animation.finished;
+ const finishEvent = await eventWatcher.wait_for('finish');
+ assert_equals(animation.playState, 'finished',
+ 'Animation is finished.');
+ assert_percents_equal(animation.currentTime, 100,
+ 'The current time is the end of active duration in finished state.');
+ assert_percents_equal(animation.startTime, -75,
+ 'The start time is calculated to match animation current time.');
+ assert_percents_equal(finishEvent.currentTime, 100,
+ 'event.currentTime is the animation current time.');
+ assert_percents_equal(finishEvent.timelineTime, 25,
+ 'event.timelineTime is timeline.currentTime');
+}, 'Finishing running animation produces correct state and fires finish event.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ animation.play();
+ await animation.ready;
+
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ assert_equals(animation.playState, 'running',
+ 'Sanity check the animation is running.');
+
+ animation.finish();
+ assert_equals(animation.playState, 'paused', 'Animation is paused.');
+}, 'Finishing running animation attached to inactive timeline pauses the ' +
+ 'animation.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.pause();
+ await animation.ready;
+
+ animation.finish();
+
+ assert_equals(animation.playState, 'finished',
+ 'The play state of a paused animation should become ' +
+ '"finished"');
+ assert_percents_equal(animation.startTime,
+ animation.timeline.currentTime.value - 100,
+ 'The start time of a paused animation should be set');
+}, 'Finishing a paused animation resolves the start time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ // Update playbackRate so we can test that the calculated startTime
+ // respects it
+ animation.playbackRate = 2;
+ animation.pause();
+ // While animation is still pause-pending call finish()
+ animation.finish();
+
+ assert_false(animation.pending);
+ assert_equals(animation.playState, 'finished',
+ 'The play state of a pause-pending animation should become ' +
+ '"finished"');
+ assert_percents_equal(animation.startTime,
+ animation.timeline.currentTime.value - 100 / 2,
+ 'The start time of a pause-pending animation should ' +
+ 'be set');
+}, 'Finishing a pause-pending animation resolves the pending task'
+ + ' immediately and update the start time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.playbackRate = -2;
+ animation.pause();
+ animation.finish();
+
+ assert_false(animation.pending);
+ assert_equals(animation.playState, 'finished',
+ 'The play state of a pause-pending animation should become ' +
+ '"finished"');
+ assert_percents_equal(animation.startTime,
+ animation.timeline.currentTime,
+ 'The start time of a pause-pending animation should ' +
+ 'be set');
+}, 'Finishing a pause-pending animation with negative playback rate'
+ + ' resolves the pending task immediately');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ animation.pause();
+ animation.play();
+ // We are now in the unusual situation of being play-pending whilst having
+ // a resolved start time. Check that finish() still triggers a transition
+ // to the finished state immediately.
+ animation.finish();
+
+ assert_equals(animation.playState, 'finished',
+ 'After aborting a pause then finishing an animation its play ' +
+ 'state should become "finished" immediately');
+}, 'Finishing an animation during an aborted pause makes it finished'
+ + ' immediately');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ animation.updatePlaybackRate(2);
+ assert_true(animation.pending);
+
+ animation.finish();
+ assert_false(animation.pending);
+ assert_equals(animation.playbackRate, 2);
+ assert_percents_equal(animation.currentTime, 100);
+}, 'A pending playback rate should be applied immediately when an animation'
+ + ' is finished');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ animation.updatePlaybackRate(0);
+
+ assert_throws_dom('InvalidStateError', () => {
+ animation.finish();
+ });
+}, 'An exception should be thrown if the effective playback rate is zero');
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/idlharness.window.js b/testing/web-platform/tests/scroll-animations/scroll-timelines/idlharness.window.js
new file mode 100644
index 0000000000..90157580ce
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/idlharness.window.js
@@ -0,0 +1,16 @@
+// META: script=/resources/WebIDLParser.js
+// META: script=/resources/idlharness.js
+
+'use strict';
+
+idl_test(
+ ['scroll-animations'],
+ // The css-pseudo dependency shouldn't be necessary, but is:
+ // https://github.com/web-platform-tests/wpt/issues/12574
+ ['web-animations', 'css-pseudo', 'dom'],
+ idl_array => {
+ idl_array.add_objects({
+ ScrollTimeline: ['new ScrollTimeline()'],
+ });
+ }
+);
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/intrinsic-iteration-duration.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/intrinsic-iteration-duration.tentative.html
new file mode 100644
index 0000000000..4bcea1adba
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/intrinsic-iteration-duration.tentative.html
@@ -0,0 +1,78 @@
+<!doctype html>
+<meta charset=utf-8>
+<title>Scroll based animation: AnimationEffect.getComputedTiming</title>
+<link rel="help" href="https://www.w3.org/TR/web-animations-2/#dom-animationeffect-getcomputedtiming">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+ .scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+ }
+ .contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+<body>
+<div id="log"></div>
+<script type="text/javascript">
+
+//------------------------------------
+// Time-based duration
+//------------------------------------
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, {duration: 1000 });
+ assert_equals(anim.effect.getTiming().duration, 1000);
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 100);
+}, 'Computed duration in percent even when specified in ms');
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 1000 });
+ anim.rangeStart = { offset: CSS.percent(20) };
+ anim.rangeEnd = { offset: CSS.percent(80) };
+ assert_equals(anim.effect.getTiming().duration, 1000);
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 60);
+}, 'Time-based duration normalized to fill animation range.');
+
+test(t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(
+ t, {duration: 700, delay: 100, endDelay: 200 });
+ assert_equals(anim.effect.getTiming().duration, 700);
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 70);
+}, 'Time-based duration normalized to preserve proportional delays.');
+
+//-------------------------------------------------
+// Duration 'auto' = Intrinsic iteration duration
+//-------------------------------------------------
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, {});
+ assert_equals(anim.effect.getTiming().duration, 'auto');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 100);
+}, 'Intrinsic iteration duration fills timeline.');
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, {});
+ anim.rangeStart = { offset: CSS.percent(30) };
+ anim.rangeEnd = { offset: CSS.percent(90) };
+ assert_equals(anim.effect.getTiming().duration, 'auto');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 60);
+}, 'Intrinsic iteration duration accounts for animation range.');
+
+test(t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(
+ t, { iterations: 4 });
+ assert_equals(anim.effect.getTiming().duration, 'auto');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 25);
+}, 'Intrinsic iteration duration accounts for number of iterations');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/layout-changes-on-percentage-based-timeline.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/layout-changes-on-percentage-based-timeline.html
new file mode 100644
index 0000000000..c5a46a501e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/layout-changes-on-percentage-based-timeline.html
@@ -0,0 +1,84 @@
+<html class="reftest-wait">
+<title>Layout changes on percentage-based scroll timeline</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Scroll timeline should properly handle
+layout changes on percentage-based scroll offset">
+<link rel="match" href="animation-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+ }
+
+ #contents {
+ height: 500px;
+ width: 100%;
+ }
+
+ #spacer {
+ height: 80px;
+ }
+
+ .invisible {
+ display: none;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered">Scrolling Test</div>
+<div id="scroller">
+ <div id="contents"></div>
+ <div id="spacer" class="invisible"></div>
+</div>
+
+<script>
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateY(0)', opacity: 1 },
+ { transform: 'translateY(200px)', opacity: 0 }
+ ]
+ );
+
+ const scroller = document.getElementById('scroller');
+ const timeline = new ScrollTimeline({
+ source: scroller
+ });
+ const animation = new Animation(effect, timeline);
+ animation.play();
+ animation.ready.then(_ => {
+ // Moves the scroller to the end point (240px).
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = maxScroll * 0.6;
+
+ // Makes sure that the animation runs on compositor with current scroll offset
+ waitForAnimationFrames(2).then(_ => {
+ // Adds 80px to scroll height which pushes scroll progress back to 50%.
+ const spacer = document.getElementById('spacer');
+ spacer.classList.remove('invisible');
+ // Makes sure that the change is propagated to the compositor.
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/null-scroll-source-crash.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/null-scroll-source-crash.html
new file mode 100644
index 0000000000..53ad0d9285
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/null-scroll-source-crash.html
@@ -0,0 +1,24 @@
+<link rel="help" href="https://bugs.chromium.org/p/chromium/issues/detail?id=1088319">
+<meta name="assert" content="Playing animation with null scroll source should not crash.">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style>
+ html {
+ overflow: scroll;
+ }
+
+ body {
+ overflow: scroll;
+ }
+</style>
+<div id="box"></div>
+<script>
+ test(() => {
+ const effect = new KeyframeEffect(box, []);
+ const timeline = new ScrollTimeline();
+ const animation = new Animation(effect, timeline);
+ assert_equals(document.scrollingElement, null,
+ "This test relies on scrolling element being nil");
+ animation.play();
+ }, "Playing animation with null scroll source should not crash");
+</script> \ No newline at end of file
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/pause-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/pause-animation.html
new file mode 100644
index 0000000000..1f9641e2f8
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/pause-animation.html
@@ -0,0 +1,178 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Pausing an animation</title>
+<link rel="help"
+ href="https://drafts.csswg.org/web-animations/#pausing-an-animation-section">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ const startTimeBeforePausing = animation.startTime;
+
+ animation.pause();
+ assert_percents_equal(animation.startTime, startTimeBeforePausing,
+ 'The start time does not change when pausing-pending');
+
+ await animation.ready;
+
+ assert_equals(animation.startTime, null,
+ 'The start time is unresolved when paused');
+}, 'Pausing clears the start time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ const promise = animation.ready;
+ animation.pause();
+
+ const promiseResult = await promise;
+
+ assert_equals(promiseResult, animation);
+ assert_equals(animation.ready, promise);
+ assert_false(animation.pending, 'No longer pause-pending');
+}, 'A pending ready promise should be resolved and not replaced when the'
+ + ' animation is paused');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ // Let animation start roughly half-way through
+ animation.currentTime = CSSNumericValue.parse("50%");
+ await animation.ready;
+
+ // Go pause-pending and also set a pending playback rate
+ animation.pause();
+ animation.updatePlaybackRate(0.5);
+
+ await animation.ready;
+ // If the current time was updated using the new playback rate it will jump
+ // back to 25% but if we correctly used the old playback rate the current time
+ // will be > 50%.
+ assert_percents_equal(animation.currentTime, 50);
+}, 'A pause-pending animation maintains the current time when applying a'
+ + ' pending playback rate');
+
+promise_test(async t => {
+ // This test does not cover a specific step in the algorithm but serves as a
+ // high-level sanity check that pausing does, in fact, freeze the current
+ // time.
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.play();
+ await animation.ready;
+
+ animation.pause();
+ await animation.ready;
+
+ const currentTimeAfterPausing = animation.currentTime;
+
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+ assert_percents_equal(animation.timeline.currentTime, 20,
+ 'Sanity check timeline time changed');
+
+ assert_percents_equal(animation.currentTime, currentTimeAfterPausing,
+ 'Animation.currentTime is unchanged after pausing');
+}, 'The animation\'s current time remains fixed after pausing');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+
+ const originalReadyPromise = animation.ready;
+ animation.cancel();
+ assert_equals(animation.startTime, null);
+ assert_equals(animation.currentTime, null);
+
+ const readyPromise = animation.ready;
+ assert_not_equals(originalReadyPromise, readyPromise,
+ 'Canceling an animation should create a new ready promise');
+
+ animation.pause();
+ assert_equals(animation.playState, 'paused',
+ 'Pausing a canceled animation should update the play state');
+ assert_true(animation.pending, 'animation should be pause-pending');
+ await animation.ready;
+ assert_false(animation.pending,
+ 'animation should no longer be pause-pending');
+ assert_equals(animation.startTime, null, 'start time should be unresolved');
+ assert_percents_equal(animation.currentTime, 0,
+ 'current time should be set to zero');
+}, 'Pausing a canceled animation sets the current time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ // Pause the animation when the timeline is inactive.
+ animation.pause();
+ assert_equals(animation.currentTime, null,
+ 'The current time is null when the timeline is inactive.');
+ assert_equals(animation.startTime, null,
+ 'The start time is null in Pending state.');
+ await waitForNextFrame();
+ assert_true(animation.pending,
+ 'Animation has pause pending task while the timeline is inactive.');
+ assert_equals(animation.playState, 'paused',
+ `State is 'paused' in Pending state.`);
+}, `Pause pending task doesn't run when the timeline is inactive.`);
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ // Play the animation when the timeline is inactive.
+ animation.pause();
+
+ // Make the scroll timeline active.
+ scroller.style.overflow = 'auto';
+ await animation.ready;
+ // Ready promise is resolved as a result of the timeline becoming active.
+ assert_percents_equal(animation.currentTime, 20,
+ 'Animation current time is resolved when the animation is ready.');
+ assert_equals(animation.startTime, null,
+ 'Animation start time is unresolved when the animation is ready.');
+}, 'Animation start and current times are correct if scroll timeline is ' +
+ 'activated after animation.pause call.');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/play-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/play-animation.html
new file mode 100644
index 0000000000..7d95eaa257
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/play-animation.html
@@ -0,0 +1,276 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Playing an animation</title>
+<link rel="help"
+ href="https://drafts.csswg.org/web-animations/#playing-an-animation-section">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ assert_equals(animation.startTime, null);
+ await animation.ready;
+ assert_percents_equal(animation.startTime, 0);
+
+ animation.cancel();
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = maxScroll / 2;
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 50);
+
+}, 'Playing an animations aligns the start time with the start of the active ' +
+ 'range');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.playbackRate = -1;
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.startTime, 100);
+}, 'Playing an animations with a negative playback rate aligns the start ' +
+ 'time with the end of the active range');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.startTime = CSSNumericValue.parse("10%");
+ await animation.ready;
+ assert_percents_equal(animation.startTime, 10);
+}, 'Start time set while play pending is preserved.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("10%");
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 10);
+}, 'Current time set while play pending is preserved.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+ animation.currentTime = CSSNumericValue.parse("10%");
+ assert_percents_equal(animation.currentTime, 10);
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 0);
+}, 'Playing a running animation resets a sticky start time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.finish();
+ assert_percents_equal(animation.currentTime, 100);
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 0);
+}, 'Playing a finished animation restarts the animation aligned at the start');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.playbackRate = -1;
+ animation.currentTime = CSSNumericValue.parse("0%");
+ assert_percents_equal(animation.currentTime, 0);
+ animation.play();
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 100);
+}, 'Playing a finished and reversed animation restarts the animation aligned ' +
+ 'at the end');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.finish();
+ await animation.finished;
+
+ // Start time is now sticky since modified by an explicit API call.
+ // All current time calculations will be based on the new start time
+ // while running.
+ assert_percents_equal(animation.startTime, -100,
+ 'start time when finished');
+ assert_percents_equal(animation.currentTime, 100,
+ 'current time when finished');
+
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = maxScroll / 2;
+ await waitForNextFrame();
+ assert_percents_equal(animation.startTime, -100,
+ 'start time after scrolling a finished animation');
+ // Clamped at effect end time.
+ assert_percents_equal(animation.currentTime, 100,
+ 'current time after scrolling a finished animation');
+
+ // Initiate a pause then abort it
+ animation.pause();
+ animation.play();
+
+ // Wait to return to running state
+ await animation.ready;
+
+ assert_percents_equal(animation.startTime, 0,
+ 'After aborting a pause when finished, the start time should no ' +
+ 'longer be sticky');
+ assert_percents_equal(animation.currentTime, 50,
+ 'After aborting a pause when finished, the current time should realign ' +
+ 'with the scroll position');
+}, 'Playing a pause-pending but previously finished animation realigns'
+ + ' with the scroll position');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.finish();
+ await animation.ready;
+
+ animation.play();
+ assert_equals(animation.startTime, null);
+ await animation.ready;
+ assert_percents_equal(animation.startTime, 0, 'start time is zero');
+}, 'Playing a finished animation clears the start time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.cancel();
+ const promise = animation.ready;
+ animation.play();
+ assert_not_equals(animation.ready, promise);
+}, 'The ready promise should be replaced if the animation is not already'
+ + ' pending');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ const promise = animation.ready;
+ const promiseResult = await promise;
+ assert_equals(promiseResult, animation);
+ assert_equals(animation.ready, promise);
+}, 'A pending ready promise should be resolved and not replaced when the'
+ + ' animation enters the running state');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("50%");
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 50);
+
+ animation.pause();
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 50);
+
+ animation.play();
+ await animation.ready;
+
+ assert_percents_equal(animation.startTime, 0);
+}, 'Resuming an animation from paused realigns with scroll position.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ // Go to pause-pending state
+ animation.pause();
+ assert_true(animation.pending, 'Animation is pending');
+ const pauseReadyPromise = animation.ready;
+
+ // Now play again immediately (abort the pause)
+ animation.play();
+ assert_true(animation.pending, 'Animation is still pending');
+ assert_equals(animation.ready, pauseReadyPromise,
+ 'The pause Promise is re-used when playing while waiting to pause');
+
+ // Sanity check: Animation proceeds to running state
+ await animation.ready;
+ assert_true(!animation.pending && animation.playState === 'running',
+ 'Animation is running after aborting a pause');
+}, 'If a pause operation is interrupted, the ready promise is reused');
+
+promise_test(async t => {
+ // Seek animation beyond target end
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("-100%");
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, -100);
+
+ // Set pending playback rate to the opposite direction
+ animation.updatePlaybackRate(-1);
+ assert_true(animation.pending);
+ // Note: Updating the playback rate calls play without rewind. For a
+ // scroll-timeline, this will immediately apply the playback rate.
+ // TODO: Determine if we should defer applying the new playback rate.
+ assert_equals(animation.playbackRate, 1);
+
+ // When we play, we should align to the target end, NOT to zero (which
+ // is where we would seek to if we used the playbackRate of 1.
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.startTime, 100);
+ assert_percents_equal(animation.currentTime, 100);
+}, 'A pending playback rate is used when determining timeline range alignment');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.cancel();
+ assert_equals(animation.startTime, null,
+ 'Start time should be unresolved');
+
+ animation.play();
+ assert_true(animation.pending, 'Animation should be play-pending');
+
+ await animation.ready;
+
+ assert_false(animation.pending, 'animation should no longer be pending');
+ assert_percents_equal(animation.startTime, 0,
+ 'The start time of the playing animation should be zero');
+}, 'Playing a canceled animation sets the start time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.playbackRate = -1;
+ animation.cancel();
+ assert_equals(animation.startTime, null, 'Start time should be unresolved');
+
+ animation.play();
+ assert_true(animation.pending, 'Animation should be play-pending');
+
+ await animation.ready;
+
+ assert_false(animation.pending, 'Animation should no longer be pending');
+ assert_percents_equal(animation.startTime, 100,
+ 'The start time of the playing animation should be set');
+}, 'Playing a canceled animation backwards sets the start time');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay-ref.html
new file mode 100644
index 0000000000..59366a88dd
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay-ref.html
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<title>Reference for Web Animation with scroll timeline and effect delay tests</title>
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translate(0, 100px);
+ opacity: 0.5;
+ will-change: transform; /* force compositing */
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"></div>
+</div>
+
+<script>
+ window.addEventListener('load', function() {
+ // Move the scroller to halfway.
+ const scroller = document.getElementById("scroller");
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.75 * maxScroll;
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay.tentative.html
new file mode 100644
index 0000000000..525d8448ff
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay.tentative.html
@@ -0,0 +1,69 @@
+<html class="reftest-wait">
+<title>Animation effect delays should be accounted for when using a progress based timeline</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Effect delay should be accounted for by progress based animations">
+<link rel="match" href="progress-based-effect-delay-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"><p>Scrolling Contents</p></div>
+</div>
+<script>
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateY(0)', opacity: 1},
+ { transform: 'translateY(200px)', opacity: 0}
+ ], {
+ delay: 1000,
+ duration: 1000
+ }
+ );
+
+ const scroller = document.getElementById('scroller');
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const animation = new Animation(effect, timeline);
+
+ animation.play();
+
+ animation.ready.then(() => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.75 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/reverse-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/reverse-animation.html
new file mode 100644
index 0000000000..1054ed3983
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/reverse-animation.html
@@ -0,0 +1,164 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Reversing an animation</title>
+<link rel="help"
+ href="https://drafts.csswg.org/web-animations/#reversing-an-animation-section">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.reverse();
+ animation.currentTime = CSSNumericValue.parse("40%");
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 40);
+}, 'Setting current time while reverse-pending preserves currentTime');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ animation.currentTime = CSSNumericValue.parse("50%");
+ const previousPlaybackRate = animation.playbackRate;
+ animation.reverse();
+ assert_equals(animation.playbackRate, previousPlaybackRate,
+ 'Playback rate should not have changed');
+ await animation.ready;
+
+ assert_equals(animation.playbackRate, -previousPlaybackRate,
+ 'Playback rate should be inverted');
+}, 'Reversing an animation inverts the playback rate');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("40%");
+ await animation.ready;
+ assert_percents_equal(animation.startTime, -40);
+ assert_percents_equal(animation.currentTime, 40);
+
+ animation.reverse();
+ await animation.ready;
+ assert_percents_equal(animation.startTime, 100);
+ assert_percents_equal(animation.currentTime, 100);
+}, 'Reversing an animation resets a sticky start time.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ assert_true(animation.pending,
+ 'The animation is pending before we call reverse');
+
+ animation.reverse();
+
+ assert_true(animation.pending,
+ 'The animation is still pending after calling reverse');
+}, 'Reversing an animation does not cause it to leave the pending state');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ let readyResolved = false;
+ animation.ready.then(() => { readyResolved = true; });
+
+ animation.reverse();
+
+ await Promise.resolve();
+ assert_false(readyResolved,
+ 'ready promise should not have been resolved yet');
+}, 'Reversing an animation does not cause it to resolve the ready promise');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.playbackRate = -1;
+ animation.reverse();
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 0);
+}, 'Reversing an animation with a negative playback rate should cause ' +
+ 'the animation to play in a forward direction');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.playbackRate = 0;
+ animation.currentTime = CSSNumericValue.parse("50%");
+ animation.reverse();
+
+ await animation.ready;
+ assert_equals(animation.playbackRate, 0,
+ 'reverse() should preserve playbackRate if the playbackRate == 0');
+ assert_percents_equal(animation.currentTime, 0,
+ 'Anchors to range start boundary when playback rate is zero');
+}, 'Reversing when when playbackRate == 0 should preserve the playback rate');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ assert_equals(animation.currentTime, null);
+
+ animation.reverse();
+ await animation.ready;
+
+ assert_percents_equal(animation.startTime, 100,
+ 'animation.startTime should be at its effect end');
+ assert_percents_equal(animation.currentTime, 100,
+ 'animation.currentTime should be at its effect end');
+}, 'Reversing an idle animation aligns startTime with the rangeEnd boundary');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ await waitForNextFrame();
+
+ assert_throws_dom('InvalidStateError', () => { animation.reverse(); });
+}, 'Reversing an animation without an active timeline throws an ' +
+ 'InvalidStateError');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("50%");
+ animation.pause();
+ await animation.ready;
+
+ animation.reverse();
+ assert_equals(animation.playState, 'running',
+ 'Animation.playState should be "running" after reverse()');
+}, 'Reversing an animation plays a pausing animation');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ animation.updatePlaybackRate(2);
+ animation.reverse();
+
+ await animation.ready;
+ assert_equals(animation.playbackRate, -2);
+}, 'Reversing should use the negative pending playback rate');
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-fill-modes.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-fill-modes.tentative.html
new file mode 100644
index 0000000000..b9cc154676
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-fill-modes.tentative.html
@@ -0,0 +1,137 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Verify applied effect output for all fill modes in all timeline states: before start, at start, in range, at end, after end while using various effect delay values</title>
+<meta name="timeout" content="long">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+ .scroller {
+ overflow: hidden;
+ height: 200px;
+ width: 200px;
+ }
+ .contents {
+ /* Make scroll range 1000 to simplify the math and avoid rounding errors */
+ height: 1200px;
+ width: 100%;
+ }
+</style>
+<div id="log"></div>
+<script>
+ 'use strict';
+
+ test(t => {
+ const effect = new KeyframeEffect(createDiv(t), { opacity: [0.3, 0.7] });
+ const animation = new Animation(effect, createScrollTimeline(t));
+
+ assert_equals(effect.getTiming().fill, "auto");
+ assert_equals(effect.getComputedTiming().fill, "none");
+ }, "Scroll based animation effect fill mode should return 'auto' for" +
+ " getTiming() and should return 'none' for getComputedTiming().")
+
+ /* All interesting transitions:
+ before start delay
+ at start delay
+ within active phase
+ at effect end
+ after effect end
+
+ test_case data structure:
+ fill_mode: {
+ scroll_percentage: ["state description", expected applied effect value]
+ }
+ */
+ const test_cases = {
+ "none": {
+ 0.10: ["before start delay", 1],
+ 0.25: ["at start delay", 0.3],
+ 0.50: ["at midpoint", 0.5],
+ 0.75: ["at effect end", 1],
+ 0.90: ["after effect end", 1]
+ },
+ "backwards": {
+ 0.10: ["before start delay", 0.3],
+ 0.25: ["at start delay", 0.3],
+ 0.50: ["at midpoint", 0.5],
+ 0.75: ["at effect end", 1],
+ 0.90: ["after effect end", 1]
+ },
+ "forwards": {
+ 0.10: ["before timeline start", 1],
+ 0.25: ["at timeline start", 0.3],
+ 0.50: ["in timeline range", 0.5],
+ 0.75: ["at timeline end", 0.7],
+ 0.90: ["after timeline end", 0.7]
+ },
+ "both": {
+ 0.10: ["before timeline start", 0.3],
+ 0.25: ["at timeline start", 0.3],
+ 0.50: ["in timeline range", 0.5],
+ 0.75: ["at timeline end", 0.7],
+ 0.90: ["after timeline end", 0.7]
+ },
+ "auto": {
+ 0.10: ["before timeline start", 1],
+ 0.25: ["at timeline start", 0.3],
+ 0.50: ["in timeline range", 0.5],
+ 0.75: ["at timeline end", 1],
+ 0.90: ["after timeline end", 1]
+ }
+ }
+
+ for (const fill_mode in test_cases) {
+ const scroll_percents = test_cases[fill_mode]
+
+ for (const scroll_percentage in scroll_percents) {
+ const expectation = scroll_percents[scroll_percentage];
+
+ const [test_name, expected_value] = expectation;
+
+ const description =
+ `Applied effect value ${test_name} with fill: ${fill_mode}`
+ promise_test(
+ create_scroll_timeline_fill_test(
+ fill_mode, scroll_percentage, expected_value),
+ description);
+ }
+ }
+
+ function create_scroll_timeline_fill_test(
+ fill_mode, scroll_percentage, expected){
+ return async t => {
+ const target = createDiv(t);
+
+ const timeline = createScrollTimeline(t);
+ const effect =
+ new KeyframeEffect(target,
+ { opacity: [0.3, 0.7] },
+ {
+ fill: fill_mode,
+ /* Animation times normalized to fill scroll
+ range */
+ duration: 2000,
+ delay: 1000,
+ endDelay: 1000
+ });
+ const animation = new Animation(effect, timeline);
+ const scroller = timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+
+ await animation.ready;
+
+ scroller.scrollTop = scroll_percentage * maxScroll;
+
+ // Wait for new animation frame which allows the timeline to compute
+ // new current time.
+ await waitForNextFrame();
+
+ assert_equals(parseFloat(window.getComputedStyle(target).opacity),
+ expected,
+ "animation effect applied property value");
+ }
+ }
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-phases.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-phases.tentative.html
new file mode 100644
index 0000000000..41ae0e0612
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-phases.tentative.html
@@ -0,0 +1,555 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Verify timeline time, animation time, effect time, and effect progress for all timeline states: before start, at start, in range, at end, after end while using various effect delay values</title>
+<meta name="timeout" content="long">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+ .scroller {
+ overflow: hidden;
+ height: 200px;
+ width: 200px;
+ }
+ .contents {
+ /* Make scroll range 1000 to simplify the math and avoid rounding errors */
+ height: 1200px;
+ width: 100%;
+ }
+</style>
+<div id="log"></div>
+<script>
+ 'use strict';
+ // Note: effects are scaled to fill the timeline.
+
+ // Each entry is [[test input], [test expectations]]
+ // test input = ["description", delay, end_delay, scroll percent]
+ // test expectations = [timeline time, animation current time,
+ // effect local time, effect progress, effect phase,
+ // opacity]
+
+ /* All interesting transitions:
+ at timeline start
+ before effect delay
+ at effect start
+ in active range
+ at effect end
+ after effect end
+ at timeline end
+ */
+ const test_cases = [
+ // Case 1: No delays.
+ // Boundary at end of active phase is inclusive.
+ [
+ ["at start", 0, 0, 0],
+ [0, 0, 0, 0, "active", 0.3]
+ ],
+ [
+ ["in active range", 0, 0, 0.50],
+ [50, 50, 50, 0.5, "active", 0.5]
+ ],
+ [
+ ["at effect end time", 0, 0, 1.0],
+ [100, 100, 100, 1.0, "active", 0.7]
+ ],
+
+ // Case 2: Positive start delay and no end delay.
+ // Boundary at end of active phase is inclusive.
+ [
+ ["at timeline start", 500, 0, 0],
+ [0, 0, 0, null, "before", 1]
+ ],
+ [
+ ["before start delay", 500, 0, 0.25],
+ [25, 25, 25, null, "before", 1]
+ ],
+ [
+ ["at start delay", 500, 0, 0.5],
+ [50, 50, 50, 0, "active", 0.3]
+ ],
+ [
+ ["in active range", 500, 0, 0.75],
+ [75, 75, 75, 0.5, "active", 0.5]
+ ],
+ [
+ ["at effect end time", 500, 0, 1.0],
+ [100, 100, 100, 1.0, "active", 0.7]
+ ],
+
+ // case 3: No start delay, Positive end delay.
+ // Boundary at end of active phase is exclusive.
+ [
+ ["at timeline start", 0, 500, 0],
+ [0, 0, 0, 0, "active", 0.3]
+ ],
+ [
+ ["in active range", 0, 500, 0.25],
+ [25, 25, 25, 0.5, "active", 0.5]
+ ],
+ [
+ ["at effect end time", 0, 500, 0.5],
+ [50, 50, 50, null, "after", 1.0]
+ ],
+ [
+ ["after effect end time", 0, 500, 0.75],
+ [75, 75, 75, null, "after", 1.0]
+ ],
+ [
+ ["at timeline boundary", 0, 500, 1.0],
+ [100, 100, 100, null, "after", 1.0]
+ ],
+
+ // case 4: Positive start and end delays.
+ // Boundary at end of active phase is exclusive.
+ [
+ ["at timeline start", 250, 250, 0],
+ [0, 0, 0, null, "before", 1]
+ ],
+ [
+ ["before start delay", 250, 250, 0.1],
+ [10, 10, 10, null, "before", 1]
+ ],
+ [
+ ["at start delay", 250, 250, 0.25],
+ [25, 25, 25, 0, "active", 0.3]
+ ],
+ [
+ ["in active range", 250, 250, 0.5],
+ [50, 50, 50, 0.5, "active", 0.5]
+ ],
+ [
+ ["at effect end time", 250, 250, 0.75],
+ [75, 75, 75, null, "after", 1.0]
+ ],
+ [
+ ["after effect end time", 250, 250, 0.9],
+ [90, 90, 90, null, "after", 1.0]
+ ],
+ [
+ ["at timeline boundary", 250, 250, 1.0],
+ [100, 100, 100, null, "after", 1.0]
+ ],
+
+ // Case 5: Negative start and end delays.
+ // Effect boundaries are not reachable.
+ [
+ ["at timeline start", -125, -125, 0],
+ [0, 0, 0, 0.25, "active", 0.4]
+ ],
+ [
+ ["in active range", -125, -125, 0.5],
+ [50, 50, 50, 0.5, "active", 0.5]
+ ],
+ [
+ ["at timeline end", -125, -125, 1.0],
+ [100, 100, 100, 0.75, "active", 0.6]
+ ]
+ ];
+
+ for (const test_case of test_cases) {
+ const [inputs, expected] = test_case;
+ const [test_name, delay, end_delay, scroll_percentage] = inputs;
+
+ const description = `Current times and effect phase ${test_name} when` +
+ ` delay = ${delay} and endDelay = ${end_delay} |`;
+
+ promise_test(
+ create_scroll_timeline_delay_test(
+ delay, end_delay, scroll_percentage, expected),
+ description);
+ }
+
+ function create_scroll_timeline_delay_test(
+ delay, end_delay, scroll_percentage, expected){
+ return async t => {
+ const target = createDiv(t);
+ const timeline = createScrollTimeline(t);
+ const effect = new KeyframeEffect(
+ target,
+ {
+ opacity: [0.3, 0.7]
+ },
+ {
+ duration: 500,
+ delay: delay,
+ endDelay: end_delay
+ }
+ );
+ const animation = new Animation(effect, timeline);
+ t.add_cleanup(() => {
+ animation.cancel();
+ });
+ const scroller = timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+
+ await animation.ready;
+
+ scroller.scrollTop = scroll_percentage * maxScroll;
+
+ // Wait for new animation frame which allows the timeline to compute
+ // new current time.
+ await waitForNextFrame();
+
+ const [expected_timeline_current_time,
+ expected_animation_current_time,
+ expected_effect_local_time,
+ expected_effect_progress,
+ expected_effect_phase,
+ expected_opacity] = expected;
+
+ assert_percents_equal(
+ animation.timeline.currentTime,
+ expected_timeline_current_time,
+ "timeline current time");
+ assert_percents_equal(
+ animation.currentTime,
+ expected_animation_current_time,
+ "animation current time");
+ assert_percents_equal(
+ animation.effect.getComputedTiming().localTime,
+ expected_effect_local_time,
+ "animation effect local time");
+ assert_approx_equals_or_null(
+ animation.effect.getComputedTiming().progress,
+ expected_effect_progress,
+ 0.001,
+ "animation effect progress");
+ assert_phase(
+ animation, expected_effect_phase);
+ assert_approx_equals(
+ parseFloat(getComputedStyle(target).opacity), expected_opacity,
+ 0.001,
+ 'target opacity');
+ }
+ }
+
+ function createKeyframeEffectOpacity(test){
+ return new KeyframeEffect(
+ createDiv(test),
+ {
+ opacity: [0.3, 0.7]
+ },
+ {
+ duration: 1000
+ }
+ );
+ }
+
+ function verifyEffectBeforePhase(animation) {
+ // If currentTime is null, we are either idle, or running with an
+ // inactive timeline. Either way, the animation is not in effect and cannot
+ // be in the before phase.
+ assert_true(animation.currentTime != null,
+ 'Animation is not in effect');
+
+ const fillMode = animation.effect.getTiming().fill;
+ animation.effect.updateTiming({ fill: 'none' });
+
+ // progress == null AND opacity == 1 implies we are in the effect before
+ // or after phase.
+ assert_equals(animation.effect.getComputedTiming().progress, null);
+ assert_equals(
+ window.getComputedStyle(animation.effect.target)
+ .getPropertyValue("opacity"),
+ "1");
+
+ // If the progress is no longer null after adding fill: backwards, then we
+ // are in the before phase.
+ animation.effect.updateTiming({ fill: 'backwards' });
+ assert_true(animation.effect.getComputedTiming().progress != null);
+ assert_equals(
+ window.getComputedStyle(animation.effect.target)
+ .getPropertyValue("opacity"),
+ "0.3");
+
+ // Reset fill mode to avoid side-effects.
+ animation.effect.updateTiming({ fill: fillMode });
+ }
+
+ function createScrollLinkedOpacityAnimationWithDelays(t) {
+ const animation = new Animation(
+ createKeyframeEffectOpacity(t),
+ createScrollTimeline(t)
+ );
+ t.add_cleanup(() => {
+ animation.cancel();
+ });
+ animation.effect.updateTiming({
+ duration: 1000,
+ delay: 500,
+ endDelay: 500
+ });
+ return animation;
+ }
+
+
+ promise_test(async t => {
+ const animation = createScrollLinkedOpacityAnimationWithDelays(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // scroll pos
+ // current time
+ // start time
+ // |
+ // |---- 25% before ----|---- 50% active ----|---- 25% after ----|
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.startTime, 0);
+ assert_phase(animation, 'before');
+
+ // start time scroll pos
+ // | current time
+ // | |
+ // |---- 25% before ----|---- 50% active ----|---- 25% after ----|
+ scroller.scrollTop = 0.5 * maxScroll;
+ await waitForNextFrame();
+ assert_phase(animation, 'active');
+
+ // start time scroll pos current time
+ // | | |
+ // |---- 25% before ----|---- 50% active ----|---- 25% after ----|
+ animation.playbackRate = 2;
+ assert_phase(animation, 'after');
+
+ // start time scroll pos current time
+ // | | |
+ // |---- 33.3% before ----|---- 66.7% active ---------------------|
+ animation.effect.updateTiming({ endDelay: 0 });
+ assert_phase(animation, 'active');
+
+ // scroll pos start time
+ // current time |
+ // | |
+ // |---- 33.3% before ----|---- 66.7% active ----------------------|
+ animation.playbackRate = -1;
+ assert_percents_equal(animation.startTime, 100);
+ assert_phase(animation, 'active');
+
+ // start time
+ // scroll pos current time
+ // | | |
+ // |---- 33.3% before ----|---- 66.7% active -----------------------|
+ animation.playbackRate = -2;
+ assert_phase(animation, 'active');
+
+ // current time start time
+ // | scroll pos
+ // | |
+ // |---- 33.3% before ----|---- 66.7% active -----------------------|
+ scroller.scrollTop = maxScroll;
+ await waitForNextFrame();
+ assert_phase(animation, 'before');
+
+ // current time start time
+ // | scroll pos
+ // | |
+ // |--------------------- 100% active -------------------------------|
+ animation.effect.updateTiming({ delay: 0 });
+ assert_phase(animation, 'active');
+
+ // Finally, switch to a document timeline. The before-active boundary
+ // becomes exclusive.
+ animation.timeline = document.timeline;
+ animation.currentTime = 0;
+ await waitForNextFrame();
+ assert_phase(animation, 'before');
+
+ }, 'Playback rate affects whether active phase boundary is inclusive.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedOpacityAnimationWithDelays(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ await animation.ready;
+ verifyEffectBeforePhase(animation);
+
+ animation.pause();
+ await waitForNextFrame();
+ verifyEffectBeforePhase(animation);
+
+ animation.play();
+ await waitForNextFrame();
+
+ verifyEffectBeforePhase(animation);
+ }, 'Verify that (play -> pause -> play) doesn\'t change phase/progress.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedOpacityAnimationWithDelays(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ await animation.ready;
+ verifyEffectBeforePhase(animation);
+
+ animation.pause();
+ await animation.ready;
+ verifyEffectBeforePhase(animation);
+
+ // Scrolling should not cause the animation effect to change.
+ scroller.scrollTop = 0.5 * maxScroll;
+ await waitForNextFrame();
+
+ // Check timeline phase
+ assert_percents_equal(animation.timeline.currentTime, 50);
+ assert_percents_equal(animation.currentTime, 0);
+ assert_percents_equal(animation.effect.getComputedTiming().localTime, 0,
+ "effect local time");
+
+ // Make sure the effect is still in the before phase even though the
+ // timeline is not.
+ verifyEffectBeforePhase(animation);
+ }, 'Pause in before phase, scroll timeline into active phase, animation ' +
+ 'should remain in the before phase');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedOpacityAnimationWithDelays(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ await animation.ready;
+ verifyEffectBeforePhase(animation);
+
+ animation.pause();
+ await waitForNextFrame();
+ verifyEffectBeforePhase(animation);
+
+ // Setting the current time should force the animation into effect.
+ const expected_time = 50;
+ animation.currentTime = CSS.percent(expected_time);
+ await waitForNextFrame();
+ assert_percents_equal(animation.timeline.currentTime, 0);
+ assert_percents_equal(animation.currentTime, expected_time,
+ 'Current time matches set value');
+ assert_percents_equal(
+ animation.effect.getComputedTiming().localTime,
+ expected_time, "Effect local time after setting animation.currentTime");
+ assert_equals(animation.effect.getComputedTiming().progress, 0.5,
+ "Progress after setting animation.currentTime");
+ assert_equals(
+ window.getComputedStyle(animation.effect.target)
+ .getPropertyValue("opacity"),
+ "0.5", "Opacity after setting animation.currentTime");
+
+ // Scrolling should not cause the animation effect to change since
+ // paused.
+ scroller.scrollTop = 0.75 * maxScroll; // scroll so that timeline is 75%
+ await waitForNextFrame();
+ assert_percents_equal(animation.timeline.currentTime, 75);
+
+ // animation and effect timings are unchanged.
+ assert_percents_equal(animation.currentTime, expected_time,
+ "Current time after scrolling while paused");
+ assert_percents_equal(
+ animation.effect.getComputedTiming().localTime,
+ expected_time,
+ "Effect local time after scrolling while paused");
+ assert_equals(animation.effect.getComputedTiming().progress, 0.5,
+ "Progress after scrolling while paused");
+ assert_equals(
+ window.getComputedStyle(animation.effect.target)
+ .getPropertyValue("opacity"),
+ "0.5", "Opacity after scrolling while paused");
+ }, 'Pause in before phase, set animation current time to be in active ' +
+ 'range, animation should become active. Scrolling should have no effect.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedOpacityAnimationWithDelays(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ await animation.ready;
+
+ // Causes the timeline to be inactive
+ scroller.style.overflow = "visible";
+ await waitForNextFrame();
+ await waitForNextFrame();
+
+ // Verify that he timeline is inactive
+ assert_equals(animation.timeline.currentTime, null,
+ "Timeline is inactive");
+ assert_equals(
+ animation.currentTime, null,
+ "Current time for running animation with an inactive timeline");
+ assert_equals(animation.effect.getComputedTiming().localTime, null,
+ "effect local time with inactive timeline");
+
+ // Setting the current time while timeline is inactive should pause the
+ // animation at the specified time.
+ animation.currentTime = CSS.percent(50);
+ await waitForNextFrame();
+ await waitForNextFrame();
+
+ // Verify that animation currentTime is properly set despite the inactive
+ // timeline.
+ assert_equals(animation.timeline.currentTime, null);
+ assert_percents_equal(animation.currentTime, 50);
+ assert_percents_equal(animation.effect.getComputedTiming().localTime, 50,
+ "effect local time after setting animation current time");
+
+ // Check effect phase
+ // progress == 0.5 AND opacity == 0.5 shows we are in the effect active
+ // phase.
+ assert_equals(animation.effect.getComputedTiming().progress, 0.5,
+ "effect progress");
+ assert_equals(
+ window.getComputedStyle(animation.effect.target)
+ .getPropertyValue("opacity"),
+ "0.5",
+ "effect opacity after setting animation current time");
+ }, 'Make scroller inactive, then set current time to an in range time');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedOpacityAnimationWithDelays(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ // Update timeline.currentTime.
+ await waitForNextFrame();
+
+ animation.pause();
+ await animation.ready;
+ // verify effect is applied.
+ const expected_progress = 0.5;
+ assert_equals(
+ animation.effect.getComputedTiming().progress,
+ expected_progress,
+ "Verify effect progress after pausing.");
+
+ // cause the timeline to become inactive
+ scroller.style.overflow = 'visible';
+ await waitForAnimationFrames(2);
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ assert_equals(
+ animation.effect.getComputedTiming().progress,
+ expected_progress,
+ "Verify effect progress after the timeline goes inactive.");
+ }, 'Animation effect is still applied after pausing and making timeline ' +
+ 'inactive.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedOpacityAnimationWithDelays(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ await animation.ready;
+
+ // cause the timeline to become inactive
+ scroller.style.overflow = 'visible';
+
+ scroller.scrollTop;
+
+ animation.pause();
+ }, 'Make timeline inactive, force style update then pause the animation. ' +
+ 'No crashing indicates test success.');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-inactive-timeline.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-inactive-timeline.html
new file mode 100644
index 0000000000..02220cee14
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-inactive-timeline.html
@@ -0,0 +1,170 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Test basic functionality of scroll linked animation.</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+ .scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+ }
+
+ .contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+<div id="log"></div>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+
+ // Ensure we have a valid animation frame time before continuing the test.
+ // This is so that we can properly determine frame advancement after the
+ // style change.
+ await waitForNextFrame();
+
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ // Play the animation when the timeline is inactive.
+ animation.play();
+ assert_equals(animation.currentTime, null,
+ 'The current time is null when the timeline is inactive.');
+ assert_equals(animation.startTime, null,
+ 'The start time is unresolved while play-pending.');
+ await waitForNextFrame();
+ assert_true(animation.pending,
+ 'Animation has play pending task while timeline is inactive.');
+ assert_equals(animation.playState, 'running',
+ `State is 'running' in Pending state.`);
+}, `Play pending task doesn't run when the timeline is inactive.`);
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+
+ await waitForNextFrame();
+
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ // Play the animation when the timeline is inactive.
+ animation.play();
+
+ // Make the scroll timeline active.
+ scroller.style.overflow = 'auto';
+ await animation.ready;
+ // Ready promise is resolved as a result of the timeline becoming active.
+ assert_percents_equal(animation.currentTime, 0,
+ 'Animation current time is resolved when the animation is ready.');
+ assert_percents_equal(animation.startTime, 0,
+ 'Animation start time is resolved when the animation is ready.');
+}, 'Animation start and current times are correct if scroll timeline is ' +
+ 'activated after animation.play call.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const target = animation.effect.target;
+
+ await waitForNextFrame();
+
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ // Set start time when the timeline is inactive.
+ animation.startTime = CSSNumericValue.parse("0%");
+ assert_equals(animation.currentTime, null,
+ 'Sanity check current time is unresolved when the timeline ' +
+ 'is inactive.');
+
+ // Make the scroll timeline active.
+ scroller.style.overflow = 'auto';
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(animation.currentTime, 0,
+ 'Animation current time is resolved when the timeline is active.');
+ assert_percents_equal(animation.startTime, 0,
+ 'Animation start time is resolved.');
+ assert_percents_equal(animation.effect.getComputedTiming().localTime, 0,
+ 'Effect local time is resolved when the timeline is active.');
+ assert_equals(Number(getComputedStyle(target).opacity), 0,
+ 'Animation has an effect when the timeline is active.');
+}, 'Animation start and current times are correct if scroll timeline is ' +
+ 'activated after setting start time.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ const target = animation.effect.target;
+
+ await waitForNextFrame();
+
+ // Advance the scroller.
+ scroller.scrollTop = 0.2 * maxScroll;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ // Play the animation when the timeline is active.
+ animation.play();
+ await animation.ready;
+
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ assert_equals(animation.playState, 'running',
+ `State is 'running' when the timeline is inactive.`);
+ assert_equals(animation.currentTime, null,
+ 'Current time is unresolved when the timeline is inactive.');
+ assert_percents_equal(animation.startTime, 0,
+ 'Start time is zero when the timeline is inactive.');
+ assert_equals(animation.effect.getComputedTiming().localTime, null,
+ 'Effect local time is null when the timeline is inactive.');
+ assert_equals(Number(getComputedStyle(target).opacity), 1,
+ 'Animation does not have an effect when the timeline is inactive.');
+
+ // Make the scroll timeline active.
+ scroller.style.overflow = 'auto';
+ await waitForNextFrame();
+
+ assert_equals(animation.playState, 'running',
+ `State is 'running' when the timeline is active.`);
+ assert_percents_equal(animation.currentTime, 20,
+ 'Current time is resolved when the timeline is active.');
+ assert_percents_equal(animation.startTime, 0,
+ 'Start time is zero when the timeline is active.');
+ assert_percents_equal(animation.effect.getComputedTiming().localTime, 20,
+ 'Effect local time is resolved when the timeline is active.');
+ assert_equals(Number(getComputedStyle(target).opacity), 0.2,
+ 'Animation has an effect when the timeline is active.');
+}, 'Animation current time is correct when the timeline becomes newly ' +
+ 'inactive and then active again.');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation.html
new file mode 100644
index 0000000000..e3544762f6
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation.html
@@ -0,0 +1,160 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Test basic functionality of scroll linked animation.</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+ .scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+ }
+ .contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+<div id="log"></div>
+<script>
+ 'use strict';
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Verify initial start and current times in Idle state.
+ assert_equals(animation.currentTime, null,
+ "The current time is null in Idle state.");
+ assert_equals(animation.startTime, null,
+ "The start time is null in Idle state.");
+ animation.play();
+ assert_true(animation.pending, "Animation is in the pending state.");
+ // Verify initial start and current times in the pending state.
+ assert_equals(animation.currentTime, null,
+ "The current time remains null while in the pending state.");
+ assert_equals(animation.startTime, null,
+ "The start time remains null while in the pending state.");
+
+ await animation.ready;
+ // Verify initial start and current times once ready.
+ assert_percents_equal(animation.currentTime, 0,
+ "The current time is resolved when ready.");
+ assert_percents_equal(animation.startTime, 0,
+ "The start time is resolved when ready.");
+
+ // Now do some scrolling and make sure that the Animation current time is
+ // correct.
+ scroller.scrollTop = 0.4 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_percents_equal(animation.currentTime, animation.timeline.currentTime,
+ "The current time corresponds to the scroll position of the scroller.");
+ assert_times_equal(
+ animation.effect.getComputedTiming().progress,
+ animation.timeline.currentTime.value / 100,
+ 'Effect progress corresponds to the scroll position of the scroller.');
+}, 'Animation start and current times are correct for each animation state.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Advance the scroller.
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ // Verify initial start and current times in Idle state.
+ assert_equals(animation.currentTime, null,
+ "The current time is null in Idle state.");
+ assert_equals(animation.startTime, null,
+ "The start time is null in Idle state.");
+ animation.play();
+ // Verify initial start and current times in Pending state.
+ assert_equals(animation.currentTime, null,
+ "The current time remains unresolved while play-pending.");
+ assert_equals(animation.startTime, null,
+ "The start time remains unresolved while play-pending.");
+
+ await animation.ready;
+ // Verify initial start and current times in Playing state.
+ assert_percents_equal(animation.currentTime, animation.timeline.currentTime,
+ "The current corresponds to the scroll position of the scroller.");
+ assert_percents_equal(animation.startTime, 0,
+ "The start time is zero in Playing state.");
+}, 'Animation start and current times are correct for each animation state' +
+ ' when the animation starts playing with advanced scroller.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ await animation.ready;
+ // Advance the scroller to max position.
+ scroller.scrollTop = maxScroll;
+
+ await animation.finished;
+
+ assert_equals(animation.playState, 'finished',
+ 'Animation state is in finished state.');
+ assert_percents_equal(animation.currentTime, 100,
+ 'Animation current time is at 100% on reverse scrolling.');
+
+ // Scroll back.
+ scroller.scrollTop = 0.2 * maxScroll;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ // Verify animation state and current time on reverse scrolling.
+ assert_equals(animation.playState, 'running',
+ 'Animation state is playing on reverse scrolling.');
+ assert_percents_equal(animation.currentTime, 20,
+ 'Animation current time is updated on reverse scrolling.');
+}, 'Finished animation plays on reverse scrolling.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ await animation.ready;
+
+ // Advance the scroller to max position.
+ scroller.scrollTop = maxScroll;
+ await animation.finished;
+
+ var sent_finish_event = false;
+ animation.onfinish = function() {
+ sent_finish_event = true;
+ };
+
+ // Scroll back.
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_false(sent_finish_event,
+ "No animation finished event is sent on reverse scroll.");
+
+ scroller.scrollTop = maxScroll;
+ await animation.finished;
+
+ // Wait for next frame to allow the animation to send finish events. The
+ // finished promise fires before events are sent.
+ await waitForNextFrame();
+
+ assert_true(sent_finish_event,
+ "Animation finished event is sent on reaching max scroll.");
+}, 'Sending animation finished events by finished animation on reverse ' +
+ 'scrolling.');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-in-removed-iframe-crash.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-in-removed-iframe-crash.html
new file mode 100644
index 0000000000..07edbd83f1
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-in-removed-iframe-crash.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Starting an animation with a scroll timeline in a removed iframe
+ should not crash</title>
+</head>
+<body>
+ <div id="target"></div>
+ <iframe id="frame"></iframe>
+</body>
+<script type="text/javascript">
+ const target = document.getElementById('target');
+ const frame = document.getElementById('frame');
+ const timeline = new frame.contentWindow.ScrollTimeline();
+ frame.remove();
+ target.animate(null, {timeline: timeline});
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-invalidation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-invalidation.html
new file mode 100644
index 0000000000..a26500989e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-invalidation.html
@@ -0,0 +1,133 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>ScrollTimeline invalidation</title>
+<link rel="help" href="https://wicg.github.io/scroll-animations/#current-time-algorithm">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+ .scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+ }
+ .contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+<div id="log"></div>
+
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const effect_duration = 350;
+ animation.effect.updateTiming({ duration: effect_duration });
+ const scroller = animation.timeline.source;
+ let maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.2 * maxScroll;
+ const initial_progress = (scroller.scrollTop / maxScroll) * 100;
+
+ animation.play();
+ await animation.ready;
+
+ // Animation current time is at 20% because scroller was scrolled to 20%
+ assert_percents_equal(animation.currentTime, 20);
+ assert_equals(scroller.scrollTop, 180);
+ assert_equals(maxScroll, 900);
+
+ // Shrink scroller content size (from 1000 to 500).
+ // scroller.scrollTop maintains the same offset, which means shrinking the
+ // content has the effect of skipping the animation forward.
+ scroller.firstChild.style.height = "500px";
+ maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ assert_equals(scroller.scrollTop, 180);
+ assert_equals(maxScroll, 400);
+ await waitForNextFrame();
+
+ const expected_progress = (scroller.scrollTop / maxScroll) * 100;
+ assert_true(expected_progress > initial_progress)
+ // @ 45%
+ assert_percents_equal(animation.currentTime, expected_progress);
+ assert_percents_equal(animation.timeline.currentTime, expected_progress);
+ assert_percents_equal(animation.effect.getComputedTiming().localTime, expected_progress);
+}, 'Animation current time and effect local time are updated after scroller ' +
+ 'content size changes.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.effect.updateTiming({ duration: 350 });
+ const scroller = animation.timeline.source;
+ let maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ const scrollOffset = 0.2 * maxScroll
+ scroller.scrollTop = scrollOffset;
+ const initial_progress = (scroller.scrollTop / maxScroll) * 100;
+
+ animation.play();
+ await animation.ready;
+
+ // Animation current time is at 20% because scroller was scrolled to 20%
+ // assert_equals(animation.currentTime.value, 20);
+ assert_percents_equal(animation.currentTime, 20);
+ assert_equals(scroller.scrollTop, scrollOffset);
+ assert_equals(maxScroll, 900);
+
+ // Change scroller size.
+ scroller.style.height = "500px";
+ maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ assert_equals(scroller.scrollTop, scrollOffset);
+ assert_equals(maxScroll, 500);
+ await waitForNextFrame();
+
+ const expected_progress = (scroller.scrollTop / maxScroll) * 100;
+ assert_true(expected_progress > initial_progress);
+ // @ 45%
+ assert_percents_equal(animation.currentTime, expected_progress);
+ assert_percents_equal(animation.timeline.currentTime, expected_progress);
+ assert_percents_equal(animation.effect.getComputedTiming().localTime,
+ expected_progress);
+}, 'Animation current time and effect local time are updated after scroller ' +
+ 'size changes.');
+
+promise_test(async t => {
+ await waitForNextFrame();
+
+ const timeline = createScrollTimeline(t);
+ const scroller = timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ // Instantiate scroll animation that resizes its scroll timeline scroller.
+ const animation = new Animation(
+ new KeyframeEffect(
+ timeline.source.firstChild,
+ [{ height: '1000px', easing: 'steps(2, jump-none)'},
+ { height: '2000px' }],
+ ), timeline);
+
+ animation.play();
+ await animation.ready;
+
+ assert_percents_equal(timeline.currentTime, 0);
+ assert_equals(scroller.scrollHeight, 1000);
+
+ await runAndWaitForFrameUpdate(() => {
+ scroller.scrollTop = 0.6 * maxScroll;
+ });
+
+ // Applying the animation effect alters the height of the scroll content and
+ // makes the scroll timeline stale.
+ // https://github.com/w3c/csswg-drafts/issues/8694
+
+ // With a single layout, timeline current time would be at 60%, but the
+ // timeline would be stale.
+ const expected_progress = 60 * maxScroll / (maxScroll + 1000);
+ assert_approx_equals(timeline.currentTime.value, expected_progress, 0.1);
+
+}, 'If scroll animation resizes its scroll timeline scroller, ' +
+ 'layout reruns once per frame.');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-range.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-range.html
new file mode 100644
index 0000000000..cc844cb748
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-range.html
@@ -0,0 +1,185 @@
+<!DOCTYPE html>
+<title>Scroll timelines and animation attachment ranges</title>
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#animation-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ #scroller {
+ overflow-y: scroll;
+ width: 200px;
+ height: 200px;
+ scroll-timeline: --t1;
+ }
+ .spacer {
+ height: 1100px;
+ }
+ #target {
+ height: 100px;
+ width: 0;
+ font-size: 10px;
+ background-color: green;
+ }
+ @keyframes grow {
+ to { width: 200px }
+ }
+ .anim-1 {
+ animation: auto grow linear;
+ animation-timeline: --t1;
+ animation-range-start: 25%;
+ animation-range-end: 75%;
+ }
+ .anim-2 {
+ animation: auto grow linear;
+ animation-timeline: --t1;
+ animation-range-start: 40px;
+ animation-range-end: 700px;
+ }
+ .anim-3 {
+ animation: auto grow linear;
+ animation-timeline: --t1;
+ animation-range-start: calc(30% + 40px);
+ animation-range-end: calc(70% - 20px);
+ }
+ .anim-4 {
+ animation: auto grow linear;
+ animation-timeline: --t1;
+ animation-range-start: 5em;
+ animation-range-end: calc(100% - 5em);
+ }
+
+</style>
+<div id=scroller>
+ <div id=target></div>
+ <div class=spacer></div>
+</div>
+<script>
+ // Test via web-animation API.
+
+ async function test_range_waapi(t, options) {
+ const timeline = new ScrollTimeline({source: scroller, axis: 'block'});
+ const anim =
+ target.animate([{ width: "200px" }],
+ {
+ timeline: timeline,
+ rangeStart: options.rangeStart,
+ rangeEnd: options.rangeEnd
+ });
+ t.add_cleanup(() => {
+ anim.cancel();
+ });
+ await anim.ready;
+ scroller.scrollTop = 600;
+ await waitForNextFrame();
+
+ const expectedProgress =
+ (600 - options.startOffset) / (options.endOffset - options.startOffset);
+ assert_approx_equals(anim.effect.getComputedTiming().progress,
+ expectedProgress, 0.001);
+ }
+
+ promise_test(async t => {
+ await test_range_waapi(t, {
+ rangeStart: "25%",
+ rangeEnd: "75%",
+ startOffset: 250,
+ endOffset: 750
+ });
+ }, 'Scroll timeline with percentage range [JavaScript API]');
+
+ promise_test(async t => {
+ await test_range_waapi(t, {
+ rangeStart: "40px",
+ rangeEnd: "700px",
+ startOffset: 40,
+ endOffset: 700
+ });
+ }, 'Scroll timeline with px range [JavaScript API]');
+
+ promise_test(async t => {
+ await test_range_waapi(t, {
+ rangeStart: "calc(30% + 40px)",
+ rangeEnd: "calc(70% - 20px)",
+ startOffset: 340,
+ endOffset: 680
+ });
+ }, 'Scroll timeline with calculated range [JavaScript API]');
+
+promise_test(async t => {
+ t.add_cleanup(() => {
+ target.style.fontSize = '';
+ });
+ await test_range_waapi(t, {
+ rangeStart: "5em",
+ rangeEnd: "calc(100% - 5em)",
+ startOffset: 50,
+ endOffset: 950
+ });
+ target.style.fontSize = '20px';
+ await waitForNextFrame();
+ const anim = target.getAnimations()[0];
+ const expectedProgress = (600 - 100) / (900 - 100);
+ assert_approx_equals(anim.effect.getComputedTiming().progress,
+ expectedProgress, 0.001);
+ }, 'Scroll timeline with EM range [JavaScript API]');
+
+ // Test via CSS.
+ async function test_range_css(t, options) {
+ t.add_cleanup(() => {
+ target.classList.remove(options.animation);
+ });
+ target.classList.add(options.animation);
+ const anim = target.getAnimations()[0];
+ await anim.ready;
+ scroller.scrollTop = 600;
+ await waitForNextFrame();
+
+ const expectedProgress =
+ (600 - options.startOffset) / (options.endOffset - options.startOffset);
+ assert_approx_equals(anim.effect.getComputedTiming().progress,
+ expectedProgress, 0.001);
+ }
+
+ promise_test(async t => {
+ await test_range_css(t, {
+ animation: "anim-1",
+ startOffset: 250,
+ endOffset: 750
+ });
+ }, 'Scroll timeline with percentage range [CSS]');
+
+ promise_test(async t => {
+ await test_range_css(t, {
+ animation: "anim-2",
+ startOffset: 40,
+ endOffset: 700
+ });
+ }, 'Scroll timeline with px range [CSS]');
+
+ promise_test(async t => {
+ await test_range_css(t, {
+ animation: "anim-3",
+ startOffset: 340,
+ endOffset: 680
+ });
+ }, 'Scroll timeline with calculated range [CSS]');
+
+promise_test(async t => {
+ t.add_cleanup(() => {
+ target.style.fontSize = '';
+ });
+ await test_range_css(t, {
+ animation: "anim-4",
+ startOffset: 50,
+ endOffset: 950
+ });
+ target.style.fontSize = '20px';
+ await waitForNextFrame();
+ const anim = target.getAnimations()[0];
+ const expectedProgress = (600 - 100) / (900 - 100);
+ assert_approx_equals(anim.effect.getComputedTiming().progress,
+ expectedProgress, 0.001);
+ }, 'Scroll timeline with EM range [CSS]');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-snapshotting.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-snapshotting.html
new file mode 100644
index 0000000000..1e43699d5b
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-snapshotting.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>ScrollTimeline snapshotting</title>
+<link rel="help" href="https://wicg.github.io/scroll-animations/#avoiding-cycles">
+<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="/resources/testdriver-actions.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="./testcommon.js"></script>
+
+<style>
+ body {
+ height: 800px;
+ width: 800px;
+ }
+</style>
+<div id="log"></div>
+
+<script>
+'use strict';
+
+promise_test(async t => {
+ const scroller = document.scrollingElement;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ const timeline = new ScrollTimeline({source: scroller});
+ scroller.scrollTo(0, 0);
+ assert_equals(scroller.scrollTop, 0, "verify test pre-condition");
+
+ scroller.scrollBy({top: 100, behavior: 'smooth'});
+ // Wait for the scroll to change.
+ const startScroll = scroller.scrollTop;
+ do {
+ await waitForNextFrame();
+ } while(scroller.scrollTop == startScroll);
+ assert_percents_equal(
+ timeline.currentTime,
+ (scroller.scrollTop / maxScroll) * 100,
+ 'Scroll timeline current time corresponds to the scroll position.');
+}, 'ScrollTimeline current time is updated after programmatic animated ' +
+ 'scroll.');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/set-current-time-before-play.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/set-current-time-before-play.html
new file mode 100644
index 0000000000..280346e755
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/set-current-time-before-play.html
@@ -0,0 +1,75 @@
+<html class="reftest-wait">
+<title>Setting current time before play should not timeout</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Regression test to make sure the ready promise is correctly resolved">
+<link rel="match" href="animation-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"></div>
+</div>
+
+<script>
+ async function runTest() {
+ await waitForCompositorReady();
+
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateY(0)', opacity: 1},
+ { transform: 'translateY(200px)', opacity: 0}
+ ], {
+ duration: 1000,
+ }
+ );
+
+ const scroller = document.getElementById('scroller');
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const animation = new Animation(effect, timeline);
+ animation.currentTime = CSS.percent(0);
+ animation.play();
+
+ animation.ready.then(() => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+ }
+
+ window.onload = runTest;
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-current-time.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-current-time.html
new file mode 100644
index 0000000000..f6c826db69
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-current-time.html
@@ -0,0 +1,286 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Setting the current time of an animation</title>
+<link rel="help" href="https://drafts.csswg.org/web-animations-1/#setting-the-current-time-of-an-animation">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 200px;
+ width: 100px;
+ will-change: transform;
+}
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<div id="log"></div>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+
+ assert_throws_js(TypeError, () => {
+ animation.currentTime = null;
+ });
+}, 'Setting animation current time to null throws TypeError.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ assert_throws_dom('NotSupportedError', () => {
+ animation.currentTime = CSSNumericValue.parse("300");
+ });
+ assert_throws_dom('NotSupportedError', () => {
+ animation.currentTime = CSSNumericValue.parse("300ms");
+ });
+ assert_throws_dom('NotSupportedError', () => {
+ animation.currentTime = CSSNumericValue.parse("0.3s");
+ });
+}, 'Setting the current time to an absolute time value throws exception');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ animation.currentTime = CSSNumericValue.parse("33.3%");
+
+ assert_percents_equal(animation.currentTime, 33.3,
+ "Animation current time should be equal to the set value."
+ );
+}, 'Set animation current time to a valid value without playing.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ animation.currentTime = CSSNumericValue.parse("33.3%");
+
+ assert_percents_equal(animation.currentTime, 33.3,
+ "Animation current time should be equal to the set value."
+ );
+}, 'Set animation current time to a valid value while playing.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ animation.currentTime = CSSNumericValue.parse("200%");
+
+ assert_equals(animation.playState, "finished");
+ assert_percents_equal(animation.currentTime, 200,
+ "Animation current time should be equal to the set value."
+ );
+}, 'Set animation current time to a value beyond effect end.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ animation.currentTime = CSSNumericValue.parse("-10%");
+
+ assert_equals(animation.playState, "running");
+ assert_percents_equal(animation.currentTime, -10,
+ "Animation current time should be equal to the set value."
+ );
+}, 'Set animation current time to a negative value.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ animation.currentTime = CSSNumericValue.parse("30%");
+
+ assert_equals(animation.playState, "running");
+ assert_true(animation.pending);
+ assert_percents_equal(animation.currentTime, 30);
+}, "Setting current time while play pending overrides the current time");
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ animation.currentTime = CSSNumericValue.parse("33.3%");
+
+ assert_percents_equal(animation.currentTime, 33.3,
+ "Animation current time should be equal to the set value."
+ );
+
+ // Cancel the animation and play it again, check that current time has reset
+ // to scroll offset based current time.
+ animation.cancel();
+ animation.play();
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, animation.timeline.currentTime,
+ "Animation current time should return to a value matching its" +
+ " timeline current time after animation is cancelled and played again."
+ );
+}, 'Setting animation.currentTime then restarting the animation should' +
+ ' reset the current time.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ const originalCurrentTime = animation.currentTime.value;
+
+ // Set the current time to something other than where the scroll offset.
+ animation.currentTime = CSSNumericValue.parse("50%");
+
+ // Setting current time is internally setting the start time to
+ // scrollTimeline.currentTime - newAnimationCurrentTime.
+ // Which results in current time of (timeline.currentTime - start_time).
+ // This behavior puts the animation in a strange "out of sync" state between
+ // the scroller and the animation effect, this is currently expected
+ // behavior.
+
+ const expectedStartTime = originalCurrentTime - animation.currentTime.value;
+ assert_percents_equal(animation.startTime, expectedStartTime,
+ "Animation current time should be updated when setting the current time" +
+ " to a time within the range of the animation.");
+
+ scroller.scrollTop = 0.7 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(animation.startTime, expectedStartTime,
+ "Animation start time should remain unchanged when the scroller changes" +
+ " position."
+ );
+ assert_percents_equal(animation.currentTime,
+ animation.timeline.currentTime.value - animation.startTime.value,
+ "Animation current time should return to a value equal to" +
+ " (timeline.currentTime - animation.startTime) after timeline scroll" +
+ " source has been scrolled."
+ );
+}, 'Set Animation current time then scroll.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ await animation.ready;
+
+ // Make the timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ await waitForNextFrame();
+
+ assert_equals(animation.currentTime, null,
+ 'Current time is unresolved when the timeline is inactive.');
+
+ animation.currentTime = CSSNumericValue.parse("30%");
+ assert_percents_equal(animation.currentTime, 30,
+ 'Animation current time should be equal to the set value.');
+ assert_equals(animation.playState, 'paused',
+ 'Animation play state is \'paused\' when current time is set and ' +
+ 'timeline is inactive.');
+}, 'Animation current time and play state are correct when current time is ' +
+ 'set while the timeline is inactive.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ await animation.ready;
+
+ // Make the timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ await waitForNextFrame();
+
+ assert_equals(animation.timeline.currentTime, null,
+ 'Current time is unresolved when the timeline is inactive.');
+
+ animation.currentTime = CSSNumericValue.parse("30%");
+ assert_percents_equal(animation.currentTime, 30,
+ 'Animation current time should be equal to the set value.');
+ assert_equals(animation.playState, 'paused',
+ 'Animation play state is \'paused\' when current time is set and ' +
+ 'timeline is inactive.');
+
+ // Make the timeline active.
+ scroller.style.overflow = 'auto';
+ scroller.scrollTop;
+ await waitForNextFrame();
+
+ assert_percents_equal(animation.timeline.currentTime, 0,
+ 'Current time is resolved when the timeline is active.');
+ assert_percents_equal(animation.currentTime, 30,
+ 'Animation current time holds the set value.');
+ assert_equals(animation.playState, 'paused',
+ 'Animation holds \'paused\' state.');
+}, 'Animation current time set while the timeline is inactive holds when the ' +
+ 'timeline becomes active again.');
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-playback-rate.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-playback-rate.html
new file mode 100644
index 0000000000..e7e96a27e1
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-playback-rate.html
@@ -0,0 +1,298 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Setting the playback rate of an animation that is using a ScrollTimeline</title>
+<link rel="help" href="https://drafts.csswg.org/web-animations/#setting-the-playback-rate-of-an-animation">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<script>
+ 'use strict';
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // this forces a layout which results in an active timeline
+ scroller.scrollTop = 0;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ animation.playbackRate = 0.5;
+ animation.play();
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 0,
+ 'Zero current time is not affected by playbackRate change.');
+ }, 'Zero current time is not affected by playbackRate set while the ' +
+ 'animation is in idle state.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // this forces a layout which results in an active timeline
+ scroller.scrollTop = 0;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ animation.play();
+ await animation.ready;
+ animation.playbackRate = 0.5;
+
+ assert_percents_equal(animation.currentTime, 0,
+ 'Zero current time is not affected by playbackRate change.');
+ }, 'Zero current time is not affected by playbackRate set while the ' +
+ 'animation is in play-pending state.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ animation.playbackRate = 0.5;
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 10,
+ 'Initial current time is scaled by playbackRate change.');
+ }, 'Initial current time is scaled by playbackRate set while ' +
+ 'scroll-linked animation is in running state.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ const playbackRate = 2;
+
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ animation.play();
+ await animation.ready;
+ // Set playback rate while the animation is playing.
+ animation.playbackRate = playbackRate;
+ assert_percents_equal(animation.currentTime, 40,
+ 'The current time is scaled by the playback rate.');
+ }, 'The current time is scaled by playbackRate set while the ' +
+ 'scroll-linked animation is in play state.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Set playback rate while the animation is in 'idle' state.
+ animation.playbackRate = 2;
+ animation.play();
+ await animation.ready;
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(animation.currentTime, 40,
+ 'The current time should increase two times faster ' +
+ 'than timeline time.');
+ }, 'The playback rate set before scroll-linked animation started playing ' +
+ 'affects the rate of progress of the current time');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.play();
+
+ await animation.ready;
+
+ animation.playbackRate = 2;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(
+ animation.currentTime,
+ animation.timeline.currentTime.value * animation.playbackRate,
+ 'The current time should increase two times faster than timeline time');
+ }, 'The playback rate affects the rate of progress of the current time' +
+ ' when scrolling');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ // Setting the current time while play-pending sets the hold time and not
+ // the start time. currentTime is unaffected by playback rate until no
+ // longer pending.
+ animation.currentTime = CSSNumericValue.parse("25%");
+ animation.playbackRate = 2;
+
+ assert_equals(animation.playState, "running");
+ assert_true(animation.pending);
+ assert_percents_equal(animation.currentTime, 25);
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 25);
+ }, 'Setting the playback rate while play-pending does not scale current ' +
+ 'time.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ animation.play();
+ await animation.ready;
+ animation.playbackRate = 2;
+
+ assert_percents_equal(animation.currentTime, 50);
+ }, 'Setting the playback rate while playing scales current time.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("25%");
+ await animation.ready;
+ animation.playbackRate = 2;
+
+ assert_percents_equal(animation.currentTime, 50);
+ }, 'Setting the playback rate while playing scales the set current time.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.playbackRate = -1;
+ scroller.scrollTop = 0.3 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ const expectedCurrentTime = 100 - animation.timeline.currentTime.value;
+ assert_percents_equal(animation.currentTime, expectedCurrentTime);
+ }, 'Negative initial playback rate should correctly modify initial current' +
+ ' time.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ animation.play();
+
+ await animation.ready;
+ const startingTimelineTime = animation.timeline.currentTime;
+ const startingCurrentTime = animation.currentTime;
+ assert_percents_equal(startingCurrentTime, 50);
+ assert_percents_equal(startingTimelineTime, 50);
+
+ animation.playbackRate = -1;
+
+ scroller.scrollTop = 0.8 * maxScroll;
+ await waitForNextFrame();
+ // -300 = 500 - 800
+
+ // let timelineDiff =
+ // startingTimelineTime.value - animation.timeline.currentTime.value;
+ // // 200 = 500 + (-300)
+ // let expected = startingCurrentTime.value + timelineDiff;
+ assert_percents_equal(animation.timeline.currentTime, 80);
+ assert_percents_equal(animation.currentTime, 20);
+
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+ // // 300 = 500 - 200
+ // timelineDiff =
+ // startingTimelineTime.value - animation.timeline.currentTime.value;
+ // // 800 = 500 + 300
+ // expected = startingCurrentTime.value + timelineDiff;
+ assert_percents_equal(animation.timeline.currentTime, 20);
+ assert_percents_equal(animation.currentTime, 80);
+ }, 'Reversing the playback rate while playing correctly impacts current' +
+ ' time during future scrolls');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.playbackRate = 0;
+ scroller.scrollTop = 0.3 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 0);
+ }, 'Zero initial playback rate should correctly modify initial current' +
+ ' time.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 20);
+ animation.playbackRate = 0;
+ scroller.scrollTop = 0.5 * maxScroll;
+ await waitForNextFrame();
+
+ // Ensure that current time does not change.
+ assert_percents_equal(animation.timeline.currentTime, 50);
+ assert_percents_equal(animation.currentTime, 0);
+ }, 'Setting a zero playback rate while running preserves the start time');
+
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ assert_percents_equal(animation.timeline.currentTime, 20);
+ assert_percents_equal(animation.currentTime, 20);
+ animation.startTime = animation.currentTime;
+ // timeline current time [0%, 100%] --> animation current time [-20%, 80%].
+ assert_percents_equal(animation.currentTime, 0);
+
+ animation.playbackRate = -1;
+ // timeline current time [0%, 100%] --> animation current time [80%, -20%].
+ // timeline @ 20% --> animation current time @ (20% - 80%) * (-1) = 60%.
+ assert_percents_equal(animation.timeline.currentTime, 20);
+ assert_percents_equal(animation.currentTime, 60);
+ }, 'Reversing an animation with non-boundary aligned start time ' +
+ 'symmetrically adjusts the start time');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-start-time.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-start-time.html
new file mode 100644
index 0000000000..aae1849565
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-start-time.html
@@ -0,0 +1,401 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Setting the start time of scroll animation</title>
+<link rel="help" href="https://drafts.csswg.org/web-animations/#setting-the-start-time-of-an-animation">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 200px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<div id="log"></div>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ assert_throws_dom('NotSupportedError', () => {
+ animation.startTime = CSSNumericValue.parse("300");
+ });
+ assert_throws_dom('NotSupportedError', () => {
+ animation.startTime = CSSNumericValue.parse("300ms");
+ });
+ assert_throws_dom('NotSupportedError', () => {
+ animation.startTime = CSSNumericValue.parse("0.3s");
+ });
+}, 'Setting the start time to an absolute time value throws exception');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ // So long as a hold time is set, querying the current time will return
+ // the hold time.
+
+ // Since the start time is unresolved at this point, setting the current time
+ // will set the hold time
+ animation.currentTime = CSSNumericValue.parse("30%");
+ assert_equals(animation.startTime, null, 'The start time stays unresolved');
+ assert_percents_equal(animation.currentTime, 30,
+ 'The current time is calculated from the hold time');
+
+ // If we set the start time, however, we should clear the hold time.
+ animation.startTime = CSSNumericValue.parse("0%");
+ assert_percents_equal(animation.startTime, 0,
+ 'The start time is set to the requested value');
+ assert_percents_equal(animation.currentTime, 20,
+ 'The current time is calculated from the start time, ' +
+ 'not the hold time');
+ // Sanity check
+ assert_equals(animation.playState, 'running',
+ 'Animation reports it is running after setting a resolved ' +
+ 'start time');
+}, 'Setting the start time clears the hold time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive');
+
+ // So long as a hold time is set, querying the current time will return
+ // the hold time.
+
+ // Since the start time is unresolved at this point, setting the current time
+ // will set the hold time
+ animation.currentTime = CSSNumericValue.parse("30%");
+ assert_equals(animation.startTime, null, 'The start time stays unresolved');
+ assert_percents_equal(animation.currentTime, 30,
+ 'The current time is calculated from the hold time');
+
+ // If we set the start time, however, we should clear the hold time.
+ animation.startTime = CSSNumericValue.parse("0%");
+ assert_percents_equal(animation.startTime, 0,
+ 'The start time is set to the requested value');
+ assert_equals(animation.currentTime, null,
+ 'The current time is calculated from the start time, not' +
+ ' the hold time');
+ // Sanity check
+ assert_equals(animation.playState, 'running',
+ 'Animation reports it is running after setting a resolved ' +
+ 'start time');
+}, 'Setting the start time clears the hold time when the timeline is inactive');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.2 * maxScroll;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ // Set up a running animation (i.e. both start time and current time
+ // are resolved).
+ animation.startTime = CSSNumericValue.parse("5%");
+ assert_equals(animation.playState, 'running');
+ assert_percents_equal(animation.startTime, 5,
+ 'The start time is set to the requested value');
+ assert_percents_equal(animation.currentTime, 15,
+ 'Current time is resolved for a running animation');
+
+ // Clear start time
+ animation.startTime = null;
+ assert_equals(animation.startTime, null,
+ 'The start time is set to the requested value');
+ assert_percents_equal(animation.currentTime, 15,
+ 'Hold time is set after start time is made unresolved');
+ assert_equals(animation.playState, 'paused',
+ 'Animation reports it is paused after setting an unresolved'
+ + ' start time');
+}, 'Setting an unresolved start time sets the hold time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive');
+
+ // Set up a running animation (i.e. both start time and current time
+ // are resolved).
+ animation.startTime = CSSNumericValue.parse("5%");
+ assert_equals(animation.playState, 'running');
+ assert_percents_equal(animation.startTime, 5,
+ 'The start time is set to the requested value');
+ assert_equals(animation.currentTime, null,
+ 'Current time is unresolved for a running animation when the ' +
+ 'timeline is inactive');
+
+ // Clear start time
+ animation.startTime = null;
+ assert_equals(animation.startTime, null,
+ 'The start time is set to the requested value');
+ assert_equals(animation.currentTime, null,
+ 'Hold time is set to unresolved after start time is made ' +
+ 'unresolved');
+ assert_equals(animation.playState, 'idle',
+ 'Animation reports it is idle after setting an unresolved'
+ + ' start time');
+}, 'Setting an unresolved start time sets the hold time to unresolved when ' +
+ 'the timeline is inactive');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ let readyPromiseCallbackCalled = false;
+ animation.ready.then(() => { readyPromiseCallbackCalled = true; } );
+
+ // Put the animation in the play-pending state
+ animation.play();
+
+ // Sanity check
+ assert_true(animation.pending && animation.playState === 'running',
+ 'Animation is in play-pending state');
+
+ // Setting the start time should resolve the 'ready' promise, i.e.
+ // it should schedule a microtask to run the promise callbacks.
+ animation.startTime = CSSNumericValue.parse("10%");
+ assert_percents_equal(animation.startTime, 10,
+ 'The start time is set to the requested value');
+ assert_false(readyPromiseCallbackCalled,
+ 'Ready promise callback is not called synchronously');
+
+ // If we schedule another microtask then it should run immediately after
+ // the ready promise resolution microtask.
+ await Promise.resolve();
+ assert_true(readyPromiseCallbackCalled,
+ 'Ready promise callback called after setting startTime');
+}, 'Setting the start time resolves a pending ready promise');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive');
+
+ let readyPromiseCallbackCalled = false;
+ animation.ready.then(() => { readyPromiseCallbackCalled = true; } );
+
+ // Put the animation in the play-pending state
+ animation.play();
+
+ // Sanity check
+ assert_true(animation.pending && animation.playState === 'running',
+ 'Animation is in play-pending state');
+
+ // Setting the start time should resolve the 'ready' promise, i.e.
+ // it should schedule a microtask to run the promise callbacks.
+ animation.startTime = CSSNumericValue.parse("10%");
+ assert_percents_equal(animation.startTime, 10,
+ 'The start time is set to the requested value');
+ assert_false(readyPromiseCallbackCalled,
+ 'Ready promise callback is not called synchronously');
+
+ // If we schedule another microtask then it should run immediately after
+ // the ready promise resolution microtask.
+ await Promise.resolve();
+ assert_true(readyPromiseCallbackCalled,
+ 'Ready promise callback called after setting startTime');
+}, 'Setting the start time resolves a pending ready promise when the timeline' +
+ 'is inactive');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ // Put the animation in the play-pending state
+ animation.play();
+
+ // Sanity check
+ assert_true(animation.pending, 'Animation is pending');
+ assert_equals(animation.playState, 'running', 'Animation is play-pending');
+ assert_equals(animation.startTime, null, 'Start time is unresolved');
+
+ // Setting start time should cancel the pending task.
+ animation.startTime = null;
+ assert_false(animation.pending, 'Animation is no longer pending');
+ assert_equals(animation.playState, 'idle', 'Animation is idle');
+}, 'Setting an unresolved start time on a play-pending animation makes it'
+ + ' idle');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ // Set start time such that the current time is past the end time
+ animation.startTime = CSSNumericValue.parse("-110%");
+ assert_percents_equal(animation.startTime, -110,
+ 'The start time is set to the requested value');
+ assert_equals(animation.playState, 'finished',
+ 'Seeked to finished state using the startTime');
+
+ // If the 'did seek' flag is true, the current time should be greater than
+ // the effect end.
+ assert_greater_than(animation.currentTime.value,
+ animation.effect.getComputedTiming().endTime.value,
+ 'Setting the start time updated the finished state with'
+ + ' the \'did seek\' flag set to true');
+
+ // Furthermore, that time should persist if we have correctly updated
+ // the hold time
+ const finishedCurrentTime = animation.currentTime;
+ await waitForNextFrame();
+ assert_percents_equal(animation.currentTime, finishedCurrentTime,
+ 'Current time does not change after seeking past the ' +
+ 'effect end time by setting the current time');
+}, 'Setting the start time updates the finished state');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+
+ await animation.ready;
+ assert_equals(animation.playState, 'running');
+
+ // Setting the start time updates the finished state. The hold time is not
+ // constrained by the effect end time.
+ animation.startTime = CSSNumericValue.parse("-110%");
+ assert_equals(animation.playState, 'finished');
+
+ assert_percents_equal(animation.currentTime, 110);
+}, 'Setting the start time on a running animation updates the play state');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ // Setting the start time updates the finished state. The hold time is not
+ // constrained by the normal range of the animation time.
+ animation.currentTime = CSSNumericValue.parse("100%");
+ assert_equals(animation.playState, 'finished', 'Animation is finished');
+ animation.playbackRate = -1;
+ assert_equals(animation.playState, 'running', 'Animation is running');
+ animation.startTime = CSSNumericValue.parse("-200%");
+ assert_equals(animation.playState, 'finished', 'Animation is finished');
+ assert_percents_equal(animation.currentTime, -200);
+}, 'Setting the start time on a reverse running animation updates the play '
+ + 'state');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ let readyPromiseCallbackCalled = false;
+ animation.ready.then(() => { readyPromiseCallbackCalled = true; } );
+ animation.pause();
+
+ // Sanity check
+ assert_true(animation.pending && animation.playState === 'paused',
+ 'Animation is in pause-pending state');
+
+ // Setting the start time should resolve the 'ready' promise although
+ // the resolution callbacks when be run in a separate microtask.
+ animation.startTime = null;
+ assert_false(readyPromiseCallbackCalled,
+ 'Ready promise callback is not called synchronously');
+
+ await Promise.resolve();
+ assert_true(readyPromiseCallbackCalled,
+ 'Ready promise callback called after setting startTime');
+}, 'Setting the start time resolves a pending pause task');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+
+ // We should be play-pending now
+ assert_true(anim.pending);
+ assert_equals(anim.playState, 'running');
+
+ // Apply a pending playback rate
+ anim.updatePlaybackRate(2);
+ assert_equals(anim.playbackRate, 1);
+ assert_true(anim.pending);
+
+ // Setting the start time should apply the pending playback rate
+ anim.startTime = CSSNumericValue.parse(
+ anim.timeline.currentTime.value - 2500 + "%");
+ assert_equals(anim.playbackRate, 2);
+ assert_false(anim.pending);
+
+ // Sanity check that the start time is preserved and current time is
+ // calculated using the new playback rate
+ assert_percents_equal(anim.startTime,
+ anim.timeline.currentTime.value - 2500);
+ assert_percents_equal(anim.currentTime, 5000);
+}, 'Setting the start time of a play-pending animation applies a pending '
+ + 'playback rate');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ anim.play();
+ await anim.ready;
+
+ // We should be running now
+ assert_false(anim.pending);
+ assert_equals(anim.playState, 'running');
+
+ // Apply a pending playback rate
+ anim.updatePlaybackRate(2);
+ assert_equals(anim.playbackRate, 1);
+ assert_true(anim.pending);
+
+ // Setting the start time should apply the pending playback rate
+ anim.startTime = CSSNumericValue.parse(
+ anim.timeline.currentTime.value - 25 + "%");
+ assert_equals(anim.playbackRate, 2);
+ assert_false(anim.pending);
+
+ // Sanity check that the start time is preserved and current time is
+ // calculated using the new playback rate
+ assert_percents_equal(anim.startTime,
+ anim.timeline.currentTime.value - 25);
+ assert_percents_equal(anim.currentTime, 50);
+}, 'Setting the start time of a playing animation applies a pending playback '
+ + 'rate');
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-timeline.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-timeline.tentative.html
new file mode 100644
index 0000000000..5813de60fa
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-timeline.tentative.html
@@ -0,0 +1,429 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Setting the timeline of scroll animation</title>
+<link rel="help"
+ href="https://drafts.csswg.org/web-animations-1/#setting-the-timeline">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+ .scroller {
+ overflow-x: hidden;
+ overflow-y: auto;
+ height: 200px;
+ width: 100px;
+ will-change: transform;
+ }
+ .contents {
+ /* The height is set to align scrolling in pixels with logical time in ms */
+ height: 1200px;
+ width: 100%;
+ }
+ @keyframes anim {
+ from { opacity: 0; }
+ to { opacity: 1; }
+ }
+ .anim {
+ animation: anim 1s paused linear;
+ }
+ #target {
+ height: 100px;
+ width: 100px;
+ background-color: green;
+ margin-top: -1000px;
+ }
+</style>
+<body>
+<script>
+'use strict';
+
+function createAnimation(t) {
+ const elem = createDiv(t);
+ const animation = elem.animate({ opacity: [1, 0] }, 1000);
+ return animation;
+}
+
+function createPausedCssAnimation(t) {
+ const elem = createDiv(t);
+ elem.classList.add('anim');
+ return elem.getAnimations()[0];
+}
+
+function updateScrollPosition(timeline, offset) {
+ const scroller = timeline.source;
+ assert_true(!!scroller, 'source is resolved');
+ scroller.scrollTop = offset;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ return waitForNextFrame();
+}
+
+function assert_timeline_current_time(animation, timeline_current_time) {
+ if (animation.currentTime instanceof CSSUnitValue){
+ assert_percents_equal(animation.timeline.currentTime, timeline_current_time,
+ `Timeline's currentTime aligns with the scroll ` +
+ `position even when paused`);
+ }
+ else {
+ assert_times_equal(animation.timeline.currentTime, timeline_current_time,
+ `Timeline's currentTime aligns with the scroll ` +
+ `position even when paused`);
+ }
+}
+
+function assert_scroll_synced_times(animation, timeline_current_time,
+ animation_current_time) {
+ assert_timeline_current_time(animation, timeline_current_time);
+ if (animation.currentTime instanceof CSSUnitValue){
+ assert_percents_equal(animation.currentTime, animation_current_time,
+ `Animation's currentTime aligns with the scroll position`);
+ }
+ else {
+ assert_times_equal(animation.currentTime, animation_current_time,
+ `Animation's currentTime aligns with the scroll position`);
+ }
+}
+
+function assert_paused_times(animation, timeline_current_time,
+ animation_current_time) {
+ assert_timeline_current_time(animation, timeline_current_time);
+ if (animation.currentTime instanceof CSSUnitValue){
+ assert_percents_equal(animation.currentTime, animation_current_time,
+ `Animation's currentTime is fixed while paused`);
+ }
+ else {
+ assert_times_equal(animation.currentTime, animation_current_time,
+ `Animation's currentTime is fixed while paused`);
+ }
+}
+
+function createViewTimeline(t) {
+ const parent = document.querySelector('.scroller');
+ const elem = document.createElement('div');
+ elem.id = 'target';
+ t.add_cleanup(() => {
+ elem.remove();
+ });
+ parent.appendChild(elem);
+ return new ViewTimeline({ subject: elem });
+}
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createAnimation(t);
+ animation.timeline = scrollTimeline;
+ assert_true(animation.pending);
+ await animation.ready;
+
+ assert_equals(animation.playState, 'running');
+ assert_scroll_synced_times(animation, 10, 10);
+}, 'Setting a scroll timeline on a play-pending animation synchronizes ' +
+ 'currentTime of the animation with the scroll position.');
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createAnimation(t);
+ animation.pause();
+ animation.timeline = scrollTimeline;
+ assert_true(animation.pending);
+ await animation.ready;
+
+ assert_equals(animation.playState, 'paused');
+ assert_paused_times(animation, 10, 0);
+
+ await updateScrollPosition(animation.timeline, 200);
+
+ assert_equals(animation.playState, 'paused');
+ assert_paused_times(animation, 20, 0);
+
+ animation.play();
+ await animation.ready;
+
+ assert_scroll_synced_times(animation, 20, 20);
+}, 'Setting a scroll timeline on a pause-pending animation fixes the ' +
+ 'currentTime of the animation based on the scroll position once resumed');
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createAnimation(t);
+ animation.reverse();
+ animation.timeline = scrollTimeline;
+ await animation.ready;
+
+ assert_equals(animation.playState, 'running');
+ assert_scroll_synced_times(animation, 10, 90);
+}, 'Setting a scroll timeline on a reversed play-pending animation ' +
+ 'synchronizes the currentTime of the animation with the scroll ' +
+ 'position.');
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createAnimation(t);
+ await animation.ready;
+
+ animation.timeline = scrollTimeline;
+ assert_true(animation.pending);
+ assert_equals(animation.playState, 'running');
+ await animation.ready;
+ assert_scroll_synced_times(animation, 10, 10);
+}, 'Setting a scroll timeline on a running animation synchronizes the ' +
+ 'currentTime of the animation with the scroll position.');
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createAnimation(t);
+ animation.pause();
+ await animation.ready;
+
+ animation.timeline = scrollTimeline;
+ assert_false(animation.pending);
+ assert_equals(animation.playState, 'paused');
+ assert_paused_times(animation, 10, 0);
+
+ animation.play();
+ await animation.ready;
+
+ assert_scroll_synced_times(animation, 10, 10);
+}, 'Setting a scroll timeline on a paused animation fixes the currentTime of ' +
+ 'the animation based on the scroll position when resumed');
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createAnimation(t);
+ animation.reverse();
+ animation.pause();
+ await animation.ready;
+
+ animation.timeline = scrollTimeline;
+ assert_false(animation.pending);
+ assert_equals(animation.playState, 'paused');
+ assert_paused_times(animation, 10, 100);
+
+ animation.play();
+ await animation.ready;
+
+ assert_scroll_synced_times(animation, 10, 90);
+}, 'Setting a scroll timeline on a reversed paused animation ' +
+ 'fixes the currentTime of the animation based on the scroll ' +
+ 'position when resumed');
+
+promise_test(async t => {
+ const animation = createAnimation(t);
+ const scrollTimeline = createScrollTimeline(t);
+ animation.timeline = scrollTimeline;
+ await animation.ready;
+ await updateScrollPosition(scrollTimeline, 100);
+
+ animation.timeline = document.timeline;
+ assert_times_equal(animation.currentTime, 100);
+}, 'Transitioning from a scroll timeline to a document timeline on a running ' +
+ 'animation preserves currentTime');
+
+promise_test(async t => {
+ const animation = createAnimation(t);
+ const scrollTimeline = createScrollTimeline(t);
+ animation.timeline = scrollTimeline;
+ await animation.ready;
+ await updateScrollPosition(scrollTimeline, 100);
+
+ animation.pause();
+ animation.timeline = document.timeline;
+
+ await animation.ready;
+ assert_times_equal(animation.currentTime, 100);
+}, 'Transitioning from a scroll timeline to a document timeline on a ' +
+ 'pause-pending animation preserves currentTime');
+
+promise_test(async t => {
+ const animation = createAnimation(t);
+ const scrollTimeline = createScrollTimeline(t);
+ animation.timeline = scrollTimeline;
+
+ animation.reverse();
+ await animation.ready;
+ await updateScrollPosition(scrollTimeline, 100);
+
+ animation.pause();
+ await animation.ready;
+
+ assert_scroll_synced_times(animation, 10, 90);
+
+ animation.timeline = document.timeline;
+ assert_false(animation.pending);
+ assert_equals(animation.playState, 'paused');
+ assert_times_equal(animation.currentTime, 900);
+}, 'Transition from a scroll timeline to a document timeline on a reversed ' +
+ 'paused animation maintains correct currentTime');
+
+promise_test(async t => {
+ const animation = createAnimation(t);
+ const scrollTimeline = createScrollTimeline(t);
+ animation.timeline = scrollTimeline;
+ await animation.ready;
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const progress = animation.currentTime.value / 100;
+ const duration = animation.effect.getTiming().duration;
+ animation.timeline = null;
+
+ const expectedCurrentTime = progress * duration;
+ assert_times_equal(animation.currentTime, expectedCurrentTime);
+}, 'Transitioning from a scroll timeline to a null timeline on a running ' +
+ 'animation preserves current progress.');
+
+promise_test(async t => {
+ const keyframeEfect = new KeyframeEffect(createDiv(t),
+ { opacity: [0, 1] },
+ 1000);
+ const animation = new Animation(keyframeEfect, null);
+ animation.startTime = 0;
+ assert_equals(animation.playState, 'running');
+
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ animation.timeline = scrollTimeline;
+ assert_equals(animation.playState, 'running');
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 10);
+}, 'Switching from a null timeline to a scroll timeline on an animation with ' +
+ 'a resolved start time preserved the play state');
+
+promise_test(async t => {
+ const firstScrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(firstScrollTimeline, 100);
+
+ const secondScrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(secondScrollTimeline, 200);
+
+ const animation = createAnimation(t);
+ animation.timeline = firstScrollTimeline;
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 10);
+
+ animation.timeline = secondScrollTimeline;
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 20);
+}, 'Switching from one scroll timeline to another updates currentTime');
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createPausedCssAnimation(t);
+ animation.timeline = scrollTimeline;
+ await animation.ready;
+ assert_equals(animation.playState, 'paused');
+ assert_percents_equal(animation.currentTime, 0);
+
+ const target = animation.effect.target;
+ target.style.animationPlayState = 'running';
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 10);
+}, 'Switching from a document timeline to a scroll timeline updates ' +
+ 'currentTime when unpaused via CSS.');
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createAnimation(t);
+ animation.pause();
+ animation.currentTime = 500; // 50%
+ animation.timeline = scrollTimeline;
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 50);
+
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 10);
+}, 'Switching from a document timeline to a scroll timeline and updating ' +
+ 'currentTime preserves the progress while paused.');
+
+promise_test(async t => {
+ const elem = createDiv(t);
+ const animation = elem.animate(null, Infinity);
+ await animation.ready;
+
+ animation.timeline = new ScrollTimeline();
+ let timing = animation.effect.getComputedTiming();
+ assert_percents_equal(timing.endTime, 100);
+ assert_percents_equal(timing.activeDuration, 100);
+ assert_percents_equal(timing.duration, 100);
+
+ animation.effect.updateTiming({ iterations: 2 });
+ timing = animation.effect.getComputedTiming();
+ assert_percents_equal(timing.endTime, 100);
+ assert_percents_equal(timing.activeDuration, 100);
+ assert_percents_equal(timing.duration, 50);
+
+ // Blink implementation does not permit setting an infinite number of
+ // iterations on a scroll-linked animation. Workaround by temporarily
+ // switching back to a document timeline.
+ animation.timeline = document.timeline;
+ animation.effect.updateTiming({ iterations: Infinity });
+ animation.timeline = new ScrollTimeline();
+ timing = animation.effect.getComputedTiming();
+ // Having an infinite number of iterations with a finite timeline results in
+ // each iteration having zero duration.
+ assert_percents_equal(timing.duration, 0);
+ // If either the iteration duration or iteration count is zero, the active
+ // duration is always zero.
+ assert_percents_equal(timing.activeDuration, 0);
+ assert_percents_equal(timing.endTime, 0);
+
+}, 'Switching from a document timeline to a scroll timeline on an infinite ' +
+ 'duration animation.');
+
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ const view_timeline = createViewTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+ const animation = createAnimation(t);
+ animation.timeline = scrollTimeline;
+ // Range name is ignored while attached to a non-view scroll-timeline.
+ // Offsets are still applied to the scroll-timeline.
+ animation.rangeStart = { rangeName: 'contain', offset: CSS.percent(10) };
+ animation.rangeEnd = { rangeName: 'contain', offset: CSS.percent(90) };
+ await animation.ready;
+
+ assert_scroll_synced_times(animation, 10, 0);
+ assert_percents_equal(animation.startTime, 10);
+
+ animation.timeline = view_timeline;
+ assert_true(animation.pending);
+ await animation.ready;
+
+ // Cover range is [0px, 300px]
+ // Contain range is [100px, 200px]
+ // start time = (contain 10% pos - cover start pos) / cover range * 100%
+ const expected_start_time = 110 / 300 * 100;
+ // timeline time = (scroll pos - cover start pos) / cover range * 100%
+ const expected_timeline_time = 100 / 300 * 100;
+ // current time = timeline time - start time.
+ const expected_current_time = expected_timeline_time - expected_start_time;
+
+ assert_percents_equal(animation.startTime, expected_start_time);
+ assert_percents_equal(animation.timeline.currentTime, expected_timeline_time);
+ assert_percents_equal(animation.currentTime, expected_current_time);
+}, 'Changing from a scroll-timeline to a view-timeline updates start time.');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/source-quirks-mode.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/source-quirks-mode.html
new file mode 100644
index 0000000000..17e95a0519
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/source-quirks-mode.html
@@ -0,0 +1,36 @@
+<!-- Quirks mode -->
+<html>
+<head>
+ <title>ScrollTimeline default source in quirks mode</title>
+ <link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#dom-scrolltimeline-scrolltimeline">
+ <script src="/resources/testharness.js"></script>
+ <script src="/resources/testharnessreport.js"></script>
+ <style>
+ /* This is just to make it possible for #body1 to be
+ "potentially scrollable".
+
+ https://drafts.csswg.org/cssom-view/#potentially-scrollable */
+ html {
+ overflow: hidden;
+ }
+ </style>
+</head>
+<body id=body1></body>
+<script>
+test(() => {
+ try {
+ assert_equals(document.scrollingElement.id, 'body1');
+ assert_equals(new ScrollTimeline({}).source, body1);
+
+ // Make #body1 "potentially scrollable". This causes the scrollingElement
+ // of the document to become null.
+ //
+ // https://drafts.csswg.org/cssom-view/#dom-document-scrollingelement
+ body1.style = 'overflow:scroll';
+ assert_equals(new ScrollTimeline({}).source, null);
+ } finally {
+ body1.style = '';
+ }
+}, 'Style of <body> is reflected in source attribute in quirks mode');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/testcommon.js b/testing/web-platform/tests/scroll-animations/scroll-timelines/testcommon.js
new file mode 100644
index 0000000000..97e81f494c
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/testcommon.js
@@ -0,0 +1,124 @@
+'use strict';
+
+// Builds a generic structure that looks like:
+//
+// <div class="scroller"> // 100x100 viewport
+// <div class="contents"></div> // 500x500
+// </div>
+//
+// The |scrollerOverrides| and |contentOverrides| parameters are maps which
+// are applied to the scroller and contents style after basic setup.
+//
+// Appends the outer 'scroller' element to the document body, and returns it.
+function setupScrollTimelineTest(
+ scrollerOverrides = new Map(), contentOverrides = new Map()) {
+ let scroller = document.createElement('div');
+ scroller.style.width = '100px';
+ scroller.style.height = '100px';
+ // Hide the scrollbars, but maintain the ability to scroll. This setting
+ // ensures that variability in scrollbar sizing does not contribute to test
+ // failures or flakes.
+ scroller.style.overflow = 'hidden';
+ for (const [key, value] of scrollerOverrides) {
+ scroller.style[key] = value;
+ }
+
+ let contents = document.createElement('div');
+ contents.style.width = '500px';
+ contents.style.height = '500px';
+ for (const [key, value] of contentOverrides) {
+ contents.style[key] = value;
+ }
+
+ scroller.appendChild(contents);
+ document.body.appendChild(scroller);
+ return scroller;
+}
+
+// Helper method to calculate the current time, implementing only step 5 of
+// https://wicg.github.io/scroll-animations/#current-time-algorithm
+function calculateCurrentTime(
+ currentScrollOffset, startScrollOffset, endScrollOffset) {
+ return ((currentScrollOffset - startScrollOffset) /
+ (endScrollOffset - startScrollOffset)) *
+ 100;
+}
+
+function createScroller(test) {
+ var scroller = createDiv(test);
+ scroller.innerHTML = "<div class='contents'></div>";
+ scroller.classList.add('scroller');
+ // Trigger layout run.
+ scroller.scrollTop;
+ return scroller;
+}
+
+function createScrollerWithStartAndEnd(test, orientationClass = 'vertical') {
+ var scroller = createDiv(test);
+ scroller.innerHTML =
+ `<div class='contents'>
+ <div id='start'></div>
+ <div id='end'></div>
+ </div>`;
+ scroller.classList.add('scroller');
+ scroller.classList.add(orientationClass);
+
+ return scroller;
+}
+
+function createScrollTimeline(test, options) {
+ options = options || {
+ source: createScroller(test)
+ }
+ return new ScrollTimeline(options);
+}
+
+function createScrollLinkedAnimation(test, timeline) {
+ return createScrollLinkedAnimationWithTiming(test, /* duration in ms*/ 1000, timeline);
+}
+
+function createScrollLinkedAnimationWithTiming(test, timing, timeline) {
+ if (timeline === undefined)
+ timeline = createScrollTimeline(test);
+ const KEYFRAMES = { opacity: [0, 1] };
+ return new Animation(
+ new KeyframeEffect(createDiv(test), KEYFRAMES, timing), timeline);
+}
+
+function assert_approx_equals_or_null(actual, expected, tolerance, name) {
+ if (actual === null || expected === null){
+ assert_equals(actual, expected, name);
+ }
+ else {
+ assert_approx_equals(actual, expected, tolerance, name);
+ }
+}
+
+function assert_percents_approx_equal(actual, expected, maxScroll,
+ description) {
+ // Base the tolerance on being out by up to half a pixel.
+ const tolerance = 0.5 / maxScroll * 100;
+ assert_equals(actual.unit, "percent", `'actual' unit type must be ` +
+ `'percent' for "${description}"`);
+ assert_true(actual instanceof CSSUnitValue, `'actual' must be of type ` +
+ `CSSNumberish for "${description}"`);
+ if (expected instanceof CSSUnitValue){
+ // Verify that when the expected in a CSSUnitValue, it is the correct unit
+ // type
+ assert_equals(expected.unit, "percent", `'expected' unit type must be ` +
+ `'percent' for "${description}"`);
+ assert_approx_equals(actual.value, expected.value, tolerance,
+ `values do not match for "${description}"`);
+ } else if (typeof expected, "number"){
+ assert_approx_equals(actual.value, expected, tolerance,
+ `values do not match for "${description}"`);
+ }
+}
+
+function assert_percents_equal(actual, expected, description) {
+ // Rough estimate of the default size of the scrollable area based on
+ // sizes in setupScrollTimelineTest.
+ const defaultScrollRange = 400;
+ return assert_percents_approx_equal(actual, expected, defaultScrollRange,
+ description);
+}
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline-cancel-one.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline-cancel-one.html
new file mode 100644
index 0000000000..ed8e8337a6
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline-cancel-one.html
@@ -0,0 +1,84 @@
+<html class="reftest-wait">
+<title>Scroll timeline shared by two animation, one gets cancelled</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Cancelling animations should not affect other
+ animation that is attached to the same timeline.">
+<link rel="match" href="animation-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"><p>Scrolling Contents</p></div>
+</div>
+
+<script>
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateY(0)', opacity: 1},
+ { transform: 'translateY(200px)', opacity: 0}
+ ], {
+ duration: 1000,
+ }
+ );
+ const temporary_effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateX(0)'},
+ { transform: 'translateX(200px)'}
+ ], {
+ duration: 1000,
+ }
+ );
+
+ const scroller = document.getElementById('scroller');
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const animation = new Animation(effect, timeline);
+ const temporary_animation = new Animation(temporary_effect, timeline);
+ animation.play();
+ temporary_animation.play();
+
+ Promise.all([animation.ready, temporary_animation.ready]).then(() => {
+ temporary_animation.cancel();
+ temporary_animation.ready.then(() => {
+ waitForAnimationFrames(2).then(_ => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline.html
new file mode 100644
index 0000000000..de50599fba
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline.html
@@ -0,0 +1,79 @@
+<html class="reftest-wait">
+<title>Scroll timeline shared by two animation</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Should be able to use the same scroll timeline to
+drive two animations">
+<link rel="match" href="animation-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"><p>Scrolling Contents</p></div>
+</div>
+
+<script>
+ const box = document.getElementById('box');
+ const transform_effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateY(0)'},
+ { transform: 'translateY(200px)'}
+ ], {
+ duration: 1000,
+ }
+ );
+ const opacity_effect = new KeyframeEffect(box,
+ [
+ { opacity: 1},
+ { opacity: 0}
+ ], {
+ duration: 1000,
+ }
+ );
+
+ const scroller = document.getElementById('scroller');
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const transform_animation = new Animation(transform_effect, timeline);
+ transform_animation.play();
+ const opacity_animation = new Animation(opacity_effect, timeline);
+ opacity_animation.play();
+
+ Promise.all([transform_animation.ready, opacity_animation.ready]).then(() => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/update-playback-rate.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/update-playback-rate.html
new file mode 100644
index 0000000000..10535319fc
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/update-playback-rate.html
@@ -0,0 +1,178 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Seamlessly updating the playback rate of an animation</title>
+<link rel="help"
+ href="https://drafts.csswg.org/web-animations-1/#seamlessly-updating-the-playback-rate-of-an-animation">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ animation.currentTime = CSSNumericValue.parse("50%");
+
+ animation.updatePlaybackRate(0.5);
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 50,
+ 'Reducing the playback rate should not change the ' +
+ 'current time of a playing animation');
+
+ animation.updatePlaybackRate(2);
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 50,
+ 'Increasing the playback rate should not change the ' +
+ 'current time of a playing animation');
+}, 'Updating the playback rate maintains the current time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ await animation.ready;
+
+ assert_false(animation.pending);
+ animation.updatePlaybackRate(2);
+ assert_true(animation.pending);
+}, 'Updating the playback rate while running makes the animation pending');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("50%");
+ assert_true(animation.pending);
+
+ animation.updatePlaybackRate(0.5);
+
+ // Check that the hold time is updated as expected
+ assert_percents_equal(animation.currentTime, 50);
+
+ await animation.ready;
+
+ // As above, check that the currentTime is not calculated by simply
+ // substituting in the updated playbackRate without updating the startTime.
+ assert_percents_equal(animation.currentTime, 50,
+ 'Reducing the playback rate should not change the ' +
+ 'current time of a play-pending animation');
+}, 'Updating the playback rate on a play-pending animation maintains the ' +
+ 'current time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("50%");
+ await animation.ready;
+
+ animation.pause();
+ animation.updatePlaybackRate(0.5);
+
+ assert_percents_equal(animation.currentTime, 50);
+}, 'Updating the playback rate on a pause-pending animation maintains the ' +
+ 'current time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ animation.updatePlaybackRate(2);
+ animation.updatePlaybackRate(3);
+ animation.updatePlaybackRate(4);
+
+ assert_equals(animation.playbackRate, 1);
+ await animation.ready;
+
+ assert_equals(animation.playbackRate, 4);
+}, 'If a pending playback rate is set multiple times, the latest wins');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.cancel();
+
+ animation.updatePlaybackRate(2);
+ assert_equals(animation.playbackRate, 2);
+ assert_false(animation.pending);
+}, 'In the idle state, the playback rate is applied immediately');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.pause();
+ await animation.ready;
+
+ animation.updatePlaybackRate(2);
+ assert_equals(animation.playbackRate, 2);
+ assert_false(animation.pending);
+}, 'In the paused state, the playback rate is applied immediately');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.finish();
+ assert_percents_equal(animation.currentTime, 100);
+ assert_false(animation.pending);
+
+ animation.updatePlaybackRate(2);
+ assert_equals(animation.playbackRate, 2);
+ assert_percents_equal(animation.currentTime, 100);
+ assert_false(animation.pending);
+}, 'Updating the playback rate on a finished animation maintains the current ' +
+ 'time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.finish();
+ assert_percents_equal(animation.currentTime, 100);
+ assert_false(animation.pending);
+
+ animation.updatePlaybackRate(0);
+ assert_equals(animation.playbackRate, 0);
+ assert_percents_equal(animation.currentTime, 100);
+ assert_false(animation.pending);
+}, 'Updating the playback rate to zero on a finished animation maintains the ' +
+ 'current time');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/updating-the-finished-state.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/updating-the-finished-state.html
new file mode 100644
index 0000000000..86b52d5aa0
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/updating-the-finished-state.html
@@ -0,0 +1,565 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Updating the finished state</title>
+<link rel="help" href="https://drafts.csswg.org/web-animations/#updating-the-finished-state">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<script>
+'use strict';
+
+// --------------------------------------------------------------------
+//
+// TESTS FOR UPDATING THE HOLD TIME
+//
+// --------------------------------------------------------------------
+
+// CASE 1: playback rate > 0 and current time >= target effect end
+// (Also the start time is resolved and there is pending task)
+// Did seek = true
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(200);
+ scroller.scrollTop = 0.7 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 200,
+ 'Hold time is set so current time should NOT change');
+}, 'Updating the finished state when seeking past end');
+
+// Did seek = false
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+ await anim.ready;
+
+ scroller.scrollTop = maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 100,
+ 'Hold time is set to target end clamping current time');
+}, 'Updating the finished state when playing exactly to end');
+
+// Did seek = true
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(100);
+ scroller.scrollTop = 0.7 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 100,
+ 'Hold time is set so current time should NOT change');
+}, 'Updating the finished state when seeking exactly to end');
+
+
+// CASE 2: playback rate < 0 and current time <= 0
+// (Also the start time is resolved and there is pending task)
+
+// Did seek = false
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.playbackRate = -1;
+ anim.play(); // Make sure animation is not initially finished
+
+ await anim.ready;
+
+ // Seek to 1ms before 0 and then wait 1ms
+ anim.currentTime = CSS.percent(1);
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 0,
+ 'Hold time is set to zero clamping current time');
+}, 'Updating the finished state when playing in reverse past zero');
+
+// Did seek = true
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.playbackRate = -1;
+ anim.play();
+
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(-100);
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, -100,
+ 'Hold time is set so current time should NOT change');
+}, 'Updating the finished state when seeking a reversed animation past zero');
+
+// Did seek = false
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.playbackRate = -1;
+ anim.play();
+ await anim.ready;
+
+ scroller.scrollTop = maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 0,
+ 'Hold time is set to target end clamping current time');
+}, 'Updating the finished state when playing a reversed animation exactly ' +
+ 'to zero');
+
+// Did seek = true
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.playbackRate = -1;
+ anim.play();
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(0);
+
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 0,
+ 'Hold time is set so current time should NOT change');
+}, 'Updating the finished state when seeking a reversed animation exactly ' +
+ 'to zero');
+
+// CASE 3: playback rate > 0 and current time < target end OR
+// playback rate < 0 and current time > 0
+// (Also the start time is resolved and there is pending task)
+
+// Did seek = true; playback rate > 0
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+ anim.finish();
+ await anim.ready;
+ assert_percents_equal(anim.startTime, -100);
+
+ anim.currentTime = CSS.percent(50);
+ // When did seek = true, updating the finished state: (i) updates
+ // the animation's start time and (ii) clears the hold time.
+ // We can test both by checking that the currentTime is initially
+ // updated and then increases.
+ assert_percents_equal(anim.currentTime, 50, 'Start time is updated');
+ assert_percents_equal(anim.startTime, -50);
+
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 70,
+ 'Hold time is not set so current time should increase');
+}, 'Updating the finished state when seeking before end');
+
+// Did seek = false; playback rate < 0
+//
+// Unfortunately it is not possible to test this case. We need to have
+// a hold time set, a resolved start time, and then perform some
+// operation that updates the finished state with did seek set to true.
+//
+// However, the only situation where this could arrive is when we
+// replace the timeline and that procedure is likely to change. For all
+// other cases we either have an unresolved start time (e.g. when
+// paused), we don't have a set hold time (e.g. regular playback), or
+// the current time is zero (and anything that gets us out of that state
+// will set did seek = true).
+
+// Did seek = true; playback rate < 0
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+ anim.playbackRate = -1;
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(50);
+ assert_percents_equal(anim.startTime, 50, 'Start time is updated');
+ assert_percents_equal(anim.currentTime, 50, 'Current time is updated');
+
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 30,
+ 'Hold time is not set so current time should decrease');
+}, 'Updating the finished state when seeking a reversed animation before end');
+
+
+// CASE 4: playback rate == 0
+
+// current time < 0
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+ anim.playbackRate = 0;
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(-100);
+
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, -100,
+ 'Hold time should not be cleared so current time should NOT change');
+}, 'Updating the finished state when playback rate is zero and the current ' +
+ 'time is less than zero');
+
+// current time < target end
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+
+ anim.playbackRate = 0;
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(50);
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 50,
+ 'Hold time should not be cleared so current time should NOT change');
+}, 'Updating the finished state when playback rate is zero and the current ' +
+ 'time is less than end');
+
+// current time > target end
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+ anim.playbackRate = 0;
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(200);
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 200,
+ 'Hold time should not be cleared so current time should NOT change');
+}, 'Updating the finished state when playback rate is zero and the current' +
+ 'time is greater than end');
+
+// CASE 5: current time unresolved
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+ anim.cancel();
+ // Trigger a change that will cause the "update the finished state"
+ // procedure to run.
+ anim.effect.updateTiming({ duration: 2000 });
+ assert_equals(anim.currentTime, null,
+ 'The animation hold time / start time should not be updated');
+ // The "update the finished state" procedure is supposed to run after any
+ // change to timing, but just in case an implementation defers that, let's
+ // wait a frame and check that the hold time / start time has still not been
+ // updated.
+ await waitForAnimationFrames(1);
+
+ assert_equals(anim.currentTime, null,
+ 'The animation hold time / start time should not be updated');
+}, 'Updating the finished state when current time is unresolved');
+
+// CASE 7: start time unresolved
+
+// Did seek = true
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.cancel();
+ anim.currentTime = CSS.percent(150);
+ // Trigger a change that will cause the "update the finished state"
+ // procedure to run.
+ anim.currentTime = CSS.percent(50);
+ assert_percents_equal(anim.currentTime, 50,
+ 'The animation hold time should not be updated');
+ assert_equals(anim.startTime, null,
+ 'The animation start time should not be updated');
+}, 'Updating the finished state when start time is unresolved and did seek = ' +
+ 'true');
+
+// --------------------------------------------------------------------
+//
+// TESTS FOR RUNNING FINISH NOTIFICATION STEPS
+//
+// --------------------------------------------------------------------
+
+function waitForFinishEventAndPromise(animation) {
+ const eventPromise = new Promise(resolve => {
+ animation.onfinish = resolve;
+ });
+ return Promise.all([eventPromise, animation.finished]);
+}
+
+promise_test(t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.play();
+ animation.onfinish =
+ t.unreached_func('Seeking to finish should not fire finish event');
+ animation.finished.then(
+ t.unreached_func(
+ 'Seeking to finish should not resolve finished promise'));
+ animation.currentTime = CSS.percent(100);
+ animation.currentTime = CSS.percent(0);
+ animation.pause();
+ scroller.scrollTop = 0.2 * maxScroll;
+ return waitForAnimationFrames(3);
+}, 'Finish notification steps don\'t run when the animation seeks to finish ' +
+ 'and then seeks back again');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.play();
+ await animation.ready;
+ scroller.scrollTop = maxScroll;
+
+ return waitForFinishEventAndPromise(animation);
+}, 'Finish notification steps run when the animation completes normally');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.effect.target = null;
+
+ animation.play();
+ await animation.ready;
+ scroller.scrollTop = maxScroll;
+ return waitForFinishEventAndPromise(animation);
+}, 'Finish notification steps run when an animation without a target effect ' +
+ 'completes normally');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ animation.currentTime = CSS.percent(101);
+ return waitForFinishEventAndPromise(animation);
+}, 'Finish notification steps run when the animation seeks past finish');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ // Register for notifications now since once we seek away from being
+ // finished the 'finished' promise will be replaced.
+ const finishNotificationSteps = waitForFinishEventAndPromise(animation);
+ animation.finish();
+ animation.currentTime = CSS.percent(0);
+ animation.pause();
+ return finishNotificationSteps;
+}, 'Finish notification steps run when the animation completes with ' +
+ '.finish(), even if we then seek away');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ scroller.scrollTop = maxScroll;
+ const initialFinishedPromise = animation.finished;
+ await animation.finished;
+
+ animation.currentTime = CSS.percent(0);
+ assert_not_equals(initialFinishedPromise, animation.finished);
+}, 'Animation finished promise is replaced after seeking back to start');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+
+ const initialFinishedPromise = animation.finished;
+ scroller.scrollTop = maxScroll;
+ await animation.finished;
+
+ scroller.scrollTop = 0;
+ await waitForNextFrame();
+
+ animation.play();
+ assert_not_equals(initialFinishedPromise, animation.finished);
+}, 'Animation finished promise is replaced after replaying from start');
+
+async_test(t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+
+ animation.onfinish = event => {
+ scroller.scrollTop = 0;
+ window.requestAnimationFrame(function() {
+ window.requestAnimationFrame(function() {
+ scroller.scrollTop = maxScroll;
+ });
+ });
+ animation.onfinish = event => {
+ t.done();
+ };
+ };
+ scroller.scrollTop = maxScroll;
+}, 'Animation finish event is fired again after seeking back to start');
+
+async_test(t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+
+ animation.onfinish = event => {
+ scroller.scrollTop = 0;
+ window.requestAnimationFrame(function() {
+ animation.play();
+ scroller.scrollTop = maxScroll;
+ animation.onfinish = event => {
+ t.done();
+ };
+ });
+ };
+ scroller.scrollTop = maxScroll;
+}, 'Animation finish event is fired again after replaying from start');
+
+async_test(t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ anim.effect.updateTiming({ duration: 800, endDelay: 200});
+
+ anim.onfinish = t.step_func(event => {
+ assert_unreached('finish event should not be fired');
+ });
+ anim.play();
+ anim.ready.then(() => {
+ scroller.scrollTop = 0.9 * maxScroll;
+ return waitForAnimationFrames(3);
+ }).then(t.step_func(() => {
+ t.done();
+ }));
+}, 'finish event is not fired at the end of the active interval when the ' +
+ 'endDelay has not expired');
+
+async_test(t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ anim.effect.updateTiming({ duration: 800, endDelay: 100});
+ anim.play();
+ anim.ready.then(() => {
+ scroller.scrollTop = 0.95 * maxScroll; // during endDelay
+ anim.onfinish = t.step_func(event => {
+ assert_unreached('onfinish event should not be fired during endDelay');
+ });
+ return waitForAnimationFrames(2);
+ }).then(t.step_func(() => {
+ anim.onfinish = t.step_func(event => {
+ t.done();
+ });
+ scroller.scrollTop = maxScroll;
+ return waitForAnimationFrames(2);
+ }));
+}, 'finish event is fired after the endDelay has expired');
+
+</script>
+</body>