summaryrefslogtreecommitdiffstats
path: root/dom/animation/test/chrome/test_animation_observers_async.html
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
commit43a97878ce14b72f0981164f87f2e35e14151312 (patch)
tree620249daf56c0258faa40cbdcf9cfba06de2a846 /dom/animation/test/chrome/test_animation_observers_async.html
parentInitial commit. (diff)
downloadfirefox-upstream.tar.xz
firefox-upstream.zip
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'dom/animation/test/chrome/test_animation_observers_async.html')
-rw-r--r--dom/animation/test/chrome/test_animation_observers_async.html665
1 files changed, 665 insertions, 0 deletions
diff --git a/dom/animation/test/chrome/test_animation_observers_async.html b/dom/animation/test/chrome/test_animation_observers_async.html
new file mode 100644
index 0000000000..7505baca53
--- /dev/null
+++ b/dom/animation/test/chrome/test_animation_observers_async.html
@@ -0,0 +1,665 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>
+Test chrome-only MutationObserver animation notifications (async tests)
+</title>
+<!--
+
+ This file contains tests for animation mutation observers that require
+ some asynchronous steps (e.g. waiting for animation events).
+
+ Where possible, however, we prefer to write synchronous tests since they are
+ less to timeout when run on automation. These synchronous tests are located
+ in test_animation_observers_sync.html.
+
+-->
+<script type="application/javascript" src="../testharness.js"></script>
+<script type="application/javascript" src="../testharnessreport.js"></script>
+<script src="../testcommon.js"></script>
+<div id="log"></div>
+<style>
+@keyframes anim {
+ to { transform: translate(100px); }
+}
+@keyframes anotherAnim {
+ to { transform: translate(0px); }
+}
+#target {
+ width: 100px;
+ height: 100px;
+ background-color: yellow;
+ line-height: 16px;
+}
+</style>
+<div id=container><div id=target></div></div>
+<script>
+var div = document.getElementById("target");
+var gRecords = [];
+var gObserver = new MutationObserver(newRecords => {
+ gRecords.push(...newRecords);
+});
+
+function setupAsynchronousObserver(t, options) {
+
+ gRecords = [];
+ t.add_cleanup(() => {
+ gObserver.disconnect();
+ });
+ gObserver.observe(options.subtree ? div.parentNode : div,
+ { animations: true, subtree: options.subtree });
+}
+
+// Adds an event listener and returns a Promise that is resolved when the
+// event listener is called.
+function await_event(aElement, aEventName) {
+ return new Promise(aResolve => {
+ function listener(aEvent) {
+ aElement.removeEventListener(aEventName, listener);
+ aResolve();
+ }
+ aElement.addEventListener(aEventName, listener);
+ });
+}
+
+function assert_record_list(actual, expected, desc, index, listName) {
+ assert_equals(actual.length, expected.length,
+ `${desc} - record[${index}].${listName} length`);
+ if (actual.length != expected.length) {
+ return;
+ }
+ for (var i = 0; i < actual.length; i++) {
+ assert_not_equals(actual.indexOf(expected[i]), -1,
+ `${desc} - record[${index}].${listName} contains expected Animation`);
+ }
+}
+
+function assert_records(expected, desc) {
+ var records = gRecords;
+ gRecords = [];
+ assert_equals(records.length, expected.length, `${desc} - number of records`);
+ if (records.length != expected.length) {
+ return;
+ }
+ for (var i = 0; i < records.length; i++) {
+ assert_record_list(records[i].addedAnimations, expected[i].added, desc, i, "addedAnimations");
+ assert_record_list(records[i].changedAnimations, expected[i].changed, desc, i, "changedAnimations");
+ assert_record_list(records[i].removedAnimations, expected[i].removed, desc, i, "removedAnimations");
+ }
+}
+
+function assert_records_any_order(expected, desc) {
+ // Generate a unique label for each Animation object.
+ let animation_labels = new Map();
+ let animation_counter = 0;
+ for (let record of gRecords) {
+ for (let a of [...record.addedAnimations, ...record.changedAnimations, ...record.removedAnimations]) {
+ if (!animation_labels.has(a)) {
+ animation_labels.set(a, ++animation_counter);
+ }
+ }
+ }
+ for (let record of expected) {
+ for (let a of [...record.added, ...record.changed, ...record.removed]) {
+ if (!animation_labels.has(a)) {
+ animation_labels.set(a, ++animation_counter);
+ }
+ }
+ }
+
+ function record_label(record) {
+ // Generate a label of the form:
+ //
+ // <added-animations>:<changed-animations>:<removed-animations>
+ let added = record.addedAnimations || record.added;
+ let changed = record.changedAnimations || record.changed;
+ let removed = record.removedAnimations || record.removed;
+ return [added .map(a => animation_labels.get(a)).sort().join(),
+ changed.map(a => animation_labels.get(a)).sort().join(),
+ removed.map(a => animation_labels.get(a)).sort().join()]
+ .join(":");
+ }
+
+ // Sort records by their label.
+ gRecords.sort((a, b) => record_label(a) < record_label(b));
+ expected.sort((a, b) => record_label(a) < record_label(b));
+
+ // Assert the sorted record lists are equal.
+ assert_records(expected, desc);
+}
+
+// -- Tests ------------------------------------------------------------------
+
+// We run all tests first targeting the div and observing the div, then again
+// targeting the div and observing its parent while using the subtree:true
+// MutationObserver option.
+
+function runTest() {
+ [
+ { observe: div, target: div, subtree: false },
+ { observe: div.parentNode, target: div, subtree: true },
+ ].forEach(aOptions => {
+
+ var e = aOptions.target;
+
+ promise_test(t => {
+ setupAsynchronousObserver(t, aOptions);
+ // Clear all styles once test finished since we re-use the same element
+ // in all test cases.
+ t.add_cleanup(() => {
+ e.style = "";
+ flushComputedStyle(e);
+ });
+
+ // Start a transition.
+ e.style = "transition: background-color 100s; background-color: lime;";
+
+ // Register for the end of the transition.
+ var transitionEnd = await_event(e, "transitionend");
+
+ // The transition should cause the creation of a single Animation.
+ var animations = e.getAnimations();
+ assert_equals(animations.length, 1,
+ "getAnimations().length after transition start");
+
+ // Wait for the single MutationRecord for the Animation addition to
+ // be delivered.
+ return waitForFrame().then(() => {
+ assert_records([{ added: animations, changed: [], removed: [] }],
+ "records after transition start");
+
+ // Advance until near the end of the transition, then wait for it to
+ // finish.
+ animations[0].currentTime = 99900;
+ }).then(() => {
+ return transitionEnd;
+ }).then(() => {
+ // After the transition has finished, the Animation should disappear.
+ assert_equals(e.getAnimations().length, 0,
+ "getAnimations().length after transition end");
+
+ // Wait for the change MutationRecord for seeking the Animation to be
+ // delivered, followed by the the removal MutationRecord.
+ return waitForFrame();
+ }).then(() => {
+ assert_records([{ added: [], changed: animations, removed: [] },
+ { added: [], changed: [], removed: animations }],
+ "records after transition end");
+ });
+ }, `single_transition ${aOptions.subtree ? ': subtree' : ''}`);
+
+ // Test that starting a single animation that completes normally
+ // dispatches an added notification and then a removed notification.
+ promise_test(t => {
+ setupAsynchronousObserver(t, aOptions);
+ t.add_cleanup(() => {
+ e.style = "";
+ flushComputedStyle(e);
+ });
+
+ // Start an animation.
+ e.style = "animation: anim 100s;";
+
+ // Register for the end of the animation.
+ var animationEnd = await_event(e, "animationend");
+
+ // The animation should cause the creation of a single Animation.
+ var animations = e.getAnimations();
+ assert_equals(animations.length, 1,
+ "getAnimations().length after animation start");
+
+ // Wait for the single MutationRecord for the Animation addition to
+ // be delivered.
+ return waitForFrame().then(() => {
+ assert_records([{ added: animations, changed: [], removed: [] }],
+ "records after animation start");
+
+ // Advance until near the end of the animation, then wait for it to finish.
+ animations[0].currentTime = 99900;
+ return animationEnd;
+ }).then(() => {
+ // After the animation has finished, the Animation should disappear.
+ assert_equals(e.getAnimations().length, 0,
+ "getAnimations().length after animation end");
+
+ // Wait for the change MutationRecord from seeking the Animation to
+ // be delivered, followed by a further MutationRecord for the Animation
+ // removal.
+ return waitForFrame();
+ }).then(() => {
+ assert_records([{ added: [], changed: animations, removed: [] },
+ { added: [], changed: [], removed: animations }],
+ "records after animation end");
+ });
+ }, `single_animation ${aOptions.subtree ? ': subtree' : ''}`);
+
+ // Test that starting a single animation that is cancelled by updating
+ // the animation-fill-mode property dispatches an added notification and
+ // then a removed notification.
+ promise_test(t => {
+ setupAsynchronousObserver(t, aOptions);
+ t.add_cleanup(() => {
+ e.style = "";
+ flushComputedStyle(e);
+ });
+
+ // Start a short, filled animation.
+ e.style = "animation: anim 100s forwards;";
+
+ // Register for the end of the animation.
+ var animationEnd = await_event(e, "animationend");
+
+ // The animation should cause the creation of a single Animation.
+ var animations = e.getAnimations();
+ assert_equals(animations.length, 1,
+ "getAnimations().length after animation start");
+
+ // Wait for the single MutationRecord for the Animation addition to
+ // be delivered.
+ return waitForFrame().then(() => {
+ assert_records([{ added: animations, changed: [], removed: [] }],
+ "records after animation start");
+
+ // Advance until near the end of the animation, then wait for it to finish.
+ animations[0].currentTime = 99900;
+ return animationEnd;
+ }).then(() => {
+ // The only MutationRecord at this point should be the change from
+ // seeking the Animation.
+ assert_records([{ added: [], changed: animations, removed: [] }],
+ "records after animation starts filling");
+
+ // Cancel the animation by setting animation-fill-mode.
+ e.style.animationFillMode = "none";
+ // Explicitly flush style to make sure the above style change happens.
+ // Normally we don't need explicit style flush if there is a waitForFrame()
+ // call but in this particular case we are in the middle of animation events'
+ // callback handling and requestAnimationFrame handling so that we have no
+ // chance to process styling even after the requestAnimationFrame handling.
+ flushComputedStyle(e);
+
+ // Wait for the single MutationRecord for the Animation removal to
+ // be delivered.
+ return waitForFrame();
+ }).then(() => {
+ assert_records([{ added: [], changed: [], removed: animations }],
+ "records after animation end");
+ });
+ }, `single_animation_cancelled_fill ${aOptions.subtree ? ': subtree' : ''}`);
+
+ // Test that calling finish() on a paused (but otherwise finished) animation
+ // dispatches a changed notification.
+ promise_test(t => {
+ setupAsynchronousObserver(t, aOptions);
+ t.add_cleanup(() => {
+ e.style = "";
+ flushComputedStyle(e);
+ });
+
+ // Start a long animation
+ e.style = "animation: anim 100s forwards";
+
+ // The animation should cause the creation of a single Animation.
+ var animations = e.getAnimations();
+ assert_equals(animations.length, 1,
+ "getAnimations().length after animation start");
+
+ // Wait for the single MutationRecord for the Animation addition to
+ // be delivered.
+ return waitForFrame().then(() => {
+ assert_records([{ added: animations, changed: [], removed: [] }],
+ "records after animation start");
+
+ // Wait until the animation is playing.
+ return animations[0].ready;
+ }).then(() => {
+ // Finish and pause.
+ animations[0].finish();
+ animations[0].pause();
+
+ // Wait for the pause to complete.
+ return animations[0].ready;
+ }).then(() => {
+ assert_true(
+ !animations[0].pending && animations[0].playState === "paused",
+ "playState after finishing and pausing");
+
+ // We should have two MutationRecords for the Animation changes:
+ // one for the finish, one for the pause.
+ assert_records([{ added: [], changed: animations, removed: [] },
+ { added: [], changed: animations, removed: [] }],
+ "records after finish() and pause()");
+
+ // Call finish() again.
+ animations[0].finish();
+ assert_equals(animations[0].playState, "finished",
+ "playState after finishing from paused state");
+
+ // Wait for the single MutationRecord for the Animation change to
+ // be delivered. Even though the currentTime does not change, the
+ // playState will change.
+ return waitForFrame();
+ }).then(() => {
+ assert_records([{ added: [], changed: animations, removed: [] }],
+ "records after finish() and pause()");
+
+ // Cancel the animation.
+ e.style = "";
+
+ // Wait for the single removal notification.
+ return waitForFrame();
+ }).then(() => {
+ assert_records([{ added: [], changed: [], removed: animations }],
+ "records after animation end");
+ });
+ }, `finish_from_pause ${aOptions.subtree ? ': subtree' : ''}`);
+
+ // Test that calling play() on a paused Animation dispatches a changed
+ // notification.
+ promise_test(t => {
+ setupAsynchronousObserver(t, aOptions);
+ t.add_cleanup(() => {
+ e.style = "";
+ flushComputedStyle(e);
+ });
+
+ // Start a long, paused animation
+ e.style = "animation: anim 100s paused";
+
+ // The animation should cause the creation of a single Animation.
+ var animations = e.getAnimations();
+ assert_equals(animations.length, 1,
+ "getAnimations().length after animation start");
+
+ // Wait for the single MutationRecord for the Animation addition to
+ // be delivered.
+ return waitForFrame().then(() => {
+ assert_records([{ added: animations, changed: [], removed: [] }],
+ "records after animation start");
+
+ // Wait until the animation is ready
+ return animations[0].ready;
+ }).then(() => {
+ // Play
+ animations[0].play();
+
+ // Wait for the single MutationRecord for the Animation change to
+ // be delivered.
+ return animations[0].ready;
+ }).then(() => {
+ assert_records([{ added: [], changed: animations, removed: [] }],
+ "records after play()");
+
+ // Redundant play
+ animations[0].play();
+
+ // Wait to ensure no change is dispatched
+ return waitForFrame();
+ }).then(() => {
+ assert_records([], "records after redundant play()");
+
+ // Cancel the animation.
+ e.style = "";
+
+ // Wait for the single removal notification.
+ return waitForFrame();
+ }).then(() => {
+ assert_records([{ added: [], changed: [], removed: animations }],
+ "records after animation end");
+ });
+ }, `play ${aOptions.subtree ? ': subtree' : ''}`);
+
+ // Test that a non-cancelling change to an animation followed immediately by a
+ // cancelling change will only send an animation removal notification.
+ promise_test(t => {
+ setupAsynchronousObserver(t, aOptions);
+ t.add_cleanup(() => {
+ e.style = "";
+ flushComputedStyle(e);
+ });
+
+ // Start a long animation.
+ e.style = "animation: anim 100s;";
+
+ // The animation should cause the creation of a single Animation.
+ var animations = e.getAnimations();
+ assert_equals(animations.length, 1,
+ "getAnimations().length after animation start");
+
+ // Wait for the single MutationRecord for the Animation addition to
+ // be delivered.
+ return waitForFrame().then(() => {;
+ assert_records([{ added: animations, changed: [], removed: [] }],
+ "records after animation start");
+
+ // Update the animation's delay such that it is still running.
+ e.style.animationDelay = "-1s";
+
+ // Then cancel the animation by updating its duration.
+ e.style.animationDuration = "0.5s";
+
+ // We should get a single removal notification.
+ return waitForFrame();
+ }).then(() => {
+ assert_records([{ added: [], changed: [], removed: animations }],
+ "records after animation end");
+ });
+ }, `coalesce_change_cancel ${aOptions.subtree ? ': subtree' : ''}`);
+
+ });
+}
+
+promise_test(t => {
+ setupAsynchronousObserver(t, { observe: div, subtree: true });
+ t.add_cleanup(() => {
+ div.style = "";
+ flushComputedStyle(div);
+ });
+
+ // Add style for pseudo elements
+ var extraStyle = document.createElement('style');
+ document.head.appendChild(extraStyle);
+ var sheet = extraStyle.sheet;
+ var rules = { ".before::before": "animation: anim 100s; content: '';",
+ ".after::after" : "animation: anim 100s, anim 100s; " +
+ "content: '';"};
+ for (var selector in rules) {
+ sheet.insertRule(selector + '{' + rules[selector] + '}',
+ sheet.cssRules.length);
+ }
+
+ // Create a tree with two children:
+ //
+ // div
+ // (::before)
+ // (::after)
+ // / \
+ // childA childB(::before)
+ var childA = document.createElement("div");
+ var childB = document.createElement("div");
+
+ div.appendChild(childA);
+ div.appendChild(childB);
+
+ // Start an animation on each (using order: childB, div, childA)
+ //
+ // We include multiple animations on some nodes so that we can test batching
+ // works as expected later in this test.
+ childB.style = "animation: anim 100s";
+ div.style = "animation: anim 100s, anim 100s, anim 100s";
+ childA.style = "animation: anim 100s, anim 100s";
+
+ // Start animations targeting to pseudo element of div and childB.
+ childB.classList.add("before");
+ div.classList.add("after");
+ div.classList.add("before");
+
+ // Check all animations we have in this document
+ var docAnims = document.getAnimations();
+ assert_equals(docAnims.length, 10, "total animations");
+
+ var divAnimations = div.getAnimations();
+ var childAAnimations = childA.getAnimations();
+ var childBAnimations = childB.getAnimations();
+
+ var divBeforeAnimations =
+ docAnims.filter(x => (x.effect.target == div &&
+ x.effect.pseudoElement == "::before"));
+ var divAfterAnimations =
+ docAnims.filter(x => (x.effect.target == div &&
+ x.effect.pseudoElement == "::after"));
+ var childBPseudoAnimations =
+ docAnims.filter(x => (x.effect.target == childB &&
+ x.effect.pseudoElement == "::before"));
+
+ var seekRecords;
+ // The order in which we get the corresponding records is currently
+ // based on the order we visit these nodes when updating styles.
+ //
+ // That is because we don't do any document-level batching of animation
+ // mutation records when we flush styles. We may introduce that in the
+ // future but for now all we are interested in testing here is that the
+ // right records are generated, but we allow them to occur in any order.
+ return waitForFrame().then(() => {
+ assert_records_any_order(
+ [{ added: divAfterAnimations, changed: [], removed: [] },
+ { added: childAAnimations, changed: [], removed: [] },
+ { added: childBAnimations, changed: [], removed: [] },
+ { added: childBPseudoAnimations, changed: [], removed: [] },
+ { added: divAnimations, changed: [], removed: [] },
+ { added: divBeforeAnimations, changed: [], removed: [] }],
+ "records after simultaneous animation start");
+
+ // The one case where we *do* currently perform document-level (or actually
+ // timeline-level) batching is when animations are updated from a refresh
+ // driver tick. In particular, this means that when animations finish
+ // naturally the removed records should be dispatched according to the
+ // position of the elements in the tree.
+
+ // First, flatten the set of animations. we put the animations targeting to
+ // pseudo elements last. (Actually, we don't care the order in the list.)
+ var animations = [ ...divAnimations,
+ ...childAAnimations,
+ ...childBAnimations,
+ ...divBeforeAnimations,
+ ...divAfterAnimations,
+ ...childBPseudoAnimations ];
+
+ // Fast-forward to *just* before the end of the animation.
+ animations.forEach(animation => animation.currentTime = 99999);
+
+ // Prepare the set of expected change MutationRecords, one for each
+ // animation that was seeked.
+ seekRecords = animations.map(
+ p => ({ added: [], changed: [p], removed: [] })
+ );
+
+ return await_event(div, "animationend");
+ }).then(() => {
+ // After the changed notifications, which will be dispatched in the order that
+ // the animations were seeked, we should get removal MutationRecords in order
+ // (div, div::before, div::after), childA, (childB, childB::before).
+ // Note: The animations targeting to the pseudo element are appended after
+ // the animations of its parent element.
+ divAnimations = [ ...divAnimations,
+ ...divBeforeAnimations,
+ ...divAfterAnimations ];
+ childBAnimations = [ ...childBAnimations, ...childBPseudoAnimations ];
+ assert_records(seekRecords.concat(
+ { added: [], changed: [], removed: divAnimations },
+ { added: [], changed: [], removed: childAAnimations },
+ { added: [], changed: [], removed: childBAnimations }),
+ "records after finishing");
+
+ // Clean up
+ div.classList.remove("before");
+ div.classList.remove("after");
+ div.style = "";
+ childA.remove();
+ childB.remove();
+ extraStyle.remove();
+ });
+}, "tree_ordering: subtree");
+
+// Test that animations removed by auto-removal trigger an event
+promise_test(async t => {
+ setupAsynchronousObserver(t, { observe: div, subtree: false });
+
+ // Start two animations such that one will be auto-removed
+ const animA = div.animate(
+ { opacity: 1 },
+ { duration: 100 * MS_PER_SEC, fill: 'forwards' }
+ );
+ const animB = div.animate(
+ { opacity: 1 },
+ { duration: 100 * MS_PER_SEC, fill: 'forwards' }
+ );
+
+ // Wait for the MutationRecords corresponding to each addition.
+ await waitForNextFrame();
+
+ assert_records(
+ [
+ { added: [animA], changed: [], removed: [] },
+ { added: [animB], changed: [], removed: [] },
+ ],
+ 'records after animation start'
+ );
+
+ // Finish the animations -- this should cause animA to be replaced, and
+ // automatically removed.
+ animA.finish();
+ animB.finish();
+
+ // Wait for the MutationRecords corresponding to the timing changes and the
+ // subsequent removal to be delivered.
+ await waitForNextFrame();
+
+ assert_records(
+ [
+ { added: [], changed: [animA], removed: [] },
+ { added: [], changed: [animB], removed: [] },
+ { added: [], changed: [], removed: [animA] },
+ ],
+ 'records after finishing'
+ );
+
+ // Restore animA.
+ animA.persist();
+
+ // Wait for the MutationRecord corresponding to the re-addition of animA.
+ await waitForNextFrame();
+
+ assert_records(
+ [{ added: [animA], changed: [], removed: [] }],
+ 'records after persisting'
+ );
+
+ // Tidy up
+ animA.cancel();
+ animB.cancel();
+
+ await waitForNextFrame();
+
+ assert_records(
+ [
+ { added: [], changed: [], removed: [animA] },
+ { added: [], changed: [], removed: [animB] },
+ ],
+ 'records after tidying up end'
+ );
+}, 'Animations automatically removed are reported');
+
+setup({explicit_done: true});
+SpecialPowers.pushPrefEnv(
+ {
+ set: [
+ ["dom.animations-api.autoremove.enabled", true],
+ ["dom.animations-api.implicit-keyframes.enabled", true],
+ ],
+ },
+ function() {
+ runTest();
+ done();
+ }
+);
+</script>