Bug 1637281 [wpt PR 23533] - Enabled Web Animation APIs for scroll animations, a=testonly
authorOlga Gerchikov <gerchiko@microsoft.com>
Sat, 30 May 2020 09:50:02 +0000
changeset 597302 0f16ea25eded5cb8ca91f1795ccb1b3243441a8a
parent 597301 919781699601aa31022bd862f25afb11e121ec37
child 597303 0cc9ccac66c6883628e132aab1ac5e3e0041749a
push id13310
push userffxbld-merge
push dateMon, 29 Jun 2020 14:50:06 +0000
treeherdermozilla-beta@15a59a0afa5c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerstestonly
bugs1637281, 23533, 916117, 2194586, 771881
milestone78.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1637281 [wpt PR 23533] - Enabled Web Animation APIs for scroll animations, a=testonly Automatic update from web-platform-tests Enabled Web Animation APIs for scroll animations - Enabled pause(), reverse() and updatePlaybackRate() APIs for scroll animations. - Provided temporary fix for play animation procedure to accommodate reverse() and replaying finished animation cases. The fix reflects pending https://github.com/w3c/csswg-drafts/pull/5059. - Ported wpt/web-animations/timing-model/animations tests that exercise the APIs. - Implemented scroll animation specific tests. Bug: 916117 Change-Id: I689e7a6d1f220ee12e7f7a6f90e54340eaa33aee Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2194586 Reviewed-by: Kevin Ellis <kevers@chromium.org> Commit-Queue: Olga Gerchikov <gerchiko@microsoft.com> Cr-Commit-Position: refs/heads/master@{#771881} -- wpt-commits: 1afcb0f78cd1f6ec37d64f9bce4220102a6dc0a2 wpt-pr: 23533
testing/web-platform/tests/scroll-animations/cancel-animation.html
testing/web-platform/tests/scroll-animations/finish-animation.html
testing/web-platform/tests/scroll-animations/pause-animation.html
testing/web-platform/tests/scroll-animations/play-animation.html
testing/web-platform/tests/scroll-animations/reverse-animation.html
testing/web-platform/tests/scroll-animations/scroll-animation-inactive-timeline.html
testing/web-platform/tests/scroll-animations/scroll-timeline-phases.tentative.html
testing/web-platform/tests/scroll-animations/setting-start-time.html
testing/web-platform/tests/scroll-animations/testcommon.js
testing/web-platform/tests/scroll-animations/update-playback-rate.html
testing/web-platform/tests/scroll-animations/updating-the-finished-state.html
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/cancel-animation.html
@@ -0,0 +1,213 @@
+<!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;
+}
+
+.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.scrollSource;
+
+  // 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.scrollSource;
+
+  // 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>
\ No newline at end of file
--- a/testing/web-platform/tests/scroll-animations/finish-animation.html
+++ b/testing/web-platform/tests/scroll-animations/finish-animation.html
@@ -334,10 +334,160 @@
     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);
