diff options
Diffstat (limited to 'dom/animation/test/chrome/test_animation_observers_async.html')
-rw-r--r-- | dom/animation/test/chrome/test_animation_observers_async.html | 665 |
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> |