+    // Wait for new animation frame which allows the timeline to compute new
+    // current time.
+    await waitForNextFrame();
+    animation.pause();
+    await animation.ready;
+
+    animation.finish();
+
+    assert_equals(animation.playState, 'finished',
+                  'The play state of a paused animation should become ' +
+                  '"finished"');
+    assert_times_equal(animation.startTime,
+                      animation.timeline.currentTime - 1000,
+                      '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);
+    // Wait for new animation frame which allows the timeline to compute new
+    // current time.
+    await waitForNextFrame();
+    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_times_equal(animation.startTime,
+                      animation.timeline.currentTime - 1000 / 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);
+    // Wait for new animation frame which allows the timeline to compute new
+    // current time.
+    await waitForNextFrame();
+    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_times_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);
+    // Wait for new animation frame which allows the timeline to compute new
+    // current time.
+    await waitForNextFrame();
+    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);
+    // Wait for new animation frame which allows the timeline to compute new
+    // current time.
+    await waitForNextFrame();
+    animation.play();
+    await animation.ready;
+
+    animation.updatePlaybackRate(2);
+    assert_true(animation.pending);
+
+    animation.finish();
+    assert_false(animation.pending);
+    assert_equals(animation.playbackRate, 2);
+    assert_times_equal(animation.currentTime, 1000);
+  }, 'A pending playback rate should be applied immediately when an animation'
+    + ' is finished');
+
+  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;
+
+    animation.updatePlaybackRate(0);
+
+    assert_throws_dom('InvalidStateError', () => {
+      animation.finish();
+    });
+  }, 'An exception should be thrown if the effective playback rate is zero');
+
+  promise_test(async t => {
+    const animation = createScrollLinkedAnimation(t);
+    animation.effect.updateTiming({ iterations: Infinity });
+    // Wait for new animation frame which allows the timeline to compute new
+    // current time.
+    await waitForNextFrame();
+    animation.play();
+    animation.currentTime = 500;
+    animation.playbackRate = -1;
+    await animation.ready;
+
+    animation.updatePlaybackRate(1);
+
+    assert_throws_dom('InvalidStateError', () => {
+      animation.finish();
+    });
+  }, 'An exception should be thrown when finishing if the effective playback rate'
+    + ' is positive and the target effect end is infinity');
+
+  promise_test(async t => {
+    const animation = createScrollLinkedAnimation(t);
+    animation.effect.updateTiming({ iterations: Infinity });
+    // Wait for new animation frame which allows the timeline to compute new
+    // current time.
+    await waitForNextFrame();
+    animation.play();
+    await animation.ready;
+
+    animation.updatePlaybackRate(-1);
+
+    animation.finish();
+    // Should not have thrown
+  }, 'An exception is NOT thrown when finishing if the effective playback rate'
+    + ' is negative and the target effect end is infinity');
 </script>
 </body>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/pause-animation.html
@@ -0,0 +1,208 @@
+<!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;
+}
+
+.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();
+  await animation.ready;
+
+  const startTimeBeforePausing = animation.startTime;
+
+  animation.pause();
+  assert_equals(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);
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  animation.play();
+  await animation.ready;
+
+  animation.pause();
+  assert_not_equals(animation.startTime, null,
+                    'The start time is resolved when pause-pending');
+
+  animation.play();
+  assert_not_equals(animation.startTime, null,
+                    'The start time is preserved when a pause is aborted');
+}, 'Aborting a pause preserves the start 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();
+  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);
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  animation.play();
+  // Let animation start roughly half-way through
+  animation.currentTime = 500;
+  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 25s but if we correctly used the old playback rate the current time
+  // will be >= 50s.
+  assert_greater_than_equal(animation.currentTime, 500);
+}, '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.scrollSource;
+  const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  animation.play();
+  await animation.ready;
+
+  animation.pause();
+  await animation.ready;
+
+  const currentTimeAfterPausing = animation.currentTime;
+
+  scroller.scrollTop = 0.2 * maxScroll;
+  await waitForNextFrame();
+  assert_equals(animation.timeline.currentTime, 200,
+    'Sanity check timeline time changed');
+
+  assert_equals(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);
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  animation.play();
+
+  const originalReadyPromise = animation.ready;
+  animation.cancel();
+  assert_equals(animation.startTime, null);
+  assert_equals(animation.currentTime, null);
+
+  const readyPromise = animation.ready;
+  assert_true(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_equals(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.scrollSource;
+  // 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, 0,
+    'The start time is zero 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.scrollSource;
+  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_equals(animation.currentTime, 200,
+    '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>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/play-animation.html
@@ -0,0 +1,233 @@
+<!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;
+}
+
+.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.currentTime = 100;
+  assert_time_equals_literal(animation.currentTime, 100);
+  animation.play();
+  assert_time_equals_literal(animation.currentTime, 100);
+}, 'Playing a running animation leaves the current time unchanged');
+
+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_time_equals_literal(animation.currentTime, 1000);
+  animation.play();
+  assert_time_equals_literal(animation.currentTime, 0);
+}, 'Playing a finished animation seeks back to the start');
+
+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.playbackRate = -1;
+  animation.currentTime = 0;
+  assert_time_equals_literal(animation.currentTime, 0);
+  animation.play();
+  assert_time_equals_literal(animation.currentTime, 1000);
+}, 'Playing a finished and reversed animation seeks to end');
+
+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();
+
+  // Initiate a pause then abort it
+  animation.pause();
+  animation.play();
+
+  // Wait to return to running state
+  await animation.ready;
+
+  assert_equals(animation.currentTime, 0,
+              'After aborting a pause when finished, the current time should'
+              + ' jump back to the start of the animation');
+}, 'Playing a pause-pending but previously finished animation seeks back to'
+   + ' to the start');
+
+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();
+  await animation.ready;
+
+  animation.play();
+  assert_equals(animation.startTime, 0, 'start time is zero');
+}, 'Playing a finished animation clears the start 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.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);
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  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);
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  animation.play();
+  animation.currentTime = 500;
+  await animation.ready;
+
+  animation.pause();
+  await animation.ready;
+
+  const holdTime = animation.currentTime;
+
+  animation.play();
+  await animation.ready;
+
+  assert_equals(animation.startTime, animation.timeline.currentTime - holdTime);
+}, 'Resuming an animation from paused calculates start time from hold 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;
+
+  // 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);
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  animation.play();
+  animation.currentTime = -1000;
+  await animation.ready;
+
+  // Set pending playback rate to the opposite direction
+  animation.updatePlaybackRate(-1);
+  assert_true(animation.pending);
+  assert_equals(animation.playbackRate, 1);
+
+  // When we play, we should seek to the target end, NOT to zero (which
+  // is where we would seek to if we used the playbackRate of 1.
+  animation.play();
+  assert_time_equals_literal(animation.currentTime, 1000);
+}, 'A pending playback rate is used when determining auto-rewind behavior');
+
+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,
+                '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_time_equals_literal(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);
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  animation.play();
+  animation.playbackRate = -1;
+  animation.cancel();
+  assert_equals(animation.startTime, null,
+                'Start time should be unresolved');
+
+  const playTime = animation.timeline.currentTime;
+  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_times_equal(animation.startTime, 1000,
+                     'The start time of the playing animation should be set');
+}, 'Playing a canceled animation backwards sets the start time');
+
+</script>
+</body>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/reverse-animation.html
@@ -0,0 +1,329 @@
+<!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;
+}
+
+.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();
+  await animation.ready;
+
+  animation.currentTime = 500;
+  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);
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  animation.play();
+  animation.currentTime = 500;
+  animation.reverse();
+
+  assert_equals(animation.currentTime, 500,
+    'The current time should not change it is in the middle of ' +
+    'the animation duration');
+}, 'Reversing an animation maintains the same 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();
+  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);
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  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);
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  animation.play();
+  animation.currentTime = 2000;
+  animation.reverse();
+
+  assert_equals(animation.currentTime, 1000,
+    'reverse() should start playing from the animation effect end ' +
+    'if the playbackRate > 0 and the currentTime > effect end');
+}, 'Reversing an animation when playbackRate > 0 and currentTime > ' +
+   'effect end should make it play from the end');
+
+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 = -2000;
+  animation.reverse();
+
+  assert_equals(animation.currentTime, 1000,
+    'reverse() should start playing from the animation effect end ' +
+    'if the playbackRate > 0 and the currentTime < 0');
+}, 'Reversing an animation when playbackRate > 0 and currentTime < 0 ' +
+   'should make it play from the end');
+
+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.playbackRate = -1;
+  animation.currentTime = -2000;
+  animation.reverse();
+
+  assert_times_equal(animation.currentTime, 0,
+    'reverse() should start playing from the start of animation time ' +
+    'if the playbackRate < 0 and the currentTime < 0');
+}, 'Reversing an animation when playbackRate < 0 and currentTime < 0 ' +
+   'should make it play from the start');
+
+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.playbackRate = -1;
+  animation.currentTime = 2000;
+  animation.reverse();
+
+  assert_times_equal(animation.currentTime, 0,
+    'reverse() should start playing from the start of animation time ' +
+    'if the playbackRate < 0 and the currentTime > effect end');
+}, 'Reversing an animation when playbackRate < 0 and currentTime > effect ' +
+   'end should make it play from the start');
+
+promise_test(async t => {
+  const animation = createScrollLinkedAnimation(t);
+  animation.effect.updateTiming({ iterations: Infinity });
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  animation.play();
+
+  animation.currentTime = -2000;
+
+  assert_throws_dom('InvalidStateError',
+    () => { animation.reverse(); },
+    'reverse() should throw InvalidStateError ' +
+    'if the playbackRate > 0 and the currentTime < 0 ' +
+    'and the target effect is positive infinity');
+}, 'Reversing an animation when playbackRate > 0 and currentTime < 0 ' +
+   'and the target effect end is positive infinity should throw an exception');
+
+promise_test(async t => {
+  const animation = createScrollLinkedAnimation(t);
+  animation.effect.updateTiming({ iterations: Infinity });
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  animation.play();
+
+  animation.currentTime = -2000;
+
+  try { animation.reverse(); } catch(e) { }
+
+  assert_equals(animation.playbackRate, 1, 'playbackRate is unchanged');
+
+  await animation.ready;
+  assert_equals(animation.playbackRate, 1, 'playbackRate remains unchanged');
+}, 'When reversing throws an exception, the playback rate remains unchanged');
+
+promise_test(async t => {
+  const animation = createScrollLinkedAnimation(t);
+  animation.effect.updateTiming({ iterations: Infinity });
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  animation.play();
+
+  animation.currentTime = -2000;
+  animation.playbackRate = 0;
+
+  try {
+    animation.reverse();
+  } catch (e) {
+    assert_unreached(`Unexpected exception when calling reverse(): ${e}`);
+  }
+}, 'Reversing animation when playbackRate = 0 and currentTime < 0 ' +
+   'and the target effect end is positive infinity should NOT throw an ' +
+   'exception');
+
+promise_test(async t => {
+  const animation = createScrollLinkedAnimation(t);
+  animation.effect.updateTiming({ iterations: Infinity });
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  animation.play();
+
+  animation.playbackRate = -1;
+  animation.currentTime = -2000;
+  animation.reverse();
+
+  assert_times_equal(animation.currentTime, 0,
+    'reverse() should start playing from the start of animation time ' +
+    'if the playbackRate < 0 and the currentTime < 0 ' +
+    'and the target effect is positive infinity');
+}, 'Reversing an animation when playbackRate < 0 and currentTime < 0 ' +
+   'and the target effect end is positive infinity should make it play ' +
+   'from the start');
+
+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.playbackRate = 0;
+  animation.currentTime = 500;
+  animation.reverse();
+
+  await animation.ready;
+  assert_equals(animation.playbackRate, 0,
+    'reverse() should preserve playbackRate if the playbackRate == 0');
+  assert_times_equal(animation.currentTime, 500,
+    'reverse() should not affect the currentTime if the playbackRate == 0');
+}, 'Reversing when when playbackRate == 0 should preserve the current ' +
+   'time and playback rate');
+
+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.currentTime, null);
+
+  animation.reverse();
+  await animation.ready;
+
+  assert_times_equal(animation.startTime, 1000,
+    'animation.startTime should be at its effect end');
+  assert_times_equal(animation.currentTime, 1000,
+    'animation.currentTime should be at its effect end');
+}, 'Reversing an idle animation from starts playing the animation');
+
+promise_test(async t => {
+  const animation = createScrollLinkedAnimation(t);
+  const scroller = animation.timeline.scrollSource;
+  // 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_throws_dom('InvalidStateError', () => { animation.reverse(); });
+}, 'Reversing an animation without an active timeline throws an ' +
+   'InvalidStateError');
+
+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 = 500;
+  animation.pause();
+
+  await animation.ready;
+
+  animation.reverse();
+  await animation.ready;
+
+  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);
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  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');
+
+promise_test(async t => {
+  const animation = createScrollLinkedAnimation(t);
+  animation.effect.updateTiming({ iterations: Infinity });
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  animation.play();
+  animation.currentTime = -200 * MS_PER_SEC;
+  await animation.ready;
+
+  animation.updatePlaybackRate(2);
+  assert_throws_dom('InvalidStateError', () => { animation.reverse(); });
+  assert_equals(animation.playbackRate, 1);
+
+  await animation.ready;
+  assert_equals(animation.playbackRate, 2);
+}, 'When reversing fails, it should restore any previous pending playback'
+   + ' rate');
+</script>
+</body>
\ No newline at end of file
--- a/testing/web-platform/tests/scroll-animations/scroll-animation-inactive-timeline.html
+++ b/testing/web-platform/tests/scroll-animations/scroll-animation-inactive-timeline.html
@@ -7,16 +7,17 @@
 <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';
@@ -24,16 +25,18 @@
 promise_test(async t => {
     const animation = createScrollLinkedAnimation(t);
     const scroller = animation.timeline.scrollSource;
     // 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, 0,
       'The start time is zero in Pending state.');
     await waitForNextFrame();
     assert_true(animation.pending,
@@ -45,16 +48,18 @@ promise_test(async t => {
 promise_test(async t => {
     const animation = createScrollLinkedAnimation(t);
     const scroller = animation.timeline.scrollSource;
     // 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_equals(animation.currentTime, 0,
@@ -65,19 +70,22 @@ promise_test(async t => {
    'activated after animation.play call.');
 
 promise_test(async t => {
     const animation = createScrollLinkedAnimation(t);
     const scroller = animation.timeline.scrollSource;
     const target = animation.effect.target;
     // 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 = 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
@@ -110,16 +118,18 @@ promise_test(async t => {
     // 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_equals(animation.startTime, 0,
       'Start time is zero when the timeline is inactive.');
     assert_equals(
       animation.effect.getComputedTiming().localTime,
@@ -141,37 +151,9 @@ promise_test(async t => {
     assert_times_equal(
       animation.effect.getComputedTiming().localTime,
       200,
       '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.');
-
-promise_test(async t => {
-  const animation = createScrollLinkedAnimation(t);
-  const scroller = animation.timeline.scrollSource;
-  scroller.scrollTop;
-
-  // 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();
-
-  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>
\ No newline at end of file
--- a/testing/web-platform/tests/scroll-animations/scroll-timeline-phases.tentative.html
+++ b/testing/web-platform/tests/scroll-animations/scroll-timeline-phases.tentative.html
@@ -188,27 +188,30 @@
     // active.
     assert_equals(timeline.phase, "active");
   }, 'Scroll timeline phase should be active when at scroll maximum and ' +
     'endScrollOffset is equal to maximum scroll offset.');
 
   promise_test(async t => {
     const timeline = createScrollTimeline(t);
     const scroller = timeline.scrollSource;
-    // Timeline should be inactive since layout hasn't updated yet
+    // Setting the scroller to display none should make the timeline inactive
+    scroller.style.display = "none";
+    scroller.scrollTop;
+    await waitForNextFrame();
     assert_equals(timeline.phase, "inactive");
 
-    // Accessing scroller.scrollHeight forces the scroller to update
-    scroller.scrollHeight;
-    // Wait for new animation frame which allows the timeline to compute new
-    // current time.
+    // Setting the scroller to display "block" should make the timeline active
+    scroller.style.display = "block";
+    scroller.scrollTop;
     await waitForNextFrame();
     assert_equals(timeline.phase, "active");
 
     // Setting the scroller to display none should make the timeline inactive
-    scroller.style.display = "none"
+    scroller.style.display = "none";
+    scroller.scrollTop;
     await waitForNextFrame();
 
     assert_equals(timeline.phase, "inactive");
   }, 'Scroll timeline starts inactive, can transition to active, and then' +
   ' back to inactive.');
 
 </script>
\ No newline at end of file
--- a/testing/web-platform/tests/scroll-animations/setting-start-time.html
+++ b/testing/web-platform/tests/scroll-animations/setting-start-time.html
@@ -8,32 +8,33 @@
 <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.scrollSource;
   const maxScroll = scroller.scrollHeight - scroller.clientHeight;
   scroller.scrollTop = 0.2 * maxScroll;
-  // Wait for new animation frame  which allows the timeline to compute new
+  // 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
@@ -55,17 +56,17 @@ promise_test(async t => {
                 'start time');
 }, 'Setting the start time clears the hold time');
 
 promise_test(async t => {
   const animation = createScrollLinkedAnimation(t);
   const scroller = animation.timeline.scrollSource;
   // Make the scroll timeline inactive.
   scroller.style.overflow = 'visible';
-  // Wait for new animation frame  which allows the timeline to compute new
+  // 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.
 
@@ -90,17 +91,17 @@ promise_test(async t => {
 }, '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.scrollSource;
   const maxScroll = scroller.scrollHeight - scroller.clientHeight;
   scroller.scrollTop = 0.2 * maxScroll;
 
-  // Wait for new animation frame  which allows the timeline to compute new
+  // 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 = 50;
   assert_equals(animation.playState, 'running');
   assert_times_equal(animation.startTime, 50,
@@ -119,17 +120,17 @@ promise_test(async t => {
                 + ' start time');
 }, 'Setting an unresolved start time sets the hold time');
 
 promise_test(async t => {
   const animation = createScrollLinkedAnimation(t);
   const scroller = animation.timeline.scrollSource;
   // Make the scroll timeline inactive.
   scroller.style.overflow = 'visible';
-  // Wait for new animation frame  which allows the timeline to compute new
+  // 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 = 50;
@@ -151,17 +152,17 @@ promise_test(async t => {
                 '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
+  // 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();
@@ -185,17 +186,17 @@ promise_test(async t => {
               '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.scrollSource;
   // Make the scroll timeline inactive.
   scroller.style.overflow = 'visible';
-  // Wait for new animation frame  which allows the timeline to compute new
+  // 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; } );
 
@@ -220,17 +221,17 @@ promise_test(async t => {
   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
+  // 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');
@@ -244,17 +245,17 @@ promise_test(async t => {
 }, 'Setting an unresolved start time on a play-pending animation makes it'
    + ' paused');
 
 promise_test(async t => {
   const animation = createScrollLinkedAnimation(t);
   const scroller = animation.timeline.scrollSource;
   // Make the scroll timeline inactive.
   scroller.style.overflow = 'visible';
-  // Wait for new animation frame  which allows the timeline to compute new
+  // 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');
 
   // Put the animation in the play-pending state
   animation.play();
 
@@ -267,17 +268,17 @@ promise_test(async t => {
   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 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
+  // 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 = -1100;
   assert_times_equal(animation.startTime, -1100,
                      'The start time is set to the requested value');
   assert_equals(animation.playState, 'finished',
@@ -296,17 +297,17 @@ promise_test(async t => {
   await waitForNextFrame();
   assert_equals(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);
-  // Wait for new animation frame  which allows the timeline to compute new
+  // Wait for new animation frame which allows the timeline to compute new
   // current time.
   await waitForNextFrame();
   animation.play();
 
   await animation.ready;
   assert_equals(animation.playState, 'running');
 
   // Setting the start time updates the finished state. The hold time is not
@@ -314,27 +315,107 @@ promise_test(async t => {
   animation.startTime = -1100;
   assert_equals(animation.playState, 'finished');
 
   assert_times_equal(animation.currentTime, 1100);
 }, 'Setting the start time on a running animation updates the play state');
 
 promise_test(async t => {
   const animation = createScrollLinkedAnimation(t);
-  // Wait for new animation frame  which allows the timeline to compute new
+  // Wait for new animation frame which allows the timeline to compute new
   // current time.
   await waitForNextFrame();
   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 = 1000;
   assert_equals(animation.playState, 'finished', 'Animation is finished');
   animation.playbackRate = -1;
   assert_equals(animation.playState, 'running', 'Animation is running');
   animation.startTime = -2000;
   assert_equals(animation.playState, 'finished', 'Animation is finished');
   assert_times_equal(animation.currentTime, -2000);
 }, 'Setting the start time on a reverse running animation updates the play '
    + 'state');
+   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; } );
+  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 = anim.timeline.currentTime - 25 * MS_PER_SEC;
+  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_times_equal(anim.startTime,
+                     anim.timeline.currentTime - 25 * MS_PER_SEC);
+  assert_time_equals_literal(anim.currentTime, 50 * MS_PER_SEC);
+}, 'Setting the start time of a play-pending animation applies a pending playback rate');
+
+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();
+  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 = anim.timeline.currentTime - 250;
+  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_times_equal(anim.startTime,
+                     anim.timeline.currentTime - 250);
+  assert_time_equals_literal(parseInt(anim.currentTime.toPrecision(5), 10), 500);
+}, 'Setting the start time of a playing animation applies a pending playback rate');
 </script>
 </body>
--- a/testing/web-platform/tests/scroll-animations/testcommon.js
+++ b/testing/web-platform/tests/scroll-animations/testcommon.js
@@ -1,12 +1,14 @@
 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 createScrollTimeline(test, options) {
   options = options || {
     scrollSource: createScroller(test),
     timeRange: 1000
   }
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/update-playback-rate.html
@@ -0,0 +1,180 @@
+<!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;
+}
+
+.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();
+  await animation.ready;
+
+  animation.currentTime = 500;
+
+  animation.updatePlaybackRate(0.5);
+  await animation.ready;
+  assert_times_equal(animation.currentTime, 500,
+    'Reducing the playback rate should not change the current time ' +
+    'of a playing animation');
+
+  animation.updatePlaybackRate(2);
+  await animation.ready;
+  assert_times_equal(animation.currentTime, 500,
+    '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 = 500;
+  assert_true(animation.pending);
+
+  animation.updatePlaybackRate(0.5);
+
+  // Check that the hold time is updated as expected
+  assert_times_equal(animation.currentTime, 500);
+
+  await animation.ready;
+
+  // As above, check that the currentTime is not calculated by simply
+  // substituting in the updated playbackRate without updating the startTime.
+  assert_times_equal(animation.currentTime, 500,
+    '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 = 500;
+  await animation.ready;
+
+  animation.pause();
+  animation.updatePlaybackRate(0.5);
+
+  assert_times_equal(animation.currentTime, 500);
+}, '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_time_equals_literal(animation.currentTime, 1000);
+  assert_false(animation.pending);
+
+  animation.updatePlaybackRate(2);
+  assert_equals(animation.playbackRate, 2);
+  assert_time_equals_literal(animation.currentTime, 1000);
+  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_time_equals_literal(animation.currentTime, 1000);
+  assert_false(animation.pending);
+
+  animation.updatePlaybackRate(0);
+  assert_equals(animation.playbackRate, 0);
+  assert_time_equals_literal(animation.currentTime, 1000);
+  assert_false(animation.pending);
+}, 'Updating the playback rate to zero on a finished animation maintains'
+   + ' the current time');
+
+</script>
+</body>
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/updating-the-finished-state.html
@@ -0,0 +1,673 @@
+<!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;
+}
+
+.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 = false
+promise_test(async t => {
+  const anim = createScrollLinkedAnimation(t);
+  // Set duration to half of scroll timeline timeRange.
+  anim.effect.updateTiming({ duration: 500 });
+  const scroller = anim.timeline.scrollSource;
+  const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  anim.play();
+  // Here and in the following tests we wait until ready resolves as
+  // otherwise we don't have a resolved start time. We test the case
+  // where the start time is unresolved in a subsequent test.
+  await anim.ready;
+
+  scroller.scrollTop = 0.7 * maxScroll;
+  await waitForNextFrame();
+
+  assert_equals(anim.currentTime, 500,
+                'Hold time is set to target end clamping current time');
+}, 'Updating the finished state when playing past end');
+
+// Did seek = true
+promise_test(async t => {
+  const anim = createScrollLinkedAnimation(t);
+  const scroller = anim.timeline.scrollSource;
+  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 = 2000;
+  scroller.scrollTop = 0.7 * maxScroll;
+  await waitForNextFrame();
+
+  assert_equals(anim.currentTime, 2000,
+                '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.scrollSource;
+  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_equals(anim.currentTime, 1000,
+                '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);
+  // Set duration to half of scroll timeline timeRange.
+  anim.effect.updateTiming({ duration: 500 });
+  const scroller = anim.timeline.scrollSource;
+  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 = 500;
+  scroller.scrollTop = 0.7 * maxScroll;
+  await waitForNextFrame();
+
+  assert_equals(anim.currentTime, 500,
+                '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);
+  // Set duration to half of scroll timeline timeRange.
+  anim.effect.updateTiming({ duration: 500 });
+  const scroller = anim.timeline.scrollSource;
+  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 = 1;
+  scroller.scrollTop = 0.2 * maxScroll;
+  await waitForNextFrame();
+
+  assert_equals(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.scrollSource;
+  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 = -1000;
+  scroller.scrollTop = 0.2 * maxScroll;
+  await waitForNextFrame();
+
+  assert_equals(anim.currentTime, -1000,
+                '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.scrollSource;
+  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_equals(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.scrollSource;
+  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 = 0;
+
+  scroller.scrollTop = 0.2 * maxScroll;
+  await waitForNextFrame();
+
+  assert_equals(anim.currentTime, 0 * MS_PER_SEC,
+                '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 = false; playback rate > 0
+promise_test(async t => {
+  const anim = createScrollLinkedAnimation(t);
+  const scroller = anim.timeline.scrollSource;
+  const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  anim.play();
+
+  // We want to test that the hold time is cleared so first we need to
+  // put the animation in a state where the hold time is set.
+  anim.finish();
+  await anim.ready;
+
+  assert_equals(anim.currentTime, 1000,
+                'Hold time is initially set');
+
+  // Then extend the duration so that the hold time is cleared and on
+  // the next tick the current time will increase.
+  anim.effect.updateTiming({
+    duration: anim.effect.getComputedTiming().duration * 2,
+  });
+  scroller.scrollTop = 0.2 * maxScroll;
+  await waitForNextFrame();
+  assert_equals(anim.currentTime, 1200,
+                'Hold time is not set so current time should increase');
+}, 'Updating the finished state when playing before end');
+
+
+// Did seek = true; playback rate > 0
+promise_test(async t => {
+  const anim = createScrollLinkedAnimation(t);
+  const scroller = anim.timeline.scrollSource;
+  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_equals(anim.startTime, -1000);
+
+  anim.currentTime = 500;
+  // 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_equals(anim.currentTime, 500, 'Start time is updated');
+  assert_equals(anim.startTime, -500);
+
+  scroller.scrollTop = 0.2 * maxScroll;
+  await waitForNextFrame();
+
+  assert_equals(anim.currentTime, 700,
+                '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.scrollSource;
+  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 = 500;
+  assert_equals(anim.startTime, 500, 'Start time is updated');
+  assert_equals(anim.currentTime, 500, 'Current time is updated');
+
+  scroller.scrollTop = 0.2 * maxScroll;
+  await waitForNextFrame();
+
+  assert_equals(anim.currentTime, 300,
+                '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.scrollSource;
+  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 = -1000;
+
+  scroller.scrollTop = 0.2 * maxScroll;
+  await waitForNextFrame();
+
+  assert_equals(anim.currentTime, -1000,
+                '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.scrollSource;
+  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 = 500;
+  scroller.scrollTop = 0.2 * maxScroll;
+  await waitForNextFrame();
+
+  assert_equals(anim.currentTime, 500,
+                '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.scrollSource;
+  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 = 2000;
+  scroller.scrollTop = 0.2 * maxScroll;
+  await waitForNextFrame();
+
+  assert_equals(anim.currentTime, 2000,
+                '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 6: has a pending 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();
+  anim.cancel();
+  anim.currentTime = 750;
+  anim.play();
+  // We now have a pending task and a resolved current time.
+  //
+  // In the next step we will adjust the timing so that the current time
+  // is greater than the target end. At this point the "update the finished
+  // state" procedure should run and if we fail to check for a pending task
+  // we will set the hold time to the target end, i.e. 50ms.
+  anim.effect.updateTiming({ duration: 500 });
+  assert_equals(anim.currentTime, 750,
+                'Hold time should not be updated');
+}, 'Updating the finished state when there is a pending task');
+
+// CASE 7: start time unresolved
+
+// Did seek = false
+promise_test(async t => {
+  const anim = createScrollLinkedAnimation(t);
+  const scroller = anim.timeline.scrollSource;
+  const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+  // Wait for new animation frame which allows the timeline to compute new
+  // current time.
+  await waitForNextFrame();
+  anim.cancel();
+  // Make it so that only the start time is unresolved (to avoid overlapping
+  // with the test case where current time is unresolved)
+  anim.currentTime = 1500;
+  // Trigger a change that will cause the "update the finished state"
+  // procedure to run (did seek = false).
+  anim.effect.updateTiming({ duration: 2000 });
+  scroller.scrollTop = 0.2 * maxScroll;
+  await waitForNextFrame();
+
+  assert_equals(anim.currentTime, 1500,
+                '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 = false');
+
+// 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 = 1500;
+  // Trigger a change that will cause the "update the finished state"
+  // procedure to run.
+  anim.currentTime = 500;
+  assert_equals(anim.currentTime, 500,
+                '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.scrollSource;
+  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 = 1000;
+  animation.currentTime = 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.scrollSource;
+  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.scrollSource;
+  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 = 1010;
+  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 = 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.scrollSource;
+  const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+  animation.play();
+  scroller.scrollTop = maxScroll;
+  const initialFinishedPromise = animation.finished;
+  await animation.finished;
+
+  animation.currentTime = 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.scrollSource;
+  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.scrollSource;
+  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.scrollSource;
+  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.scrollSource;
+  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.scrollSource;
+  const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+  anim.effect.updateTiming({ duration: 800,  endDelay: 100});
+  anim.play();
+  anim.ready.then(() => {
+    scroller.scrollTop = 0.85 * 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 = 0.95 * maxScroll;
+    return waitForAnimationFrames(2);
+  }));
+}, 'finish event is fired after the endDelay has expired');
+
+</script>
+</body>
\ No newline at end of file