diff options
Diffstat (limited to 'dom/animation/test/chrome')
12 files changed, 6756 insertions, 0 deletions
diff --git a/dom/animation/test/chrome/file_animate_xrays.html b/dom/animation/test/chrome/file_animate_xrays.html new file mode 100644 index 0000000000..2fa15b1764 --- /dev/null +++ b/dom/animation/test/chrome/file_animate_xrays.html @@ -0,0 +1,18 @@ +<!doctype html> +<html> +<head> +<meta charset=utf-8> +<script> +Element.prototype.animate = function() { + throw 'Called animate() as defined in content document'; +} +for (let name of ["KeyframeEffect", "Animation"]) { + this[name] = function() { + throw `Called overridden ${name} constructor`; + }; +} +</script> +<body> +<div id="target"></div> +</body> +</html> diff --git a/dom/animation/test/chrome/test_animate_xrays.html b/dom/animation/test/chrome/test_animate_xrays.html new file mode 100644 index 0000000000..64df6db720 --- /dev/null +++ b/dom/animation/test/chrome/test_animate_xrays.html @@ -0,0 +1,40 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1414674" + target="_blank">Mozilla Bug 1414674</a> +<div id="log"></div> +<iframe id="iframe" + src="http://example.org/tests/dom/animation/test/chrome/file_animate_xrays.html"></iframe> +<script> +'use strict'; + +var win = document.getElementById('iframe').contentWindow; + +async_test(function(t) { + window.addEventListener('load', t.step_func(function() { + var target = win.document.getElementById('target'); + var anim = target.animate({opacity: [ 1, 0 ]}, 100 * MS_PER_SEC); + // The frames object should be accessible via x-ray. + var frames = anim.effect.getKeyframes(); + assert_equals(frames.length, 2, + "frames for Element.animate should be non-zero"); + assert_equals(frames[0].opacity, "1", + "first frame opacity for Element.animate should be specified value"); + assert_equals(frames[0].computedOffset, 0, + "first frame offset for Element.animate should be 0"); + assert_equals(frames[1].opacity, "0", + "last frame opacity for Element.animate should be specified value"); + assert_equals(frames[1].computedOffset, 1, + "last frame offset for Element.animate should be 1"); + t.done(); + })); +}, 'Calling animate() across x-rays'); + +</script> +</body> 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..912d73a896 --- /dev/null +++ b/dom/animation/test/chrome/test_animation_observers_async.html @@ -0,0 +1,654 @@ +<!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 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(async 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. + await waitForFrame(); + + 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 ]; + + await Promise.all(animations.map(animation => animation.ready)); + + // 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: [] }) + ); + + await Promise.all(animations.map(animation => animation.finished)); + + // 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'); +runTest(); +</script> diff --git a/dom/animation/test/chrome/test_animation_observers_sync.html b/dom/animation/test/chrome/test_animation_observers_sync.html new file mode 100644 index 0000000000..ec760031e1 --- /dev/null +++ b/dom/animation/test/chrome/test_animation_observers_sync.html @@ -0,0 +1,1587 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title> +Test chrome-only MutationObserver animation notifications (sync tests) +</title> +<!-- + + This file contains synchronous tests for animation mutation observers. + + In general we prefer to write synchronous tests since they are less likely to + timeout when run on automation. Tests that require asynchronous steps (e.g. + waiting on events) should be added to test_animations_observers_async.html + instead. + +--> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +<div id="log"></div> +<style> +@keyframes anim { + to { transform: translate(100px); } +} +@keyframes anotherAnim { + to { transform: translate(0px); } +} +</style> +<script> + +/** + * Return a new MutationObserver which observing |target| element + * with { animations: true, subtree: |subtree| } option. + * + * NOTE: This observer should be used only with takeRecords(). If any of + * MutationRecords are observed in the callback of the MutationObserver, + * it will raise an assertion. + */ +function setupSynchronousObserver(t, target, subtree) { + var observer = new MutationObserver(records => { + assert_unreached("Any MutationRecords should not be observed in this " + + "callback"); + }); + t.add_cleanup(() => { + observer.disconnect(); + }); + observer.observe(target, { animations: true, subtree }); + return observer; +} + +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_equals_records(actual, expected, desc) { + assert_equals(actual.length, expected.length, `${desc} - number of records`); + if (actual.length != expected.length) { + return; + } + for (var i = 0; i < actual.length; i++) { + assert_record_list(actual[i].addedAnimations, + expected[i].added, desc, i, "addedAnimations"); + assert_record_list(actual[i].changedAnimations, + expected[i].changed, desc, i, "changedAnimations"); + assert_record_list(actual[i].removedAnimations, + expected[i].removed, desc, i, "removedAnimations"); + } +} + +function runTest() { + [ { subtree: false }, + { subtree: true } + ].forEach(aOptions => { + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, 200 * MS_PER_SEC); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.updateTiming({ duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after duration is changed"); + + anim.effect.updateTiming({ duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.currentTime = anim.effect.getComputedTiming().duration * 2; + anim.finish(); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.updateTiming({ + duration: anim.effect.getComputedTiming().duration * 3 + }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation restarted"); + + anim.effect.updateTiming({ duration: 'auto' }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after duration set \"auto\""); + + anim.effect.updateTiming({ duration: 'auto' }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value \"auto\""); + }, "change_duration_and_currenttime"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.updateTiming({ endDelay: 10 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after endDelay is changed"); + + anim.effect.updateTiming({ endDelay: 10 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.currentTime = 109 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after currentTime during endDelay"); + + anim.effect.updateTiming({ endDelay: -110 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning negative value"); + }, "change_enddelay_and_currenttime"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC, + endDelay: -100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [], "records after animation is added"); + }, "zero_end_time"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.updateTiming({ iterations: 2 }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after iterations is changed"); + + anim.effect.updateTiming({ iterations: 2 }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.effect.updateTiming({ iterations: 0 }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.updateTiming({ iterations: Infinity }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation restarted"); + }, "change_iterations"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.updateTiming({ delay: 100 }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after delay is changed"); + + anim.effect.updateTiming({ delay: 100 }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.effect.updateTiming({ delay: -100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.updateTiming({ delay: 0 }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation restarted"); + }, "change_delay"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC, + easing: "steps(2, start)" }); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.updateTiming({ easing: "steps(2, end)" }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after easing is changed"); + + anim.effect.updateTiming({ easing: "steps(2, end)" }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + }, "change_easing"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100, delay: -100 }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning negative value"); + }, "negative_delay_in_constructor"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var effect = new KeyframeEffect(null, + { opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + var anim = new Animation(effect, document.timeline); + anim.play(); + assert_equals_records(observer.takeRecords(), + [], "no records after animation is added"); + }, "create_animation_without_target"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.target = div; + assert_equals_records(observer.takeRecords(), + [], "no records after setting the same target"); + + anim.effect.target = null; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after setting null"); + + anim.effect.target = null; + assert_equals_records(observer.takeRecords(), + [], "records after setting redundant null"); + }, "set_redundant_animation_target"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect = null; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation is removed"); + }, "set_null_animation_effect"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = new Animation(); + anim.play(); + anim.effect = new KeyframeEffect(div, { opacity: [ 0, 1 ] }, + 100 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + }, "set_effect_on_null_effect_animation"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ marginLeft: [ "0px", "100px" ] }, + 100 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect = new KeyframeEffect(div, { opacity: [ 0, 1 ] }, + 100 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after replace effects"); + }, "replace_effect_targeting_on_the_same_element"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ marginLeft: [ "0px", "100px" ] }, + 100 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.currentTime = 60 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after animation is changed"); + + anim.effect = new KeyframeEffect(div, { opacity: [ 0, 1 ] }, + 50 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after replacing effects"); + }, "replace_effect_targeting_on_the_same_element_not_in_effect"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ }, 100 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.composite = "add"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after composite is changed"); + + anim.effect.composite = "add"; + assert_equals_records(observer.takeRecords(), + [], "no record after setting the same composite"); + + }, "set_composite"); + + // Test that starting a single animation that is cancelled by calling + // cancel() dispatches an added notification and then a removed + // notification. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s forwards" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].cancel(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + + // Re-trigger the animation. + animations[0].play(); + + // Single MutationRecord for the Animation (re-)addition. + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + }, "single_animation_cancelled_api"); + + // Test that updating a property on the Animation object dispatches a changed + // notification. + [ + { prop: "playbackRate", val: 0.5 }, + { prop: "startTime", val: 50 * MS_PER_SEC }, + { prop: "currentTime", val: 50 * MS_PER_SEC }, + ].forEach(aChangeTest => { + test(t => { + // We use a forwards fill mode so that even if the change we make causes + // the animation to become finished, it will still be "relevant" so we + // won't mark it as removed. + var div = addDiv(t, { style: "animation: anim 100s forwards" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Update the property. + animations[0][aChangeTest.prop] = aChangeTest.val; + + // Make a redundant change. + // eslint-disable-next-line no-self-assign + animations[0][aChangeTest.prop] = animations[0][aChangeTest.prop]; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after animation property change"); + }, `single_animation_api_change_${aChangeTest.prop}`); + }); + + // Test that making a redundant change to currentTime while an Animation + // is pause-pending still generates a change MutationRecord since setting + // the currentTime to any value in this state aborts the pending pause. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].pause(); + + // We are now pause-pending. Even if we make a redundant change to the + // currentTime, we should still get a change record because setting the + // currentTime while pause-pending has the effect of cancelling a pause. + // eslint-disable-next-line no-self-assign + animations[0].currentTime = animations[0].currentTime; + + // Two MutationRecords for the Animation changes: one for pausing, one + // for aborting the pause. + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after pausing then seeking"); + }, "change_currentTime_while_pause_pending"); + + // Test that calling finish() on a forwards-filling Animation dispatches + // a changed notification. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s forwards" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].finish(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after finish()"); + + // Redundant finish. + animations[0].finish(); + + // Ensure no change records. + assert_equals_records(observer.takeRecords(), + [], "records after redundant finish()"); + }, "finish_with_forwards_fill"); + + // Test that calling finish() on an Animation that does not fill forwards, + // dispatches a removal notification. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].finish(); + + // Single MutationRecord for the Animation removal. + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after finishing"); + }, "finish_without_fill"); + + // Test that calling finish() on a forwards-filling Animation dispatches + test(t => { + var div = addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animation = div.getAnimations()[0]; + assert_equals_records(observer.takeRecords(), + [{ added: [animation], changed: [], removed: []}], + "records after creation"); + animation.id = "new id"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [animation], removed: []}], + "records after id is changed"); + + animation.id = "new id"; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value with id"); + }, "change_id"); + + // Test that calling reverse() dispatches a changed notification. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s both" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].reverse(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after calling reverse()"); + }, "reverse"); + + // Test that calling reverse() does *not* dispatch a changed notification + // when playbackRate == 0. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s both" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Seek to the middle and set playbackRate to zero. + animations[0].currentTime = 50 * MS_PER_SEC; + animations[0].playbackRate = 0; + + // Two MutationRecords, one for each change. + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after seeking and setting playbackRate"); + + animations[0].reverse(); + + // We should get no notifications. + assert_equals_records(observer.takeRecords(), + [], "records after calling reverse()"); + }, "reverse_with_zero_playbackRate"); + + // Test that reverse() on an Animation does *not* dispatch a changed + // notification when it throws an exception. + test(t => { + // Start an infinite animation + var div = addDiv(t, { style: "animation: anim 10s infinite" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Shift the animation into the future such that when we call reverse + // it will try to seek to the (infinite) end. + animations[0].startTime = 100 * MS_PER_SEC; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after adjusting startTime"); + + // Reverse: should throw + assert_throws('InvalidStateError', () => { + animations[0].reverse(); + }, 'reverse() on future infinite animation throws an exception'); + + // We should get no notifications. + assert_equals_records(observer.takeRecords(), + [], "records after calling reverse()"); + }, "reverse_with_exception"); + + // Test that attempting to start an animation that should already be finished + // does not send any notifications. + test(t => { + // Start an animation that should already be finished. + var div = addDiv(t, { style: "animation: anim 1s -2s;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause no Animations to be created. + var animations = div.getAnimations(); + assert_equals(animations.length, 0, + "getAnimations().length after animation start"); + + // And we should get no notifications. + assert_equals_records(observer.takeRecords(), + [], "records after attempted animation start"); + }, "already_finished"); + + test(t => { + var div = addDiv(t, { style: "animation: anim 100s, anotherAnim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: []}], + "records after creation"); + + div.style.animation = "anotherAnim 100s, anim 100s"; + animations = div.getAnimations(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: []}], + "records after the order is changed"); + + div.style.animation = "anotherAnim 100s, anim 100s"; + + assert_equals_records(observer.takeRecords(), + [], "no records after applying the same order"); + }, "animtion_order_change"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC, + iterationComposite: 'replace' }); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.iterationComposite = 'accumulate'; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after iterationComposite is changed"); + + anim.effect.iterationComposite = 'accumulate'; + assert_equals_records(observer.takeRecords(), + [], "no record after setting the same iterationComposite"); + + }, "set_iterationComposite"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.setKeyframes({ opacity: 0.1 }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after keyframes are changed"); + + anim.effect.setKeyframes({ opacity: 0.1 }); + assert_equals_records(observer.takeRecords(), + [], "no record after setting the same keyframes"); + + anim.effect.setKeyframes(null); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after keyframes are set to empty"); + + }, "set_keyframes"); + + // Test that starting a single transition that is cancelled by resetting + // the transition-property property dispatches an added notification and + // then a removed notification. + test(t => { + var div = + addDiv(t, { style: "transition: background-color 100s; " + + "background-color: yellow;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + getComputedStyle(div).transitionProperty; + div.style.backgroundColor = "lime"; + + // The transition should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after transition start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after transition start"); + + // Cancel the transition by setting transition-property. + div.style.transitionProperty = "none"; + getComputedStyle(div).transitionProperty; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after transition end"); + }, "single_transition_cancelled_property"); + + // Test that starting a single transition that is cancelled by setting + // style to the currently animated value dispatches an added + // notification and then a removed notification. + test(t => { + // A long transition with a predictable value. + var div = + addDiv(t, { style: "transition: z-index 100s -51s; " + + "z-index: 10;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + getComputedStyle(div).transitionProperty; + div.style.zIndex = "100"; + + // The transition should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after transition start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after transition start"); + + // Cancel the transition by setting the current animation value. + let value = "83"; + assert_equals(getComputedStyle(div).zIndex, value, + "half-way transition value"); + div.style.zIndex = value; + getComputedStyle(div).transitionProperty; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after transition end"); + }, "single_transition_cancelled_value"); + + // Test that starting a single transition that is cancelled by setting + // style to a non-interpolable value dispatches an added notification + // and then a removed notification. + test(t => { + var div = + addDiv(t, { style: "transition: line-height 100s; " + + "line-height: 16px;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + getComputedStyle(div).transitionProperty; + div.style.lineHeight = "100px"; + + // The transition should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after transition start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after transition start"); + + // Cancel the transition by setting line-height to a non-interpolable value. + div.style.lineHeight = "normal"; + getComputedStyle(div).transitionProperty; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after transition end"); + }, "single_transition_cancelled_noninterpolable"); + + // Test that starting a single transition and then reversing it + // dispatches an added notification, then a simultaneous removed and + // added notification, then a removed notification once finished. + test(t => { + var div = + addDiv(t, { style: "transition: background-color 100s step-start; " + + "background-color: yellow;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + getComputedStyle(div).transitionProperty; + div.style.backgroundColor = "lime"; + + var animations = div.getAnimations(); + + // The transition should cause the creation of a single Animation. + assert_equals(animations.length, 1, + "getAnimations().length after transition start"); + + var firstAnimation = animations[0]; + assert_equals_records(observer.takeRecords(), + [{ added: [firstAnimation], changed: [], removed: [] }], + "records after transition start"); + + firstAnimation.currentTime = 50 * MS_PER_SEC; + + // Reverse the transition by setting the background-color back to its + // original value. + div.style.backgroundColor = "yellow"; + + // The reversal should cause the creation of a new Animation. + animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after transition reversal"); + + var secondAnimation = animations[0]; + + assert_true(firstAnimation != secondAnimation, + "second Animation should be different from the first"); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [firstAnimation], removed: [] }, + { added: [secondAnimation], changed: [], removed: [firstAnimation] }], + "records after transition reversal"); + + // Cancel the transition. + div.style.transitionProperty = "none"; + getComputedStyle(div).transitionProperty; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [secondAnimation] }], + "records after transition end"); + }, "single_transition_reversed"); + + // Test that multiple transitions starting and ending on an element + // at the same time get batched up into a single MutationRecord. + test(t => { + var div = + addDiv(t, { style: "transition-duration: 100s; " + + "transition-property: color, background-color, line-height" + + "background-color: yellow; line-height: 16px" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + getComputedStyle(div).transitionProperty; + + div.style.backgroundColor = "lime"; + div.style.color = "blue"; + div.style.lineHeight = "24px"; + + // The transitions should cause the creation of three Animations. + var animations = div.getAnimations(); + assert_equals(animations.length, 3, + "getAnimations().length after transition starts"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after transition starts"); + + assert_equals(animations.filter(p => p.playState == "running").length, 3, + "number of running Animations"); + + // Seek well into each animation. + animations.forEach(p => p.currentTime = 50 * MS_PER_SEC); + + // Prepare the set of expected change MutationRecords, one for each + // animation that was seeked. + var seekRecords = animations.map( + p => ({ added: [], changed: [p], removed: [] }) + ); + + // Cancel one of the transitions by setting transition-property. + div.style.transitionProperty = "background-color, line-height"; + + var colorAnimation = animations.filter(p => p.playState != "running"); + var otherAnimations = animations.filter(p => p.playState == "running"); + + assert_equals(colorAnimation.length, 1, + "number of non-running Animations after cancelling one"); + assert_equals(otherAnimations.length, 2, + "number of running Animations after cancelling one"); + + assert_equals_records(observer.takeRecords(), + seekRecords.concat({ added: [], changed: [], removed: colorAnimation }), + "records after color transition end"); + + // Cancel the remaining transitions. + div.style.transitionProperty = "none"; + getComputedStyle(div).transitionProperty; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: otherAnimations }], + "records after other transition ends"); + }, "multiple_transitions"); + + // Test that starting a single animation that is cancelled by resetting + // the animation-name property dispatches an added notification and + // then a removed notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Cancel the animation by setting animation-name. + div.style.animationName = "none"; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "single_animation_cancelled_name"); + + // Test that starting a single animation that is cancelled by updating + // the animation-duration property dispatches an added notification and + // then a removed notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Advance the animation by a second. + animations[0].currentTime += 1 * MS_PER_SEC; + + // Cancel the animation by setting animation-duration to a value less + // than a second. + div.style.animationDuration = "0.1s"; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }, + { added: [], changed: [], removed: animations }], + "records after animation end"); + }, "single_animation_cancelled_duration"); + + // Test that starting a single animation that is cancelled by updating + // the animation-delay property dispatches an added notification and + // then a removed notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Cancel the animation by setting animation-delay. + div.style.animationDelay = "-200s"; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "single_animation_cancelled_delay"); + + // Test that starting a single animation that is cancelled by updating + // the animation-iteration-count property dispatches an added notification + // and then a removed notification. + test(t => { + // A short, repeated animation. + var div = + addDiv(t, { style: "animation: anim 0.5s infinite;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Advance the animation until we are past the first iteration. + animations[0].currentTime += 1 * MS_PER_SEC; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after seeking animations"); + + // Cancel the animation by setting animation-iteration-count. + div.style.animationIterationCount = "1"; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "single_animation_cancelled_iteration_count"); + + // Test that updating an animation property dispatches a changed notification. + [ + { name: "duration", prop: "animationDuration", val: "200s" }, + { name: "timing", prop: "animationTimingFunction", val: "linear" }, + { name: "iteration", prop: "animationIterationCount", val: "2" }, + { name: "direction", prop: "animationDirection", val: "reverse" }, + { name: "state", prop: "animationPlayState", val: "paused" }, + { name: "delay", prop: "animationDelay", val: "-1s" }, + { name: "fill", prop: "animationFillMode", val: "both" }, + ].forEach(aChangeTest => { + test(t => { + // Start a long animation. + var div = addDiv(t, { style: "animation: anim 100s;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Change a property of the animation such that it keeps running. + div.style[aChangeTest.prop] = aChangeTest.val; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after animation change"); + + // Cancel the animation. + div.style.animationName = "none"; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, `single_animation_change_${aChangeTest.name}`); + }); + + // Test that calling finish() on a pause-pending (but otherwise finished) + // animation dispatches a changed notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s forwards" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Finish and pause. + animations[0].finish(); + animations[0].pause(); + assert_true(animations[0].pending && animations[0].playState === "paused", + "playState after finishing and calling pause()"); + + // Call finish() again to abort the pause + animations[0].finish(); + assert_equals(animations[0].playState, "finished", + "playState after finishing again"); + + // Wait for three MutationRecords for the Animation changes to + // be delivered: one for each finish(), pause(), finish() operation. + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after finish(), pause(), finish()"); + + // Cancel the animation. + div.style = ""; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "finish_from_pause_pending"); + + // Test that calling play() on a finished Animation that fills forwards + // dispatches a changed notification. + test(t => { + // Animation with a forwards fill + var div = + addDiv(t, { style: "animation: anim 100s forwards" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Seek to the end + animations[0].finish(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after finish()"); + + // Since we are filling forwards, calling play() should produce a + // change record since the animation remains relevant. + animations[0].play(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after play()"); + + // Cancel the animation. + div.style = ""; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "play_filling_forwards"); + + // Test that calling pause() on an Animation dispatches a changed + // notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Pause + animations[0].pause(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after pause()"); + + // Redundant pause + animations[0].pause(); + + assert_equals_records(observer.takeRecords(), + [], "records after redundant pause()"); + + // Cancel the animation. + div.style = ""; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "pause"); + + // Test that calling pause() on an Animation that is pause-pending + // does not dispatch an additional changed notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Pause + animations[0].pause(); + + // We are now pause-pending, but pause again + animations[0].pause(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after pause()"); + + // Cancel the animation. + div.style = ""; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "pause_while_pause_pending"); + + // Test that calling play() on an Animation that is pause-pending + // dispatches a changed notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Pause + animations[0].pause(); + + // We are now pause-pending. If we play() now, we will abort the pause + animations[0].play(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after aborting a pause()"); + + // Cancel the animation. + div.style = ""; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "aborted_pause"); + + // Test that calling play() on a finished Animation that does *not* fill + // forwards dispatches an addition notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Seek to the end + animations[0].finish(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after finish()"); + + // Since we are *not* filling forwards, calling play() is equivalent + // to creating a new animation since it becomes relevant again. + animations[0].play(); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after play()"); + + // Cancel the animation. + div.style = ""; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "play_after_finish"); + + }); + + test(t => { + var div = addDiv(t); + var observer = setupSynchronousObserver(t, div, true); + + var child = document.createElement("div"); + div.appendChild(child); + + var anim1 = div.animate({ marginLeft: [ "0px", "50px" ] }, + 100 * MS_PER_SEC); + var anim2 = child.animate({ marginLeft: [ "0px", "100px" ] }, + 50 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [anim1], changed: [], removed: [] }, + { added: [anim2], changed: [], removed: [] }], + "records after animation is added"); + + // After setting a new effect, we remove the current animation, anim1, + // because it is no longer attached to |div|, and then remove the previous + // animation, anim2. Finally, add back the anim1 which is in effect on + // |child| now. In addition, we sort them by tree order and they are + // batched. + anim1.effect = anim2.effect; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim1] }, // div + { added: [anim1], changed: [], removed: [anim2] }], // child + "records after animation effects are changed"); + }, "set_effect_with_previous_animation"); + + test(t => { + var div = addDiv(t); + var observer = setupSynchronousObserver(t, document, true); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + + var newTarget = document.createElement("div"); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.target = null; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after setting null"); + + anim.effect.target = div; + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after setting a target"); + + anim.effect.target = addDiv(t); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }, + { added: [anim], changed: [], removed: [] }], + "records after setting a different target"); + }, "set_animation_target"); + + test(t => { + var div = addDiv(t); + var observer = setupSynchronousObserver(t, div, true); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 200 * MS_PER_SEC, + pseudoElement: '::before' }); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.updateTiming({ duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after duration is changed"); + + anim.effect.updateTiming({ duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.currentTime = anim.effect.getComputedTiming().duration * 2; + anim.finish(); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.updateTiming({ + duration: anim.effect.getComputedTiming().duration * 3 + }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation restarted"); + + anim.effect.updateTiming({ duration: "auto" }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after duration set \"auto\""); + + anim.effect.updateTiming({ duration: "auto" }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value \"auto\""); + }, "change_duration_and_currenttime_on_pseudo_elements"); + + test(t => { + var div = addDiv(t); + var observer = setupSynchronousObserver(t, div, false); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + var pAnim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC, + pseudoElement: "::before" }); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.finish(); + pAnim.finish(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation is finished"); + }, "exclude_animations_targeting_pseudo_elements"); +} + +W3CTest.runner.expectAssertions(0, 12); // bug 1189015 +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { + set: [ + ["dom.animations-api.timelines.enabled", true], + ], + }, + function() { + runTest(); + done(); + } +); + +</script> diff --git a/dom/animation/test/chrome/test_animation_performance_warning.html b/dom/animation/test/chrome/test_animation_performance_warning.html new file mode 100644 index 0000000000..13df5d7842 --- /dev/null +++ b/dom/animation/test/chrome/test_animation_performance_warning.html @@ -0,0 +1,1693 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1196114 - Test metadata related to which animation properties + are running on the compositor</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +<style> +.compositable { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +@keyframes fade { + from { opacity: 1 } + to { opacity: 0 } +} +@keyframes translate { + from { transform: none } + to { transform: translate(100px) } +} +</style> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1196114" + target="_blank">Mozilla Bug 1196114</a> +<div id="log"></div> +<script> +'use strict'; + +// This is used for obtaining localized strings. +var gStringBundle; + +W3CTest.runner.requestLongerTimeout(2); + +const Services = SpecialPowers.Services; +Services.locale.requestedLocales = ["en-US"]; + +SpecialPowers.pushPrefEnv({ "set": [ + // Need to set devPixelsPerPx explicitly to gain + // consistent pixel values in warning messages + // regardless of platform DPIs. + ["layout.css.devPixelsPerPx", 1], + ["layout.animation.prerender.partial", false], + ] }, + start); + +function compare_property_state(a, b) { + if (a.property > b.property) { + return -1; + } else if (a.property < b.property) { + return 1; + } + if (a.runningOnCompositor != b.runningOnCompositor) { + return a.runningOnCompositor ? 1 : -1; + } + return a.warning > b.warning ? -1 : 1; +} + +function assert_animation_property_state_equals(actual, expected) { + assert_equals(actual.length, expected.length, 'Number of properties'); + + var sortedActual = actual.sort(compare_property_state); + var sortedExpected = expected.sort(compare_property_state); + + for (var i = 0; i < sortedActual.length; i++) { + assert_equals(sortedActual[i].property, + sortedExpected[i].property, + 'CSS property name should match'); + assert_equals(sortedActual[i].runningOnCompositor, + sortedExpected[i].runningOnCompositor, + 'runningOnCompositor property should match'); + if (sortedExpected[i].warning instanceof RegExp) { + assert_regexp_match(sortedActual[i].warning, + sortedExpected[i].warning, + 'warning message should match'); + } else if (sortedExpected[i].warning) { + assert_equals(sortedActual[i].warning, + gStringBundle.GetStringFromName(sortedExpected[i].warning), + 'warning message should match'); + } + } +} + +// Check that the animation is running on compositor and +// warning property is not set for the CSS property regardless +// expected values. +function assert_all_properties_running_on_compositor(actual, expected) { + assert_equals(actual.length, expected.length); + + var sortedActual = actual.sort(compare_property_state); + var sortedExpected = expected.sort(compare_property_state); + + for (var i = 0; i < sortedActual.length; i++) { + assert_equals(sortedActual[i].property, + sortedExpected[i].property, + 'CSS property name should match'); + assert_true(sortedActual[i].runningOnCompositor, + 'runningOnCompositor property should be true on ' + + sortedActual[i].property); + assert_not_exists(sortedActual[i], 'warning', + 'warning property should not be set'); + } +} + +function testBasicOperation() { + [ + { + desc: 'animations on compositor', + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true + } + ] + }, + { + desc: 'animations on main thread', + frames: { + zIndex: ['0', '999'] + }, + expected: [ + { + property: 'z-index', + runningOnCompositor: false + } + ] + }, + { + desc: 'animations on both threads', + frames: { + zIndex: ['0', '999'], + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'z-index', + runningOnCompositor: false + }, + { + property: 'transform', + runningOnCompositor: true + } + ] + }, + { + desc: 'two animation properties on compositor thread', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: true + } + ] + }, + { + desc: 'two transform-like animation properties on compositor thread', + frames: { + transform: ['translate(0px)', 'translate(100px)'], + translate: ['0px', '100px'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: true + }, + { + property: 'translate', + runningOnCompositor: true + } + ] + }, + { + desc: 'opacity on compositor with animation of geometric properties', + frames: { + width: ['100px', '200px'], + opacity: [0, 1] + }, + expected: [ + { + property: 'width', + runningOnCompositor: false + }, + { + property: 'opacity', + runningOnCompositor: true + } + ] + }, + ].forEach(subtest => { + promise_test(async t => { + var animation = addDivAndAnimate(t, { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + await waitForPaints(); + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + }, subtest.desc); + }); +} + +// Test adding/removing a 'width' property on the same animation object. +function testKeyframesWithGeometricProperties() { + [ + { + desc: 'transform', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: { + withoutGeometric: [ + { + property: 'transform', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false + }, + { + property: 'transform', + runningOnCompositor: true, + } + ] + } + }, + { + desc: 'translate', + frames: { + translate: ['0px', '100px'] + }, + expected: { + withoutGeometric: [ + { + property: 'translate', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false + }, + { + property: 'translate', + runningOnCompositor: true, + } + ] + } + }, + { + desc: 'opacity and transform-like properties', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'], + translate: ['0px', '100px'] + }, + expected: { + withoutGeometric: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: true + }, + { + property: 'translate', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false + }, + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: true, + }, + { + property: 'translate', + runningOnCompositor: true, + } + ] + } + }, + ].forEach(subtest => { + promise_test(async t => { + var animation = addDivAndAnimate(t, { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + await waitForPaints(); + + // First, a transform animation is running on compositor. + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected.withoutGeometric); + + // Add a 'width' property. + var keyframes = animation.effect.getKeyframes(); + + keyframes[0].width = '100px'; + keyframes[1].width = '200px'; + + animation.effect.setKeyframes(keyframes); + await waitForFrame(); + + // Now the transform animation is not running on compositor because of + // the 'width' property. + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected.withGeometric); + + // Remove the 'width' property. + var keyframes = animation.effect.getKeyframes(); + + delete keyframes[0].width; + delete keyframes[1].width; + + animation.effect.setKeyframes(keyframes); + await waitForFrame(); + + // Finally, the transform animation is running on compositor. + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected.withoutGeometric); + }, 'An animation has: ' + subtest.desc); + }); +} + +// Test that the expected set of geometric properties all block transform +// animations. +function testSetOfGeometricProperties() { + const geometricProperties = [ + 'width', 'height', + 'top', 'right', 'bottom', 'left', + 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'padding-top', 'padding-right', 'padding-bottom', 'padding-left' + ]; + + geometricProperties.forEach(property => { + promise_test(async t => { + const keyframes = { + [propertyToIDL(property)]: [ '100px', '200px' ], + transform: [ 'translate(0px)', 'translate(100px)' ] + }; + var animation = addDivAndAnimate(t, { class: 'compositable' }, + keyframes, 100 * MS_PER_SEC); + + await waitForPaints(); + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ + { + property, + runningOnCompositor: false + }, + { + property: 'transform', + runningOnCompositor: true, + } + ]); + }, `${property} is treated as a geometric property`); + }); +} + +// Performance warning tests that set and clear a style property. +function testStyleChanges() { + [ + { + desc: 'preserve-3d transform', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + style: 'transform-style: preserve-3d', + expected: [ + { + property: 'transform', + runningOnCompositor: true, + } + ] + }, + { + desc: 'preserve-3d translate', + frames: { + translate: ['0px', '100px'] + }, + style: 'transform-style: preserve-3d', + expected: [ + { + property: 'translate', + runningOnCompositor: true, + } + ] + }, + { + desc: 'transform with backface-visibility:hidden', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + style: 'backface-visibility: hidden;', + expected: [ + { + property: 'transform', + runningOnCompositor: true, + } + ] + }, + { + desc: 'translate with backface-visibility:hidden', + frames: { + translate: ['0px', '100px'] + }, + style: 'backface-visibility: hidden;', + expected: [ + { + property: 'translate', + runningOnCompositor: true, + } + ] + }, + { + desc: 'opacity and transform-like properties with preserve-3d', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'], + translate: ['0px', '100px'] + }, + style: 'transform-style: preserve-3d', + expected: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: true, + }, + { + property: 'translate', + runningOnCompositor: true, + } + ] + }, + { + desc: 'opacity and transform-like properties with ' + + 'backface-visibility:hidden', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'], + translate: ['0px', '100px'] + }, + style: 'backface-visibility: hidden;', + expected: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: true, + }, + { + property: 'translate', + runningOnCompositor: true, + } + ] + }, + ].forEach(subtest => { + promise_test(async t => { + var animation = addDivAndAnimate(t, { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + await waitForPaints(); + assert_all_properties_running_on_compositor( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.style = subtest.style; + await waitForFrame(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.style = ''; + await waitForFrame(); + + assert_all_properties_running_on_compositor( + animation.effect.getProperties(), + subtest.expected); + }, subtest.desc); + }); +} + +// Performance warning tests that set and clear the id property +function testIdChanges() { + [ + { + desc: 'moz-element referencing a transform', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + id: 'transformed', + createelement: 'width:100px; height:100px; background: -moz-element(#transformed)', + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningHasRenderingObserver' + } + ] + }, + { + desc: 'moz-element referencing a translate', + frames: { + translate: ['0px', '100px'] + }, + id: 'transformed', + createelement: 'width:100px; height:100px; background: -moz-element(#transformed)', + expected: [ + { + property: 'translate', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningHasRenderingObserver' + } + ] + }, + { + desc: 'moz-element referencing a translate and transform', + frames: { + transform: ['translate(0px)', 'translate(100px)'], + translate: ['0px', '100px'] + }, + id: 'transformed', + createelement: 'width:100px; height:100px; background: -moz-element(#transformed)', + expected: [ + { + property: 'translate', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningHasRenderingObserver' + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningHasRenderingObserver' + } + ] + }, + ].forEach(subtest => { + promise_test(async t => { + if (subtest.createelement) { + addDiv(t, { style: subtest.createelement }); + } + + var animation = addDivAndAnimate(t, { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + await waitForPaints(); + + assert_all_properties_running_on_compositor( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.id = subtest.id; + await waitForFrame(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.id = ''; + await waitForFrame(); + + assert_all_properties_running_on_compositor( + animation.effect.getProperties(), + subtest.expected); + }, subtest.desc); + }); +} + +function testMultipleAnimations() { + [ + { + desc: 'opacity and transform-like properties with preserve-3d', + style: 'transform-style: preserve-3d', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: true, + } + ] + }, + { + frames: { + translate: ['0px', '100px'] + }, + expected: [ + { + property: 'translate', + runningOnCompositor: true, + } + ] + }, + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + ], + }, + { + desc: 'opacity and transform-like properties with ' + + 'backface-visibility:hidden', + style: 'backface-visibility: hidden;', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: true, + } + ] + }, + { + frames: { + translate: ['0px', '100px'] + }, + expected: [ + { + property: 'translate', + runningOnCompositor: true, + } + ] + }, + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + ], + }, + ].forEach(subtest => { + promise_test(async t => { + var div = addDiv(t, { class: 'compositable' }); + var animations = subtest.animations.map(anim => { + var animation = div.animate(anim.frames, 100 * MS_PER_SEC); + + // Bind expected values to animation object. + animation.expected = anim.expected; + return animation; + }); + await waitForPaints(); + + animations.forEach(anim => { + assert_all_properties_running_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + div.style = subtest.style; + await waitForFrame(); + + animations.forEach(anim => { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected); + }); + div.style = ''; + await waitForFrame(); + + animations.forEach(anim => { + assert_all_properties_running_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + }, 'Multiple animations: ' + subtest.desc); + }); +} + +// Test adding/removing a 'width' keyframe on the same animation object, where +// multiple animation objects belong to the same element. +// The 'width' property is added to animations[1]. +function testMultipleAnimationsWithGeometricKeyframes() { + [ + { + desc: 'transform and opacity with geometric keyframes', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: { + withoutGeometric: [ + { + property: 'transform', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'transform', + runningOnCompositor: true, + } + ] + } + }, + { + frames: { + opacity: [0, 1] + }, + expected: { + withoutGeometric: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false, + }, + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + } + ], + }, + { + desc: 'opacity and transform with geometric keyframes', + animations: [ + { + frames: { + opacity: [0, 1] + }, + expected: { + withoutGeometric: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ], + withGeometric: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + }, + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: { + withoutGeometric: [ + { + property: 'transform', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false, + }, + { + property: 'transform', + runningOnCompositor: true, + } + ] + } + } + ] + }, + { + desc: 'opacity and translate with geometric keyframes', + animations: [ + { + frames: { + opacity: [0, 1] + }, + expected: { + withoutGeometric: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ], + withGeometric: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + }, + { + frames: { + translate: ['0px', '100px'] + }, + expected: { + withoutGeometric: [ + { + property: 'translate', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false, + }, + { + property: 'translate', + runningOnCompositor: true, + } + ] + } + } + ] + }, + ].forEach(subtest => { + promise_test(async t => { + var div = addDiv(t, { class: 'compositable' }); + var animations = subtest.animations.map(anim => { + var animation = div.animate(anim.frames, 100 * MS_PER_SEC); + + // Bind expected values to animation object. + animation.expected = anim.expected; + return animation; + }); + await waitForPaints(); + // First, all animations are running on compositor. + animations.forEach(anim => { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected.withoutGeometric); + }); + + // Add a 'width' property to animations[1]. + var keyframes = animations[1].effect.getKeyframes(); + + keyframes[0].width = '100px'; + keyframes[1].width = '200px'; + + animations[1].effect.setKeyframes(keyframes); + await waitForFrame(); + + // Now the transform animation is not running on compositor because of + // the 'width' property. + animations.forEach(anim => { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected.withGeometric); + }); + + // Remove the 'width' property from animations[1]. + var keyframes = animations[1].effect.getKeyframes(); + + delete keyframes[0].width; + delete keyframes[1].width; + + animations[1].effect.setKeyframes(keyframes); + await waitForFrame(); + + // Finally, all animations are running on compositor. + animations.forEach(anim => { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected.withoutGeometric); + }); + }, 'Multiple animations with geometric property: ' + subtest.desc); + }); +} + +// Tests adding/removing 'width' animation on the same element which has async +// animations. +function testMultipleAnimationsWithGeometricAnimations() { + [ + { + desc: 'transform', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: true, + } + ] + }, + ] + }, + { + desc: 'translate', + animations: [ + { + frames: { + translate: ['0px', '100px'] + }, + expected: [ + { + property: 'translate', + runningOnCompositor: true, + } + ] + }, + ] + }, + { + desc: 'opacity', + animations: [ + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true + } + ] + }, + ] + }, + { + desc: 'opacity, transform, and translate', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: true, + } + ] + }, + { + frames: { + translate: ['0px', '100px'] + }, + expected: [ + { + property: 'translate', + runningOnCompositor: true, + } + ] + }, + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + ], + }, + ].forEach(subtest => { + promise_test(async t => { + var div = addDiv(t, { class: 'compositable' }); + var animations = subtest.animations.map(anim => { + var animation = div.animate(anim.frames, 100 * MS_PER_SEC); + + // Bind expected values to animation object. + animation.expected = anim.expected; + return animation; + }); + + var widthAnimation; + + await waitForPaints(); + animations.forEach(anim => { + assert_all_properties_running_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + + // Append 'width' animation on the same element. + widthAnimation = div.animate({ width: ['100px', '200px'] }, + 100 * MS_PER_SEC); + await waitForFrame(); + + // Now transform animations are not running on compositor because of + // the 'width' animation. + animations.forEach(anim => { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected); + }); + // Remove the 'width' animation. + widthAnimation.cancel(); + await waitForFrame(); + + // Now all animations are running on compositor. + animations.forEach(anim => { + assert_all_properties_running_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + }, 'Multiple async animations and geometric animation: ' + subtest.desc); + }); +} + +function testSmallElements() { + [ + { + desc: 'opacity on small element', + frames: { + opacity: [0, 1] + }, + style: { style: 'width: 8px; height: 8px; background-color: red;' + + // We need to set transform here to try creating an + // individual frame for this opacity element. + // Without this, this small element is created on the same + // nsIFrame of mochitest iframe, i.e. the document which are + // running this test, as a result the layer corresponding + // to the frame is sent to compositor. + 'transform: translateX(100px);' }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true + } + ] + }, + { + desc: 'transform on small element', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + style: { style: 'width: 8px; height: 8px; background-color: red;' }, + expected: [ + { + property: 'transform', + runningOnCompositor: true + } + ] + }, + { + desc: 'translate on small element', + frames: { + translate: ['0px', '100px'] + }, + style: { style: 'width: 8px; height: 8px; background-color: red;' }, + expected: [ + { + property: 'translate', + runningOnCompositor: true + } + ] + }, + ].forEach(subtest => { + promise_test(async t => { + var div = addDiv(t, subtest.style); + var animation = div.animate(subtest.frames, 100 * MS_PER_SEC); + await waitForPaints(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + }, subtest.desc); + }); +} + +function testSynchronizedAnimations() { + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + await Promise.all([animA.ready, animB.ready]); + await waitForPaints(); + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: true, + } ]); + }, 'Animations created within the same tick are synchronized' + + ' (compositor animation created first)'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + const elemC = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ translate: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + const animC = elemC.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + await Promise.all([animA.ready, animB.ready, animC.ready]); + await waitForPaints(); + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ + { property: 'transform', + runningOnCompositor: true, + } ]); + assert_animation_property_state_equals( + animB.effect.getProperties(), + [ + { property: 'translate', + runningOnCompositor: true, + } ]); + }, 'Animations created within the same tick are synchronized' + + ' (compositor animation created first/second)'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + const elemC = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animC = elemC.animate({ translate: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + await Promise.all([animA.ready, animB.ready, animC.ready]); + await waitForPaints(); + + assert_animation_property_state_equals( + animB.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: true, + } ]); + assert_animation_property_state_equals( + animC.effect.getProperties(), + [ + { property: 'translate', + runningOnCompositor: true, + } ]); + }, 'Animations created within the same tick are synchronized' + + ' (compositor animation created second/third)'); + + promise_test(async t => { + const attrs = { class: 'compositable', + style: 'transition: all 100s' }; + const elemA = addDiv(t, attrs); + const elemB = addDiv(t, attrs); + elemA.style.transform = 'translate(0px)'; + elemB.style.marginLeft = '0px'; + getComputedStyle(elemA).transform; + getComputedStyle(elemB).marginLeft; + + // Generally the sequence of steps is as follows: + // + // Tick -> requestAnimationFrame -> Style -> Paint -> Events (-> Tick...) + // + // In this test we want to set up two transitions during the "Events" + // stage but only flush style for one such that the second one is actually + // generated during the "Style" stage of the *next* tick. + // + // Web content often generates transitions in this way (that is, it doesn't + // pay regard to when style is flushed and nor should it). However, we + // still want transitions generated in this way to be synchronized. + let timeForFirstFrame; + await waitForIdle(); + + timeForFirstFrame = document.timeline.currentTime; + elemA.style.transform = 'translate(100px)'; + // Flush style to trigger first transition + getComputedStyle(elemA).transform; + elemB.style.marginLeft = '100px'; + // DON'T flush style here (this includes calling getAnimations!) + await waitForFrame(); + + assert_not_equals(timeForFirstFrame, document.timeline.currentTime, + 'Should be on the other side of a tick'); + // Wait another tick so we can let the transition be started + // by regular style resolution. + await waitForFrame(); + + const transitionA = elemA.getAnimations()[0]; + assert_animation_property_state_equals( + transitionA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: true, + } ]); + }, 'Transitions created before and after a tick are synchronized'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ], + opacity: [ 0, 1 ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + await Promise.all([animA.ready, animB.ready]); + await waitForPaints(); + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: true, + }, + { property: 'opacity', + runningOnCompositor: true + } ]); + }, 'Opacity animations on the same element continue running on the' + + ' compositor when transform animations are synchronized with geometric' + + ' animations'); + + promise_test(async t => { + const transitionElem = addDiv(t, { + style: 'margin-left: 0px; transition: margin-left 100s', + }); + getComputedStyle(transitionElem).marginLeft; + + await waitForFrame(); + + transitionElem.style.marginLeft = '100px'; + const cssTransition = transitionElem.getAnimations()[0]; + + const animationElem = addDiv(t, { + class: 'compositable', + style: 'animation: translate 100s', + }); + const cssAnimation = animationElem.getAnimations()[0]; + + await Promise.all([cssTransition.ready, cssAnimation.ready]); + await waitForPaints(); + + assert_animation_property_state_equals(cssAnimation.effect.getProperties(), + [{ property: 'transform', + runningOnCompositor: true }]); + }, 'CSS Animations are NOT synchronized with CSS Transitions'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + await animA.ready; + await waitForPaints(); + + let animB = elemB.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + await animB.ready; + await waitForPaints(); + + assert_animation_property_state_equals( + animB.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: true } ]); + }, 'Transform animations are NOT synchronized with geometric animations' + + ' started in the previous frame'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + await animA.ready; + await waitForPaints(); + + let animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + await animB.ready; + await waitForPaints(); + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: true } ]); + }, 'Transform animations are NOT synchronized with geometric animations' + + ' started in the next frame'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + animB.pause(); + + await animA.ready; + await waitForPaints(); + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', runningOnCompositor: true } ]); + }, 'Paused animations are not synchronized'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + // Seek one of the animations so that their start times will differ + animA.currentTime = 5000; + + await Promise.all([animA.ready, animB.ready]); + await waitForPaints(); + + assert_not_equals(animA.startTime, animB.startTime, + 'Animations should have different start times'); + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: true, + } ]); + }, 'Animations are synchronized based on when they are started' + + ' and NOT their start time'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + await Promise.all([animA.ready, animB.ready]); + await waitForPaints(); + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: true } ]); + // Restart animation + animA.pause(); + animA.play(); + await animA.ready; + await waitForPaints(); + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: true } ]); + }, 'An initially synchronized animation may be unsynchronized if restarted'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + // Clear target effect + animB.effect.target = null; + + await Promise.all([animA.ready, animB.ready]); + await waitForPaints(); + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: true } ]); + }, 'A geometric animation with no target element is not synchronized'); +} + +function testTooLargeFrame() { + [ + { + property: 'transform', + frames: { transform: ['translate(0px)', 'translate(100px)'] }, + }, + { + property: 'translate', + frames: { translate: ['0px', '100px'] }, + }, + ].forEach(subtest => { + promise_test(async t => { + var animation = addDivAndAnimate(t, + { class: 'compositable' }, + subtest.frames, + 100 * MS_PER_SEC); + await waitForPaints(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: subtest.property, runningOnCompositor: true } ]); + animation.effect.target.style = 'width: 10000px; height: 10000px'; + await waitForFrame(); + + // viewport depends on test environment. + var expectedWarning = new RegExp( + "Animation cannot be run on the compositor because the area of the frame " + + "\\(\\d+\\) is too large relative to the viewport " + + "\\(larger than \\d+\\)"); + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { + property: subtest.property, + runningOnCompositor: false, + warning: expectedWarning + } ]); + animation.effect.target.style = 'width: 100px; height: 100px'; + await waitForFrame(); + + // With WebRender we appear to stick to the previous layerization decision + // after changing the bounds back to a smaller object. + const isWebRender = + SpecialPowers.DOMWindowUtils.layerManagerType.startsWith('WebRender'); + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: subtest.property, runningOnCompositor: !isWebRender } ]); + }, subtest.property + ' on too big element - area'); + + promise_test(async t => { + var animation = addDivAndAnimate(t, + { class: 'compositable' }, + subtest.frames, + 100 * MS_PER_SEC); + await waitForPaints(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: subtest.property, runningOnCompositor: true } ]); + animation.effect.target.style = 'width: 20000px; height: 1px'; + await waitForFrame(); + + // viewport depends on test environment. + var expectedWarning = new RegExp( + "Animation cannot be run on the compositor because the frame size " + + "\\(20000, 1\\) is too large relative to the viewport " + + "\\(larger than \\(\\d+, \\d+\\)\\) or larger than the " + + "maximum allowed value \\(\\d+, \\d+\\)"); + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { + property: subtest.property, + runningOnCompositor: false, + warning: expectedWarning + } ]); + animation.effect.target.style = 'width: 100px; height: 100px'; + await waitForFrame(); + + const isWebRender = + SpecialPowers.DOMWindowUtils.layerManagerType.startsWith('WebRender'); + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: subtest.property, runningOnCompositor: !isWebRender } ]); + }, subtest.property + ' on too big element - dimensions'); + }); +} + +function testTransformSVG() { + [ + { + property: 'transform', + frames: { transform: ['translate(0px)', 'translate(100px)'] }, + }, + { + property: 'translate', + frames: { translate: ['0px', '100px'] }, + }, + { + property: 'rotate', + frames: { rotate: ['0deg', '45deg'] }, + }, + { + property: 'scale', + frames: { scale: ['1', '2'] }, + }, + ].forEach(subtest => { + promise_test(async t => { + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '100'); + svg.setAttribute('height', '100'); + var rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute('width', '100'); + rect.setAttribute('height', '100'); + rect.setAttribute('fill', 'red'); + svg.appendChild(rect); + document.body.appendChild(svg); + t.add_cleanup(() => { + svg.remove(); + }); + + var animation = svg.animate(subtest.frames, 100 * MS_PER_SEC); + await waitForPaints(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: subtest.property, runningOnCompositor: true } ]); + svg.setAttribute('transform', 'translate(10, 20)'); + await waitForFrame(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { + property: subtest.property, + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformSVG' + } ]); + svg.removeAttribute('transform'); + await waitForFrame(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: subtest.property, runningOnCompositor: true } ]); + }, subtest.property + ' of nsIFrame with SVG transform'); + }); +} + +function testImportantRuleOverride() { + promise_test(async t => { + const elem = addDiv(t, { class: 'compositable' }); + const anim = elem.animate({ translate: [ '0px', '100px' ], + rotate: ['0deg', '90deg'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(anim); + await waitForPaints(); + + assert_animation_property_state_equals( + anim.effect.getProperties(), + [ { property: 'translate', runningOnCompositor: true }, + { property: 'rotate', runningOnCompositor: true } ] + ); + + elem.style.setProperty('rotate', '45deg', 'important'); + getComputedStyle(elem).rotate; + + await waitForFrame(); + + assert_animation_property_state_equals( + anim.effect.getProperties(), + [ + { + property: 'translate', + runningOnCompositor: false, + warning: + 'CompositorAnimationWarningTransformIsBlockedByImportantRules' + }, + { + property: 'rotate', + runningOnCompositor: false, + warning: + 'CompositorAnimationWarningTransformIsBlockedByImportantRules' + }, + ] + ); + }, 'The animations of transform-like properties are not running on the ' + + 'compositor because any of the properties has important rules'); +} + +function testCurrentColor() { + if (SpecialPowers.DOMWindowUtils.layerManagerType.startsWith('WebRender')) { + return; // skip this test until bug 1510030 landed. + } + promise_test(async t => { + const animation = addDivAndAnimate(t, { class: 'compositable' }, + { backgroundColor: [ 'currentColor', + 'red' ] }, + 100 * MS_PER_SEC); + await waitForPaints(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: 'background-color', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningHasCurrentColor' + } ]); + }, 'Background color animations with `current-color` don\'t run on the ' + + 'compositor'); +} + +function start() { + var bundleService = SpecialPowers.Cc['@mozilla.org/intl/stringbundle;1'] + .getService(SpecialPowers.Ci.nsIStringBundleService); + gStringBundle = bundleService + .createBundle("chrome://global/locale/layout_errors.properties"); + + testBasicOperation(); + testKeyframesWithGeometricProperties(); + testSetOfGeometricProperties(); + testStyleChanges(); + testIdChanges(); + testMultipleAnimations(); + testMultipleAnimationsWithGeometricKeyframes(); + testMultipleAnimationsWithGeometricAnimations(); + testSmallElements(); + testSynchronizedAnimations(); + testTooLargeFrame(); + testTransformSVG(); + testImportantRuleOverride(); + testCurrentColor(); + + promise_test(async t => { + var div = addDiv(t, { class: 'compositable', + style: 'animation: fade 100s' }); + var cssAnimation = div.getAnimations()[0]; + var scriptAnimation = div.animate({ opacity: [ 1, 0 ] }, 100 * MS_PER_SEC); + + await waitForPaints(); + assert_animation_property_state_equals( + cssAnimation.effect.getProperties(), + [ { property: 'opacity', runningOnCompositor: true } ]); + assert_animation_property_state_equals( + scriptAnimation.effect.getProperties(), + [ { property: 'opacity', runningOnCompositor: true } ]); + }, 'overridden animation'); + + promise_test(async t => { + const keyframes = { + width: [ '100px', '200px' ], + transform: [ 'translate(0px)', 'translate(100px)' ], + "--foo": ["--bar", "--baz"], + }; + const animation = addDivAndAnimate(t, { class: 'compositable' }, + keyframes, 100 * MS_PER_SEC); + await waitForPaints(); + + assert_true(true, "Didn't crash"); + }, 'Warning with custom props'); + + done(); +} + +</script> + +</body> diff --git a/dom/animation/test/chrome/test_animation_properties.html b/dom/animation/test/chrome/test_animation_properties.html new file mode 100644 index 0000000000..497b04e068 --- /dev/null +++ b/dom/animation/test/chrome/test_animation_properties.html @@ -0,0 +1,837 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1254419 - Test the values returned by + KeyframeEffect.getProperties()</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1254419" + target="_blank">Mozilla Bug 1254419</a> +<div id="log"></div> +<style> +@property --my-color { + syntax: "<color>"; + inherits: true; + initial-value: "gold"; +} + +:root { + --var-100px: 100px; + --var-100px-200px: 100px 200px; +} +div { + font-size: 10px; /* For calculating em-based units */ +} +</style> +<script> +'use strict'; + +var gTests = [ + + // --------------------------------------------------------------------- + // + // Tests for property-indexed specifications + // + // --------------------------------------------------------------------- + + { desc: 'a one-property two-value property-indexed specification', + frames: { left: ['10px', '20px'] }, + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] } ] + }, + { desc: 'a one-shorthand-property two-value property-indexed' + + ' specification', + frames: { margin: ['10px', '10px 20px 30px 40px'] }, + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '10px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '40px', 'replace') ] } ] + }, + { desc: 'a two-property (one shorthand and one of its longhand' + + ' components) two-value property-indexed specification', + frames: { marginTop: ['50px', '60px'], + margin: ['10px', '10px 20px 30px 40px'] }, + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '50px', 'replace', 'linear'), + valueFormat(1, '60px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '40px', 'replace') ] } ] + }, + { desc: 'a two-property property-indexed specification with different' + + ' numbers of values', + frames: { left: ['10px', '20px', '30px'], + top: ['40px', '50px'] }, + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'top', + values: [ valueFormat(0, '40px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] } ] + }, + { desc: 'a property-indexed specification with an invalid value', + frames: { left: ['10px', '20px', '30px', '40px', '50px'], + top: ['15px', '25px', 'invalid', '45px', '55px'] }, + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(0.25, '20px', 'replace', 'linear'), + valueFormat(0.5, '30px', 'replace', 'linear'), + valueFormat(0.75, '40px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] }, + { property: 'top', + values: [ valueFormat(0, '15px', 'replace', 'linear'), + valueFormat(0.25, '25px', 'replace', 'linear'), + valueFormat(0.75, '45px', 'replace', 'linear'), + valueFormat(1, '55px', 'replace') ] } ] + }, + { desc: 'a one-property two-value property-indexed specification that' + + ' needs to stringify its values', + frames: { opacity: [0, 1] }, + expected: [ { property: 'opacity', + values: [ valueFormat(0, '0', 'replace', 'linear'), + valueFormat(1, '1', 'replace') ] } ] + }, + { desc: 'a property-indexed keyframe where a lesser shorthand precedes' + + ' a greater shorthand', + frames: { borderLeft: [ '1px solid rgb(1, 2, 3)', + '2px solid rgb(4, 5, 6)' ], + border: [ '3px dotted rgb(7, 8, 9)', + '4px dashed rgb(10, 11, 12)' ] }, + expected: [ { property: 'border-bottom-color', + values: [ valueFormat(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + valueFormat(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-left-color', + values: [ valueFormat(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + valueFormat(1, 'rgb(4, 5, 6)', 'replace') ] }, + { property: 'border-right-color', + values: [ valueFormat(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + valueFormat(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-top-color', + values: [ valueFormat(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + valueFormat(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ valueFormat(0, '3px', 'replace', 'linear'), + valueFormat(1, '4px', 'replace') ] }, + { property: 'border-left-width', + values: [ valueFormat(0, '1px', 'replace', 'linear'), + valueFormat(1, '2px', 'replace') ] }, + { property: 'border-right-width', + values: [ valueFormat(0, '3px', 'replace', 'linear'), + valueFormat(1, '4px', 'replace') ] }, + { property: 'border-top-width', + values: [ valueFormat(0, '3px', 'replace', 'linear'), + valueFormat(1, '4px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ valueFormat(0, 'solid', 'replace', 'linear'), + valueFormat(1, 'solid', 'replace') ] }, + { property: 'border-right-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ valueFormat(0, '0', 'replace', 'linear'), + valueFormat(1, '0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ valueFormat(0, 'stretch', 'replace', 'linear'), + valueFormat(1, 'stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ valueFormat(0, '100%', 'replace', 'linear'), + valueFormat(1, '100%', 'replace') ] }, + { property: 'border-image-source', + values: [ valueFormat(0, 'none', 'replace', 'linear'), + valueFormat(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ valueFormat(0, '1', 'replace', 'linear'), + valueFormat(1, '1', 'replace') ] }, + ] + }, + { desc: 'a property-indexed keyframe where a greater shorthand precedes' + + ' a lesser shorthand', + frames: { border: [ '3px dotted rgb(7, 8, 9)', + '4px dashed rgb(10, 11, 12)' ], + borderLeft: [ '1px solid rgb(1, 2, 3)', + '2px solid rgb(4, 5, 6)' ] }, + expected: [ { property: 'border-bottom-color', + values: [ valueFormat(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + valueFormat(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-left-color', + values: [ valueFormat(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + valueFormat(1, 'rgb(4, 5, 6)', 'replace') ] }, + { property: 'border-right-color', + values: [ valueFormat(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + valueFormat(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-top-color', + values: [ valueFormat(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + valueFormat(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ valueFormat(0, '3px', 'replace', 'linear'), + valueFormat(1, '4px', 'replace') ] }, + { property: 'border-left-width', + values: [ valueFormat(0, '1px', 'replace', 'linear'), + valueFormat(1, '2px', 'replace') ] }, + { property: 'border-right-width', + values: [ valueFormat(0, '3px', 'replace', 'linear'), + valueFormat(1, '4px', 'replace') ] }, + { property: 'border-top-width', + values: [ valueFormat(0, '3px', 'replace', 'linear'), + valueFormat(1, '4px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ valueFormat(0, 'solid', 'replace', 'linear'), + valueFormat(1, 'solid', 'replace') ] }, + { property: 'border-right-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ valueFormat(0, '0', 'replace', 'linear'), + valueFormat(1, '0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ valueFormat(0, 'stretch', 'replace', 'linear'), + valueFormat(1, 'stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ valueFormat(0, '100%', 'replace', 'linear'), + valueFormat(1, '100%', 'replace') ] }, + { property: 'border-image-source', + values: [ valueFormat(0, 'none', 'replace', 'linear'), + valueFormat(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ valueFormat(0, '1', 'replace', 'linear'), + valueFormat(1, '1', 'replace') ] }, + ] + }, + { desc: 'custom registered property', + frames: { "--my-color": ['red', 'blue'] }, + expected: [ { property: '--my-color', + values: [ valueFormat(0, 'red', 'replace', 'linear'), + valueFormat(1, 'blue', 'replace') ] } ] + }, + + // --------------------------------------------------------------------- + // + // Tests for keyframe sequences + // + // --------------------------------------------------------------------- + + { desc: 'a keyframe sequence specification with repeated values at' + + ' offset 0/1 with different easings', + frames: [ { offset: 0.0, left: '100px', easing: 'ease' }, + { offset: 0.0, left: '200px', easing: 'ease' }, + { offset: 0.5, left: '300px', easing: 'linear' }, + { offset: 1.0, left: '400px', easing: 'ease-out' }, + { offset: 1.0, left: '500px', easing: 'step-end' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '100px', 'replace'), + valueFormat(0, '200px', 'replace', 'ease'), + valueFormat(0.5, '300px', 'replace', 'linear'), + valueFormat(1, '400px', 'replace'), + valueFormat(1, '500px', 'replace') ] } ] + }, + { desc: 'a one-property two-keyframe sequence', + frames: [ { offset: 0, left: '10px' }, + { offset: 1, left: '20px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] } ] + }, + { desc: 'a two-property two-keyframe sequence', + frames: [ { offset: 0, left: '10px', top: '30px' }, + { offset: 1, left: '20px', top: '40px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] }, + { property: 'top', + values: [ valueFormat(0, '30px', 'replace', 'linear'), + valueFormat(1, '40px', 'replace') ] } ] + }, + { desc: 'a one shorthand property two-keyframe sequence', + frames: [ { offset: 0, margin: '10px' }, + { offset: 1, margin: '20px 30px 40px 50px' } ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '40px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] } ] + }, + { desc: 'a two-property (a shorthand and one of its component longhands)' + + ' two-keyframe sequence', + frames: [ { offset: 0, margin: '10px', marginTop: '20px' }, + { offset: 1, marginTop: '70px', + margin: '30px 40px 50px 60px' } ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '20px', 'replace', 'linear'), + valueFormat(1, '70px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '40px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '60px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence with duplicate values for a given interior' + + ' offset', + frames: [ { offset: 0.0, left: '10px' }, + { offset: 0.5, left: '20px' }, + { offset: 0.5, left: '30px' }, + { offset: 0.5, left: '40px' }, + { offset: 1.0, left: '50px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(0.5, '20px', 'replace'), + valueFormat(0.5, '40px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence with duplicate values for offsets 0 and 1', + frames: [ { offset: 0, left: '10px' }, + { offset: 0, left: '20px' }, + { offset: 0, left: '30px' }, + { offset: 1, left: '40px' }, + { offset: 1, left: '50px' }, + { offset: 1, left: '60px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace'), + valueFormat(0, '30px', 'replace', 'linear'), + valueFormat(1, '40px', 'replace'), + valueFormat(1, '60px', 'replace') ] } ] + }, + { desc: 'a two-property four-keyframe sequence', + frames: [ { offset: 0, left: '10px' }, + { offset: 0, top: '20px' }, + { offset: 1, top: '30px' }, + { offset: 1, left: '40px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '40px', 'replace') ] }, + { property: 'top', + values: [ valueFormat(0, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] } ] + }, + { desc: 'a one-property keyframe sequence with some omitted offsets', + frames: [ { offset: 0.00, left: '10px' }, + { offset: 0.25, left: '20px' }, + { left: '30px' }, + { left: '40px' }, + { offset: 1.00, left: '50px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(0.25, '20px', 'replace', 'linear'), + valueFormat(0.5, '30px', 'replace', 'linear'), + valueFormat(0.75, '40px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] } ] + }, + { desc: 'a two-property keyframe sequence with some omitted offsets', + frames: [ { offset: 0.00, left: '10px', top: '20px' }, + { offset: 0.25, left: '30px' }, + { left: '40px' }, + { left: '50px', top: '60px' }, + { offset: 1.00, left: '70px', top: '80px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(0.25, '30px', 'replace', 'linear'), + valueFormat(0.5, '40px', 'replace', 'linear'), + valueFormat(0.75, '50px', 'replace', 'linear'), + valueFormat(1, '70px', 'replace') ] }, + { property: 'top', + values: [ valueFormat(0, '20px', 'replace', 'linear'), + valueFormat(0.75, '60px', 'replace', 'linear'), + valueFormat(1, '80px', 'replace') ] } ] + }, + { desc: 'a one-property keyframe sequence with all omitted offsets', + frames: [ { left: '10px' }, + { left: '20px' }, + { left: '30px' }, + { left: '40px' }, + { left: '50px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(0.25, '20px', 'replace', 'linear'), + valueFormat(0.5, '30px', 'replace', 'linear'), + valueFormat(0.75, '40px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence with different easing values, but the' + + ' same easing value for a given offset', + frames: [ { offset: 0.0, easing: 'ease', left: '10px'}, + { offset: 0.0, easing: 'ease', top: '20px'}, + { offset: 0.5, easing: 'linear', left: '30px' }, + { offset: 0.5, easing: 'linear', top: '40px' }, + { offset: 1.0, easing: 'step-end', left: '50px' }, + { offset: 1.0, easing: 'step-end', top: '60px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'ease'), + valueFormat(0.5, '30px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] }, + { property: 'top', + values: [ valueFormat(0, '20px', 'replace', 'ease'), + valueFormat(0.5, '40px', 'replace', 'linear'), + valueFormat(1, '60px', 'replace') ] } ] + }, + { desc: 'a one-property two-keyframe sequence that needs to' + + ' stringify its values', + frames: [ { offset: 0, opacity: 0 }, + { offset: 1, opacity: 1 } ], + expected: [ { property: 'opacity', + values: [ valueFormat(0, '0', 'replace', 'linear'), + valueFormat(1, '1', 'replace') ] } ] + }, + { desc: 'a keyframe sequence where shorthand precedes longhand', + frames: [ { offset: 0, margin: '10px', marginRight: '20px' }, + { offset: 1, margin: '30px' } ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence where longhand precedes shorthand', + frames: [ { offset: 0, marginRight: '20px', margin: '10px' }, + { offset: 1, margin: '30px' } ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence where lesser shorthand precedes greater' + + ' shorthand', + frames: [ { offset: 0, borderLeft: '1px solid rgb(1, 2, 3)', + border: '2px dotted rgb(4, 5, 6)' }, + { offset: 1, border: '3px dashed rgb(7, 8, 9)' } ], + expected: [ { property: 'border-bottom-color', + values: [ valueFormat(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-left-color', + values: [ valueFormat(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-right-color', + values: [ valueFormat(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-top-color', + values: [ valueFormat(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ valueFormat(0, '2px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-left-width', + values: [ valueFormat(0, '1px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-right-width', + values: [ valueFormat(0, '2px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-top-width', + values: [ valueFormat(0, '2px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ valueFormat(0, 'solid', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-right-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ valueFormat(0, '0', 'replace', 'linear'), + valueFormat(1, '0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ valueFormat(0, 'stretch', 'replace', 'linear'), + valueFormat(1, 'stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ valueFormat(0, '100%', 'replace', 'linear'), + valueFormat(1, '100%', 'replace') ] }, + { property: 'border-image-source', + values: [ valueFormat(0, 'none', 'replace', 'linear'), + valueFormat(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ valueFormat(0, '1', 'replace', 'linear'), + valueFormat(1, '1', 'replace') ] }, + ] + }, + { desc: 'a keyframe sequence where greater shorthand precedes' + + ' lesser shorthand', + frames: [ { offset: 0, border: '2px dotted rgb(4, 5, 6)', + borderLeft: '1px solid rgb(1, 2, 3)' }, + { offset: 1, border: '3px dashed rgb(7, 8, 9)' } ], + expected: [ { property: 'border-bottom-color', + values: [ valueFormat(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-left-color', + values: [ valueFormat(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-right-color', + values: [ valueFormat(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-top-color', + values: [ valueFormat(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ valueFormat(0, '2px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-left-width', + values: [ valueFormat(0, '1px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-right-width', + values: [ valueFormat(0, '2px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-top-width', + values: [ valueFormat(0, '2px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ valueFormat(0, 'solid', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-right-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ valueFormat(0, '0', 'replace', 'linear'), + valueFormat(1, '0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ valueFormat(0, 'stretch', 'replace', 'linear'), + valueFormat(1, 'stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ valueFormat(0, '100%', 'replace', 'linear'), + valueFormat(1, '100%', 'replace') ] }, + { property: 'border-image-source', + values: [ valueFormat(0, 'none', 'replace', 'linear'), + valueFormat(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ valueFormat(0, '1', 'replace', 'linear'), + valueFormat(1, '1', 'replace') ] }, + ] + }, + + // --------------------------------------------------------------------- + // + // Tests for unit conversion + // + // --------------------------------------------------------------------- + + { desc: 'em units are resolved to px values', + frames: { left: ['10em', '20em'] }, + expected: [ { property: 'left', + values: [ valueFormat(0, '100px', 'replace', 'linear'), + valueFormat(1, '200px', 'replace') ] } ] + }, + { desc: 'calc() expressions are resolved to the equivalent units', + frames: { left: ['calc(10em + 10px)', 'calc(10em + 10%)'] }, + expected: [ { property: 'left', + values: [ valueFormat(0, '110px', 'replace', 'linear'), + valueFormat(1, 'calc(10% + 100px)', 'replace') ] } ] + }, + + // --------------------------------------------------------------------- + // + // Tests for CSS variable handling conversion + // + // --------------------------------------------------------------------- + + { desc: 'CSS variables are resolved to their corresponding values', + frames: { left: ['10px', 'var(--var-100px)'] }, + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '100px', 'replace') ] } ] + }, + { desc: 'CSS variables in calc() expressions are resolved', + frames: { left: ['10px', 'calc(var(--var-100px) / 2 - 10%)'] }, + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, 'calc(-10% + 50px)', 'replace') ] } ] + }, + { desc: 'CSS variables in shorthands are resolved to their corresponding' + + ' values', + frames: { margin: ['10px', 'var(--var-100px-200px)'] }, + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '100px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '200px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '100px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '200px', 'replace') ] } ] + }, + + // --------------------------------------------------------------------- + // + // Tests for properties that parse correctly but which we fail to + // convert to computed values. + // + // --------------------------------------------------------------------- + + { desc: 'a missing property in initial keyframe', + frames: [ { }, + { margin: '5px' } ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] } ] + }, + { desc: 'a missing property in initial keyframe and there are some ' + + 'keyframes with the same offset', + frames: [ { }, + { margin: '10px', offset: 0.5 }, + { margin: '20px', offset: 0.5 }, + { margin: '30px'} ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] } ] + }, + { desc: 'a missing property in final keyframe', + frames: [ { margin: '5px' }, + { } ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] } ] + }, + { desc: 'a missing property in final keyframe and there are some ' + + 'keyframes with the same offsets', + frames: [ { margin: '5px' }, + { margin: '10px', offset: 0.5 }, + { margin: '20px', offset: 0.5 }, + { } ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] } ] + }, + { desc: 'a missing property in final keyframe where it forms the last' + + ' segment in the series', + frames: [ { margin: '5px' }, + { marginLeft: '5px', + marginRight: '5px', + marginBottom: '5px' } ], + expected: [ { property: 'margin-bottom', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-top', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] } ] + }, + { desc: 'a missing property in initial keyframe along with other values', + frames: [ { left: '10px' }, + { margin: '5px', left: '20px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] }, + { property: 'margin-top', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] } ] + }, + { desc: 'a missing property in final keyframe along with other values', + frames: [ { margin: '5px', left: '10px' }, + { left: '20px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] }, + { property: 'margin-top', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] } ] + }, + { desc: 'missing properties in both of initial and final keyframe', + frames: [ { left: '5px', offset: 0.5 } ], + expected: [ { property: 'left', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(0.5, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] } ] + }, + { desc: 'missing propertes in both of initial and final keyframe along ' + + 'with other values', + frames: [ { left: '5px', offset: 0 }, + { right: '5px', offset: 0.5 }, + { left: '10px', offset: 1 } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, '10px', 'replace') ] }, + { property: 'right', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(0.5, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] } ] + }, + + { desc: 'a missing property in final keyframe with duplicate offset ' + + + 'along with other values', + frames: [ { left: '5px', right: '5px', offset: 0 }, + { left: '8px', right: '8px', offset: 0 }, + { left: '10px', offset: 1 } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '5px', 'replace'), + valueFormat(0, '8px', 'replace', 'linear'), + valueFormat(1, '10px', 'replace') ] }, + { property: 'right', + values: [ valueFormat(0, '5px', 'replace'), + valueFormat(0, '8px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] } ] + }, + + { desc: 'a missing property in initial keyframe with duplicate offset ' + + 'along with other values', + frames: [ { left: '10px', offset: 0 }, + { left: '8px', right: '8px', offset: 1 }, + { left: '5px', right: '5px', offset: 1 } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '8px', 'replace'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'right', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '8px', 'replace'), + valueFormat(1, '5px', 'replace') ] } ] + }, +]; + +gTests.forEach(function(subtest) { + test(function(t) { + var div = addDiv(t); + var animation = div.animate(subtest.frames, 100 * MS_PER_SEC); + // Flush styles since getProperties currently does not. Rather, it + // returns the actual properties in use at the current time. + // However, we want to test what these properties will look like + // after the next restyle. + getComputedStyle(div).opacity; + assert_properties_equal( + animation.effect.getProperties(), + subtest.expected + ); + }, subtest.desc); +}); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_animation_properties_display.html b/dom/animation/test/chrome/test_animation_properties_display.html new file mode 100644 index 0000000000..1d558d2114 --- /dev/null +++ b/dom/animation/test/chrome/test_animation_properties_display.html @@ -0,0 +1,34 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1536688 - Test that 'display' is not included in + KeyframeEffect.getProperties() when using shorthand 'all'</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1536688" + target="_blank">Mozilla Bug 1536688</a> +<div id="log"></div> +<script> +'use strict'; + +test(t => { + const div = addDiv(t); + const animation = div.animate( + { all: ['unset', 'unset'] }, + 100 * MS_PER_SEC + ); + // Flush styles since getProperties does not. + getComputedStyle(div).opacity; + + const properties = animation.effect.getProperties(); + assert_false( + properties.some(property => property.property === 'display'), + 'Should not have a property for display' + ); +}); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_cssanimation_missing_keyframes.html b/dom/animation/test/chrome/test_cssanimation_missing_keyframes.html new file mode 100644 index 0000000000..8c599655b7 --- /dev/null +++ b/dom/animation/test/chrome/test_cssanimation_missing_keyframes.html @@ -0,0 +1,66 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1339332 - Test for missing keyframes in CSS Animation</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1339332" + target="_blank">Mozilla Bug 1339332</a> +<div id="log"></div> +<style> +@keyframes missingFrom { + to { + text-align: right; + } +} +@keyframes missingBoth { + 50% { + text-align: right; + } +} +@keyframes missingTo { + from { + text-align: right; + } +} +</style> +<script> +'use strict'; + +const gTests = [ + { desc: 'missing "from" keyframe', + animationName: 'missingFrom', + expected: [{ property: 'text-align', + values: [valueFormat(0, undefined, 'replace', 'ease'), + valueFormat(1, 'right', 'replace')] } ] + }, + { desc: 'missing "to" keyframe', + animationName: 'missingTo', + expected: [{ property: 'text-align', + values: [valueFormat(0, 'right', 'replace', 'ease'), + valueFormat(1, undefined, 'replace')] } ] + }, + { desc: 'missing "from" and "to" keyframes', + animationName: 'missingBoth', + expected: [{ property: 'text-align', + values: [valueFormat(0, undefined, 'replace', 'ease'), + valueFormat(.5, 'right', 'replace', 'ease'), + valueFormat(1, undefined, 'replace')] } ] + }, +]; + +gTests.forEach(function(subtest) { + test(function(t) { + const div = addDiv(t); + div.style.animation = `${ subtest.animationName } 1000s`; + const animation = div.getAnimations()[0]; + assert_properties_equal(animation.effect.getProperties(), + subtest.expected); + }, subtest.desc); +}); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_generated_content_getAnimations.html b/dom/animation/test/chrome/test_generated_content_getAnimations.html new file mode 100644 index 0000000000..41010ca917 --- /dev/null +++ b/dom/animation/test/chrome/test_generated_content_getAnimations.html @@ -0,0 +1,81 @@ +<!DOCTYPE html> +<head> +<meta charset=utf-8> +<title>Test getAnimations() for generated-content elements</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +<style> +@keyframes anim { } +@keyframes anim2 { } +.before::before { + content: ''; + animation: anim 100s; +} +.after::after { + content: ''; + animation: anim 100s, anim2 100s; +} +</style> +</head> +<body> +<div id='root' class='before after'> + <div class='before'></div> + <div></div> +</div> +<script> +'use strict'; + +function getWalker(node) { + var walker = Cc["@mozilla.org/inspector/deep-tree-walker;1"]. + createInstance(Ci.inIDeepTreeWalker); + walker.showAnonymousContent = true; + walker.init(node.ownerDocument, NodeFilter.SHOW_ALL); + walker.currentNode = node; + return walker; +} + +test(function(t) { + var root = document.getElementById('root'); + // Flush first to make sure the generated-content elements are ready + // in the tree. + flushComputedStyle(root); + var before = getWalker(root).firstChild(); + var after = getWalker(root).lastChild(); + + // Sanity Checks + assert_equals(document.getAnimations().length, 4, + 'All animations in this document'); + assert_equals(before.tagName, '_moz_generated_content_before', + 'First child is ::before element'); + assert_equals(after.tagName, '_moz_generated_content_after', + 'Last child is ::after element'); + + // Test Element.getAnimations() for generated-content elements + assert_equals(before.getAnimations().length, 1, + 'Animations of ::before generated-content element'); + assert_equals(after.getAnimations().length, 2, + 'Animations of ::after generated-content element'); +}, 'Element.getAnimations() used on generated-content elements'); + +test(function(t) { + var root = document.getElementById('root'); + flushComputedStyle(root); + var walker = getWalker(root); + + var animations = []; + var element = walker.currentNode; + while (element) { + if (element.getAnimations) { + animations = [...animations, ...element.getAnimations()]; + } + element = walker.nextNode(); + } + + assert_equals(animations.length, document.getAnimations().length, + 'The number of animations got by DeepTreeWalker and ' + + 'document.getAnimations() should be the same'); +}, 'Element.getAnimations() used by traversing DeepTreeWalker'); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_keyframe_effect_xrays.html b/dom/animation/test/chrome/test_keyframe_effect_xrays.html new file mode 100644 index 0000000000..ca3e712ac5 --- /dev/null +++ b/dom/animation/test/chrome/test_keyframe_effect_xrays.html @@ -0,0 +1,45 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1414674" + target="_blank">Mozilla Bug 1414674</a> +<div id="log"></div> +<iframe id="iframe" + src="http://example.org/tests/dom/animation/test/chrome/file_animate_xrays.html"></iframe> +<script> +'use strict'; + +var win = document.getElementById('iframe').contentWindow; + +async_test(function(t) { + window.addEventListener('load', t.step_func(function() { + var target = win.document.getElementById('target'); + var effect = new win.KeyframeEffect(target, [ + {opacity: 1, offset: 0}, + {opacity: 0, offset: 1}, + ], {duration: 100 * MS_PER_SEC, fill: "forwards"}); + // The frames object should be accessible via x-ray. + var frames = effect.getKeyframes(); + assert_equals(frames.length, 2, + "frames for KeyframeEffect ctor should be non-zero"); + assert_equals(frames[0].opacity, "1", + "first frame opacity for KeyframeEffect ctor should be specified value"); + assert_equals(frames[0].computedOffset, 0, + "first frame offset for KeyframeEffect ctor should be 0"); + assert_equals(frames[1].opacity, "0", + "last frame opacity for KeyframeEffect ctor should be specified value"); + assert_equals(frames[1].computedOffset, 1, + "last frame offset for KeyframeEffect ctor should be 1"); + var animation = new win.Animation(effect, document.timeline); + animation.play(); + t.done(); + })); +}, 'Calling KeyframeEffect() ctor across x-rays'); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_mutation_observer_for_element_removal_in_shadow_tree.html b/dom/animation/test/chrome/test_mutation_observer_for_element_removal_in_shadow_tree.html new file mode 100644 index 0000000000..f8efaa6baf --- /dev/null +++ b/dom/animation/test/chrome/test_mutation_observer_for_element_removal_in_shadow_tree.html @@ -0,0 +1,45 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +<div id="log"></div> +<script> + +promise_test(async t => { + // Set up a MutationObserver for animations. + const observer = new MutationObserver(() => {}); + observer.observe(document.documentElement, { + animations: true, + subtree: true, + }); + + // Create a CSS transition in a shadow tree. + let s = document.createElement('shadow-test'); + document.documentElement.appendChild(s); + s.attachShadow({mode:"open"}); + + let property = 'opacity'; + let initial = '1'; + let finalValue = '0'; + + let div = document.createElement('div'); + div.style = `${property}:${initial};transition:${property} 2s;` + + s.shadowRoot.appendChild(div); + div.offsetWidth; + + div.style[property] = finalValue; + + const eventWatcher = new EventWatcher(t, div, ['transitionstart']); + + // Trigger a CSS transition. + getComputedStyle(div)[property]; + + // Wait for a transitionend event to make sure the transition has been started. + await eventWatcher.wait_for('transitionstart'); + + // Now remove the element to notify it to the observer + div.parentNode.removeChild(div); +}); +</script> diff --git a/dom/animation/test/chrome/test_running_on_compositor.html b/dom/animation/test/chrome/test_running_on_compositor.html new file mode 100644 index 0000000000..d8c1d0573e --- /dev/null +++ b/dom/animation/test/chrome/test_running_on_compositor.html @@ -0,0 +1,1656 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1045994 - Add a chrome-only property to inspect if an animation is + running on the compositor or not</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +<style> +@keyframes anim { + to { transform: translate(100px) } +} +@keyframes transform-starts-with-none { + 0% { transform: none } + 99% { transform: none } + 100% { transform: translate(100px) } +} +@keyframes opacity { + to { opacity: 0 } +} +@keyframes zIndex_and_translate { + to { z-index: 999; transform: translate(100px); } +} +@keyframes z-index { + to { z-index: 999; } +} +@keyframes rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +@keyframes rotate-and-opacity { + from { transform: rotate(0deg); opacity: 1;} + to { transform: rotate(360deg); opacity: 0;} +} +div { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +</style> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1045994" + target="_blank">Mozilla Bug 1045994</a> +<div id="log"></div> +<script> +'use strict'; + +/** Test for bug 1045994 - Add a chrome-only property to inspect if an + animation is running on the compositor or not **/ + +const omtaEnabled = isOMTAEnabled(); + +function assert_animation_is_running_on_compositor(animation, desc) { + assert_equals(animation.isRunningOnCompositor, omtaEnabled, + desc + ' at ' + animation.currentTime + 'ms'); +} + +function assert_animation_is_not_running_on_compositor(animation, desc) { + assert_equals(animation.isRunningOnCompositor, false, + desc + ' at ' + animation.currentTime + 'ms'); +} + +promise_test(async t => { + // FIXME: When we implement Element.animate, use that here instead of CSS + // so that we remove any dependency on the CSS mapping. + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + // If the animation starts at the current timeline time, we need to wait for + // one more frame to avoid receiving the fake timer-based MozAfterPaint event. + // FIXME: Bug 1419226: Drop this 'animation.ready' and 'waitForFrame'. Once + // MozAfterPaint is fired reliably, we just need to wait for a MozAfterPaint + // here. + await animation.ready; + + if (animationStartsRightNow(animation)) { + await waitForNextFrame(); + } + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' during playback'); + + div.style.animationPlayState = 'paused'; + + await animation.ready; + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when paused'); +}, ''); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: z-index 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' for animation of "z-index"'); +}, 'isRunningOnCompositor is false for animation of "z-index"'); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: zIndex_and_translate 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' when the animation has two properties, where one can run' + + ' on the compositor, the other cannot'); +}, 'isRunningOnCompositor is true if the animation has at least one ' + + 'property can run on compositor'); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + animation.pause(); + await animation.ready; + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when animation.pause() is called'); +}, 'isRunningOnCompositor is false when the animation.pause() is called'); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + animation.finish(); + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' immediately after animation.finish() is called'); + // Check that we don't set the flag back again on the next tick. + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' on the next tick after animation.finish() is called'); +}, 'isRunningOnCompositor is false when the animation.finish() is called'); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + animation.currentTime = 100 * MS_PER_SEC; + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' immediately after manually seeking the animation to the end'); + // Check that we don't set the flag back again on the next tick. + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' on the next tick after manually seeking the animation to the end'); +}, 'isRunningOnCompositor is false when manually seeking the animation to ' + + 'the end'); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + animation.cancel(); + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' immediately after animation.cancel() is called'); + // Check that we don't set the flag back again on the next tick. + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' on the next tick after animation.cancel() is called'); +}, 'isRunningOnCompositor is false when animation.cancel() is called'); + +// This is to test that we don't simply clobber the flag when ticking +// animations and then set it again during painting. +promise_test(async t => { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + await new Promise(resolve => { + window.requestAnimationFrame(() => { + t.step(() => { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' in requestAnimationFrame callback'); + }); + + resolve(); + }); + }); +}, 'isRunningOnCompositor is true in requestAnimationFrame callback'); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + await new Promise(resolve => { + var observer = new MutationObserver(records => { + var changedAnimation; + + records.forEach(record => { + changedAnimation = + record.changedAnimations.find(changedAnim => { + return changedAnim == animation; + }); + }); + + t.step(() => { + assert_true(!!changedAnimation, 'The animation should be recorded ' + + 'as one of the changedAnimations'); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' in MutationObserver callback'); + }); + + resolve(); + }); + observer.observe(div, { animations: true, subtree: false }); + t.add_cleanup(() => { + observer.disconnect(); + }); + div.style.animationDuration = "200s"; + }); +}, 'isRunningOnCompositor is true in MutationObserver callback'); + +// This is to test that we don't temporarily clear the flag when forcing +// an unthrottled sample. +promise_test(async t => { + var div = addDiv(t, { style: 'animation: rotate 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + await new Promise(resolve => { + var timeAtStart = window.performance.now(); + function handleFrame() { + t.step(() => { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' in requestAnimationFrame callback'); + }); + + // we have to wait at least 200ms because this animation is + // unthrottled on every 200ms. + // See https://hg.mozilla.org/mozilla-central/file/cafb1c90f794/layout/style/AnimationCommon.cpp#l863 + if (window.performance.now() - timeAtStart > 200) { + resolve(); + return; + } + window.requestAnimationFrame(handleFrame); + } + window.requestAnimationFrame(handleFrame); + }); +}, 'isRunningOnCompositor remains true in requestAnimationFrameCallback for ' + + 'overflow animation'); + +promise_test(async t => { + var div = addDiv(t, { style: 'transition: opacity 100s; opacity: 1' }); + + getComputedStyle(div).opacity; + + div.style.opacity = 0; + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Transition reports that it is running on the compositor' + + ' during playback for opacity transition'); +}, 'isRunningOnCompositor for transitions'); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: rotate-and-opacity 100s; ' + + 'backface-visibility: hidden; ' + + 'transform: none !important;' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'If an animation has a property that can run on the compositor and a ' + + 'property that cannot (due to Gecko limitations) but where the latter' + + 'property is overridden in the CSS cascade, the animation should ' + + 'still report that it is running on the compositor'); +}, 'isRunningOnCompositor is true when a property that would otherwise block ' + + 'running on the compositor is overridden in the CSS cascade'); + +promise_test(async t => { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 200 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.currentTime = 150 * MS_PER_SEC; + animation.effect.updateTiming({ duration: 100 * MS_PER_SEC }); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when the animation is set a shorter duration than current time'); +}, 'animation is immediately removed from compositor' + + 'when the duration is made shorter than the current time'); + +promise_test(async t => { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.currentTime = 500 * MS_PER_SEC; + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when finished'); + + animation.effect.updateTiming({ duration: 1000 * MS_PER_SEC }); + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' when restarted'); +}, 'animation is added to compositor' + + ' when the duration is made longer than the current time'); + +promise_test(async t => { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.updateTiming({ endDelay: 100 * MS_PER_SEC }); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' when endDelay is changed'); + + animation.currentTime = 110 * MS_PER_SEC; + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when currentTime is during endDelay'); +}, 'animation is removed from compositor' + + ' when current time is made longer than the duration even during endDelay'); + +promise_test(async t => { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.updateTiming({ endDelay: -200 * MS_PER_SEC }); + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when endTime is negative value'); +}, 'animation is removed from compositor' + + ' when endTime is negative value'); + +promise_test(async t => { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 200 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.updateTiming({ endDelay: -100 * MS_PER_SEC }); + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' when endTime is positive and endDelay is negative'); + animation.currentTime = 110 * MS_PER_SEC; + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when currentTime is after endTime'); +}, 'animation is NOT running on compositor' + + ' when endTime is positive and endDelay is negative'); + +promise_test(async t => { + var effect = new KeyframeEffect(null, + { opacity: [ 0, 1 ] }, + 100 * MS_PER_SEC); + var animation = new Animation(effect, document.timeline); + animation.play(); + + var div = addDiv(t); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation with null target reports that it is not running ' + + 'on the compositor'); + + animation.effect.target = div; + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor ' + + 'after setting a valid target'); +}, 'animation is added to the compositor when setting a valid target'); + +promise_test(async t => { + var div = addDiv(t); + var animation = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.target = null; + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the ' + + 'compositor after setting null target'); +}, 'animation is removed from the compositor when setting null target'); + +promise_test(async t => { + var div = addDiv(t); + var animation = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC, + delay: 100 * MS_PER_SEC, + fill: 'backwards' }); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation with fill:backwards in delay phase reports ' + + 'that it is running on the compositor'); + + animation.currentTime = 100 * MS_PER_SEC; + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Animation with fill:backwards in delay phase reports ' + + 'that it is running on the compositor after delay phase'); +}, 'animation with fill:backwards in delay phase is running on the ' + + ' compositor while it is in delay phase'); + +promise_test(async t => { + const animation = addDiv(t).animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + animation.playbackRate = -1; + animation.currentTime = 200 * MS_PER_SEC; + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation with negative playback rate is runnning on the' + + ' compositor even before it reaches the active interval'); +}, 'animation with negative playback rate is sent to the compositor even in' + + ' after phase'); + +promise_test(async t => { + var div = addDiv(t); + var animation = div.animate([{ opacity: 1, offset: 0 }, + { opacity: 1, offset: 0.99 }, + { opacity: 0, offset: 1 }], 100 * MS_PER_SEC); + + var another = addDiv(t); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Opacity animation on a 100% opacity keyframe reports ' + + 'that it is running on the compositor from the begining'); + + animation.effect.target = another; + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Opacity animation on a 100% opacity keyframe keeps ' + + 'running on the compositor after changing the target ' + + 'element'); +}, '100% opacity animations with keeps running on the ' + + 'compositor after changing the target element'); + +promise_test(async t => { + var div = addDiv(t); + var animation = div.animate({ color: ['red', 'black'] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Color animation reports that it is not running on the ' + + 'compositor'); + + animation.effect.setKeyframes([{ opacity: 1, offset: 0 }, + { opacity: 1, offset: 0.99 }, + { opacity: 0, offset: 1 }]); + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + '100% opacity animation set by using setKeyframes reports ' + + 'that it is running on the compositor'); +}, '100% opacity animation set up by converting an existing animation with ' + + 'cannot be run on the compositor, is running on the compositor'); + +promise_test(async t => { + var div = addDiv(t); + var animation = div.animate({ color: ['red', 'black'] }, 100 * MS_PER_SEC); + var effect = new KeyframeEffect(div, + [{ opacity: 1, offset: 0 }, + { opacity: 1, offset: 0.99 }, + { opacity: 0, offset: 1 }], + 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Color animation reports that it is not running on the ' + + 'compositor'); + + animation.effect = effect; + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + '100% opacity animation set up by changing effects reports ' + + 'that it is running on the compositor'); +}, '100% opacity animation set up by changing the effects on an existing ' + + 'animation which cannot be run on the compositor, is running on the ' + + 'compositor'); + +promise_test(async t => { + var div = addDiv(t, { style: "opacity: 1 ! important" }); + + var animation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Opacity animation on an element which has 100% opacity style with ' + + '!important flag reports that it is not running on the compositor'); + // Clear important flag from the opacity style on the target element. + div.style.setProperty("opacity", "1", ""); + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Opacity animation reports that it is running on the compositor after ' + + 'clearing the !important flag'); +}, 'Clearing *important* opacity style on the target element sends the ' + + 'animation to the compositor'); + +promise_test(async t => { + var div = addDiv(t); + var lowerAnimation = div.animate({ opacity: [1, 0] }, 100 * MS_PER_SEC); + var higherAnimation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(higherAnimation, + 'A higher-priority opacity animation on an element ' + + 'reports that it is running on the compositor'); + assert_animation_is_running_on_compositor(lowerAnimation, + 'A lower-priority opacity animation on the same ' + + 'element also reports that it is running on the compositor'); +}, 'Opacity animations on the same element run on the compositor'); + +promise_test(async t => { + var div = addDiv(t, { style: 'transition: opacity 100s; opacity: 1' }); + + getComputedStyle(div).opacity; + + div.style.opacity = 0; + getComputedStyle(div).opacity; + + var transition = div.getAnimations()[0]; + var animation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'An opacity animation on an element reports that' + + 'that it is running on the compositor'); + assert_animation_is_running_on_compositor(transition, + 'An opacity transition on the same element reports that ' + + 'it is running on the compositor'); +}, 'Both of transition and script animation on the same element run on the ' + + 'compositor'); + +promise_test(async t => { + var div = addDiv(t); + var importantOpacityElement = addDiv(t, { style: "opacity: 1 ! important" }); + + var animation = div.animate({ opacity: [1, 0] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Opacity animation on an element reports ' + + 'that it is running on the compositor'); + + animation.effect.target = null; + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation is no longer running on the compositor after ' + + 'removing from the element'); + animation.effect.target = importantOpacityElement; + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation is NOT running on the compositor even after ' + + 'being applied to a different element which has an ' + + '!important opacity declaration'); +}, 'Animation continues not running on the compositor after being ' + + 'applied to an element which has an important declaration and ' + + 'having previously been temporarily associated with no target element'); + +promise_test(async t => { + var div = addDiv(t); + var another = addDiv(t); + + var lowerAnimation = div.animate({ opacity: [1, 0] }, 100 * MS_PER_SEC); + var higherAnimation = another.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(lowerAnimation, + 'An opacity animation on an element reports that ' + + 'it is running on the compositor'); + assert_animation_is_running_on_compositor(higherAnimation, + 'Opacity animation on a different element reports ' + + 'that it is running on the compositor'); + + lowerAnimation.effect.target = null; + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(lowerAnimation, + 'Animation is no longer running on the compositor after ' + + 'being removed from the element'); + lowerAnimation.effect.target = another; + await waitForFrame(); + + assert_animation_is_running_on_compositor(lowerAnimation, + 'A lower-priority animation begins running ' + + 'on the compositor after being applied to an element ' + + 'which has a higher-priority animation'); + assert_animation_is_running_on_compositor(higherAnimation, + 'A higher-priority animation continues to run on the ' + + 'compositor even after a lower-priority animation is ' + + 'applied to the same element'); +}, 'Animation begins running on the compositor after being applied ' + + 'to an element which has a higher-priority animation and after ' + + 'being temporarily associated with no target element'); + +promise_test(async t => { + var div = addDiv(t); + var another = addDiv(t); + + var lowerAnimation = div.animate({ opacity: [1, 0] }, 100 * MS_PER_SEC); + var higherAnimation = another.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(lowerAnimation, + 'An opacity animation on an element reports that ' + + 'it is running on the compositor'); + assert_animation_is_running_on_compositor(higherAnimation, + 'Opacity animation on a different element reports ' + + 'that it is running on the compositor'); + + higherAnimation.effect.target = null; + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(higherAnimation, + 'Animation is no longer running on the compositor after ' + + 'being removed from the element'); + higherAnimation.effect.target = div; + await waitForFrame(); + + assert_animation_is_running_on_compositor(lowerAnimation, + 'Animation continues running on the compositor after ' + + 'a higher-priority animation applied to the same element'); + assert_animation_is_running_on_compositor(higherAnimation, + 'A higher-priority animation begins to running on the ' + + 'compositor after being applied to an element which has ' + + 'a lower-priority-animation'); +}, 'Animation begins running on the compositor after being applied ' + + 'to an element which has a lower-priority animation once after ' + + 'disassociating with an element'); + +var delayPhaseTests = [ + { + desc: 'script animation of opacity', + setupAnimation: t => { + return addDiv(t).animate( + { opacity: [0, 1] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + }, + }, + { + desc: 'script animation of transform', + setupAnimation: t => { + return addDiv(t).animate( + { transform: ['translateX(0px)', 'translateX(100px)'] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + }, + }, + { + desc: 'CSS animation of opacity', + setupAnimation: t => { + return addDiv(t, { style: 'animation: opacity 100s 100s' }) + .getAnimations()[0]; + }, + }, + { + desc: 'CSS animation of transform', + setupAnimation: t => { + return addDiv(t, { style: 'animation: anim 100s 100s' }) + .getAnimations()[0]; + }, + }, + { + desc: 'CSS transition of opacity', + setupAnimation: t => { + var div = addDiv(t, { style: 'transition: opacity 100s 100s' }); + getComputedStyle(div).opacity; + + div.style.opacity = 0; + return div.getAnimations()[0]; + }, + }, + { + desc: 'CSS transition of transform', + setupAnimation: t => { + var div = addDiv(t, { style: 'transition: transform 100s 100s' }); + getComputedStyle(div).transform; + + div.style.transform = 'translateX(100px)'; + return div.getAnimations()[0]; + }, + }, +]; + +delayPhaseTests.forEach(test => { + promise_test(async t => { + var animation = test.setupAnimation(t); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + test.desc + ' reports that it is running on the ' + + 'compositor even though it is in the delay phase'); + }, 'isRunningOnCompositor for ' + test.desc + ' is true even though ' + + 'it is in the delay phase'); +}); + +// The purpose of thie test cases is to check that +// NS_FRAME_MAY_BE_TRANSFORMED flag on the associated nsIFrame persists +// after transform style on the frame is removed. +var delayPhaseWithTransformStyleTests = [ + { + desc: 'script animation of transform with transform style', + setupAnimation: t => { + return addDiv(t, { style: 'transform: translateX(10px)' }).animate( + { transform: ['translateX(0px)', 'translateX(100px)'] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + }, + }, + { + desc: 'CSS animation of transform with transform style', + setupAnimation: t => { + return addDiv(t, { style: 'animation: anim 100s 100s;' + + 'transform: translateX(10px)' }) + .getAnimations()[0]; + }, + }, + { + desc: 'CSS transition of transform with transform style', + setupAnimation: t => { + var div = addDiv(t, { style: 'transition: transform 100s 100s;' + + 'transform: translateX(10px)'}); + getComputedStyle(div).transform; + + div.style.transform = 'translateX(100px)'; + return div.getAnimations()[0]; + }, + }, +]; + +delayPhaseWithTransformStyleTests.forEach(test => { + promise_test(async t => { + var animation = test.setupAnimation(t); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + test.desc + ' reports that it is running on the ' + + 'compositor even though it is in the delay phase'); + + // Remove the initial transform style during delay phase. + animation.effect.target.style.transform = 'none'; + await animation.ready; + + assert_animation_is_running_on_compositor(animation, + test.desc + ' reports that it keeps running on the ' + + 'compositor after removing the initial transform style'); + }, 'isRunningOnCompositor for ' + test.desc + ' is true after removing ' + + 'the initial transform style during the delay phase'); +}); + +var startsWithNoneTests = [ + { + desc: 'script animation of transform starts with transform:none segment', + setupAnimation: t => { + return addDiv(t).animate( + { transform: ['none', 'none', 'translateX(100px)'] }, 100 * MS_PER_SEC); + }, + }, + { + desc: 'CSS animation of transform starts with transform:none segment', + setupAnimation: t => { + return addDiv(t, + { style: 'animation: transform-starts-with-none 100s 100s' }) + .getAnimations()[0]; + }, + }, +]; + +startsWithNoneTests.forEach(test => { + promise_test(async t => { + var animation = test.setupAnimation(t); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + test.desc + ' reports that it is running on the ' + + 'compositor even though it is in transform:none segment'); + }, 'isRunningOnCompositor for ' + test.desc + ' is true even though ' + + 'it is in transform:none segment'); +}); + +promise_test(async t => { + var div = addDiv(t, { style: 'opacity: 1 ! important' }); + + var animation = div.animate( + { opacity: [0, 1] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Opacity animation on an element which has opacity:1 important style' + + 'reports that it is not running on the compositor'); + // Clear the opacity style on the target element. + div.style.setProperty("opacity", "1", ""); + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Opacity animations reports that it is running on the compositor after ' + + 'clearing the opacity style on the element'); +}, 'Clearing *important* opacity style on the target element sends the ' + + 'animation to the compositor even if the animation is in the delay phase'); + +promise_test(async t => { + var opaqueDiv = addDiv(t, { style: 'opacity: 1 ! important' }); + var anotherDiv = addDiv(t); + + var animation = opaqueDiv.animate( + { opacity: [0, 1] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Opacity animation on an element which has opacity:1 important style' + + 'reports that it is not running on the compositor'); + // Changing target element to another element which has no opacity style. + animation.effect.target = anotherDiv; + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Opacity animations reports that it is running on the compositor after ' + + 'changing the target element to another elemenent having no ' + + 'opacity style'); +}, 'Changing target element of opacity animation sends the animation to the ' + + 'the compositor even if the animation is in the delay phase'); + +promise_test(async t => { + var animation = + addDivAndAnimate(t, + {}, + { width: ['100px', '200px'] }, + { duration: 100 * MS_PER_SEC, delay: 100 * MS_PER_SEC }); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Width animation reports that it is not running on the compositor ' + + 'in the delay phase'); + // Changing to property runnable on the compositor. + animation.effect.setKeyframes({ opacity: [0, 1] }); + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Opacity animation reports that it is running on the compositor ' + + 'after changing the property from width property in the delay phase'); +}, 'Dynamic change to a property runnable on the compositor ' + + 'in the delay phase'); + +promise_test(async t => { + var div = addDiv(t, { style: 'transition: opacity 100s; ' + + 'opacity: 0 !important' }); + getComputedStyle(div).opacity; + + div.style.setProperty('opacity', '1', 'important'); + getComputedStyle(div).opacity; + + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Transition reports that it is running on the compositor even if the ' + + 'property is overridden by an !important rule'); +}, 'Transitions override important rules'); + +promise_test(async t => { + var div = addDiv(t, { style: 'transition: opacity 100s; ' + + 'opacity: 0 !important' }); + getComputedStyle(div).opacity; + + div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + div.style.setProperty('opacity', '1', 'important'); + getComputedStyle(div).opacity; + + var [transition, animation] = div.getAnimations(); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(transition, + 'Transition suppressed by an animation which is overridden by an ' + + '!important rule reports that it is NOT running on the compositor'); + assert_animation_is_not_running_on_compositor(animation, + 'Animation overridden by an !important rule reports that it is ' + + 'NOT running on the compositor'); +}, 'Neither transition nor animation does run on the compositor if the ' + + 'property is overridden by an !important rule'); + +promise_test(async t => { + var div = addDiv(t, { style: 'display: table' }); + var animation = + div.animate({ transform: ['rotate(0deg)', 'rotate(360deg)'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Transform animation on display:table element should be running on the' + + ' compositor'); +}, 'Transform animation on display:table element runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t, { style: 'display: table' }); + const animation = div.animate(null, 100 * MS_PER_SEC); + const effect = new KeyframeEffect(div, + { transform: ['none', 'none']}, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + + animation.effect = effect; + + await waitForNextFrame(); + await waitForPaints(); + + assert_animation_is_running_on_compositor( + animation, + 'Transform animation on table element should be running on the compositor' + ); +}, 'Empty transform effect assigned after the fact to display:table content' + + ' runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ backgroundColor: ['blue', 'green'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'background-color animation should be running on the compositor'); +}, 'background-color animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ backgroundColor: ['blue', 'green'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'background-color animation should be running on the compositor'); + + // Add a red opaque background image covering the background color animation. + div.style.backgroundImage = + 'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAIAAAAC64' + + 'paAAAAG0lEQVR42mP8z0A%2BYKJA76jmUc2jmkc1U0EzACKcASfOgGoMAAAAAElFTkSuQmCC)'; + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + // Bug 1712246. We should optimize this case eventually. + //assert_animation_is_not_running_on_compositor(animation, + // 'Opaque background image stops background-color animations from running ' + + // 'on the compositor'); +}, 'Opaque background image stops background-color animations from running ' + + ' on the compositor'); + +promise_test(async t => { + await SpecialPowers.pushPrefEnv({ + set: [["gfx.omta.background-color", false]] + }); + + const div = addDiv(t); + const animation = div.animate({ backgroundColor: ['blue', 'green'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'background-color animation should NOT be running on the compositor ' + + 'if the pref is disabled'); +}, 'background-color animation does not run on the compositor if the pref ' + + 'is disabled'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ translate: ['0px', '100px'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'translate animation should be running on the compositor'); +}, 'translate animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ rotate: ['0deg', '45deg'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'rotate animation should be running on the compositor'); +}, 'rotate animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ scale: ['1 1', '2 2'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'scale animation should be running on the compositor'); +}, 'scale animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ translate: ['0px', '100px'], + rotate: ['0deg', '45deg'], + transform: ['translate(20px)', + 'translate(30px)'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'multiple transform-like properties animation should be running on the ' + + 'compositor'); + + const properties = animation.effect.getProperties(); + properties.forEach(property => { + assert_true(property.runningOnCompositor, + property.property + ' is running on the compositor'); + }); +}, 'Multiple transform-like properties animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetPath: ['none', 'none'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-path animation should be running on the compositor even if ' + + 'it is always none'); +}, 'offset-path none animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetPath: ['path("M0 0l100 100")', + 'path("M0 0l200 200")'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-path:path() animation should be running on the compositor'); +}, 'offset-path:path() animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetPath: ['ray(0deg)', + 'ray(180deg)'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-path:ray() animation should be running on the compositor'); +}, 'offset-path:ray() animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetPath: ['inset(0px)', + 'inset(10px)'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-path:inset() animation should be running on the compositor'); +}, 'offset-path:inset() animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetPath: ['circle(10px)', + 'circle(20px)'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-path:circle() animation should be running on the compositor'); +}, 'offset-path:circle() animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetPath: ['ellipse(10px 20px)', + 'ellipse(20px 40px)'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-path:ellipse() animation should be running on the compositor'); +}, 'offset-path:ellipse() animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetPath: ['polygon(0px 0px)', + 'polygon(50px 50px)'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-path:polygon() animation should be running on the compositor'); +}, 'offset-path:polygon() animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetPath: ['padding-box', + 'padding-box'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-path:padding-box animation should be running on the compositor'); +}, 'offset-path:padding-box animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetPath: ['content-box', + 'content-box'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-path:content-box animation should be running on the compositor'); +}, 'offset-path:content-box animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetPath: ['xywh(0% 0% 10px 10px)', + 'xywh(10% 10% 20px 20px)'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-path:xywh() animation should be running on the compositor'); +}, 'offset-path:xywh() animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetPath: ['rect(0% 0% 10px 10px)', + 'rect(10% 10% 20px 20px)'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-path:rect() animation should be running on the compositor'); +}, 'offset-path:rect() animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetDistance: ['0%', '100%'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'offset-distance animation is not running on the compositor because ' + + 'offset-path is none'); + + const newAnim = div.animate({ offsetPath: ['None', 'None'] }, + 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(newAnim); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-distance animation should be running on the compositor'); + assert_animation_is_running_on_compositor(newAnim, + 'new added offset-path animation should be running on the compositor'); +}, 'offset-distance animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetRotate: ['0deg', '45deg'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'offset-rotate animation is not running on the compositor because ' + + 'offset-path is none'); + + const newAnim = div.animate({ offsetPath: ['None', 'None'] }, + 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(newAnim); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-rotate animation should be running on the compositor'); + assert_animation_is_running_on_compositor(newAnim, + 'new added offset-path animation should be running on the compositor'); +}, 'offset-rotate animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetAnchor: ['0% 0%', '100% 100%'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'offset-anchor animation is not running on the compositor because ' + + 'offset-path is none'); + + const newAnim = div.animate({ offsetPath: ['None', 'None'] }, + 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(newAnim); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-anchor animation should be running on the compositor'); + assert_animation_is_running_on_compositor(newAnim, + 'new added offset-path animation should be running on the compositor'); +}, 'offset-anchor animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetPosition: ['0% 0%', '100% 100%'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'offset-position animation is not running on the compositor because ' + + 'offset-path is none'); + + const newAnim = div.animate({ offsetPath: ['None', 'None'] }, + 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(newAnim); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-position animation should be running on the compositor'); + assert_animation_is_running_on_compositor(newAnim, + 'new added offset-path animation should be running on the compositor'); +}, 'offset-position animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ translate: ['0px', '100px'], + rotate: ['0deg', '45deg'], + transform: ['translate(0px)', + 'translate(100px)'], + offsetDistance: ['0%', '100%'] }, + 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation is running on the compositor even though we do not have ' + + 'offset-path'); + + div.style.offsetPath = 'path("M50 0v100")'; + getComputedStyle(div).offsetPath; + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation is running on the compositor'); + +}, 'Multiple transform-like properties (include motion-path) animation runs ' + + 'on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ translate: ['0px', '100px'], + rotate: ['0deg', '45deg'], + transform: ['translate(20px)', + 'translate(30px)'], + offsetDistance: ['0%', '100%'] }, + 100 * MS_PER_SEC); + + div.style.setProperty('translate', '50px', 'important'); + getComputedStyle(div).translate; + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation overridden by an !important rule reports that it is ' + + 'NOT running on the compositor'); + + const properties = animation.effect.getProperties(); + properties.forEach(property => { + assert_true(!property.runningOnCompositor, + property.property + ' is not running on the compositor'); + }); +}, 'Multiple transform-like properties animation does not runs on the ' + + 'compositor because one of the transform-like property is overridden ' + + 'by an !important rule'); + +// FIXME: Bug 1593106: We should still run the animations on the compositor if +// offset-* doesn't have any effect. +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ translate: ['0px', '100px'], + rotate: ['0deg', '45deg'], + transform: ['translate(0px)', + 'translate(100px)'], + offsetDistance: ['0%', '100%'] }, + 100 * MS_PER_SEC); + + div.style.setProperty('offset-distance', '50%', 'important'); + getComputedStyle(div).offsetDistance; + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation overridden by an !important rule reports that it is ' + + 'NOT running on the compositor'); + + const properties = animation.effect.getProperties(); + properties.forEach(property => { + assert_true(!property.runningOnCompositor, + property.property + ' is not running on the compositor'); + }); +}, 'Multiple transform-like properties animation does not runs on the ' + + 'compositor because one of the offset-* property is overridden ' + + 'by an !important rule'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ rotate: ['0deg', '45deg'], + transform: ['translate(20px)', + 'translate(30px)'], + offsetDistance: ['0%', '100%'] }, + 100 * MS_PER_SEC); + + div.style.setProperty('translate', '50px', 'important'); + getComputedStyle(div).translate; + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation is still running on the compositor'); + + const properties = animation.effect.getProperties(); + properties.forEach(property => { + assert_true(property.runningOnCompositor, + property.property + ' is running on the compositor'); + }); +}, 'Multiple transform-like properties animation still runs on the ' + + 'compositor because the overridden-by-!important property does not have ' + + 'animation'); + +promise_test(async t => { + // We should run the animations on the compositor for this case: + // 1. A transition of 'translate' + // 2. An !important rule on 'translate' + // 3. An animation of 'scale' + const div = addDiv(t, { style: 'translate: 100px !important;' }); + const animation = div.animate({ rotate: ['0deg', '45deg'] }, + 100 * MS_PER_SEC); + div.style.transition = 'translate 100s'; + getComputedStyle(div).transition; + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'rotate animation should be running on the compositor'); + + div.style.setProperty('translate', '200px', 'important'); + getComputedStyle(div).translate; + + const anims = div.getAnimations(); + await waitForPaints(); + + assert_animation_is_running_on_compositor(anims[0], + `${anims[0].effect.getProperties()[0].property} animation should be ` + + `running on the compositor`); + assert_animation_is_running_on_compositor(anims[1], + `${anims[1].effect.getProperties()[0].property} animation should be ` + + `running on the compositor`); +}, 'Transform-like animations and transitions still runs on the compositor ' + + 'because the !important rule is overridden by a transition, and the ' + + 'transition property does not have animations'); + +promise_test(async t => { + const container = addDiv(t, { style: 'transform-style: preserve-3d;' }); + const targetA = addDiv(t, { style: 'transform-style: preserve-3d' }); + const targetB = addDiv(t, { style: 'transform-style: preserve-3d' }); + const targetC = addDiv(t); + container.appendChild(targetA); + targetA.append(targetB); + targetB.append(targetC); + + const animation1 = targetA.animate({ rotate: ['0 0 1 0deg', '1 1 1 45deg'] }, + 100 * MS_PER_SEC); + + const animation2 = targetC.animate({ rotate: ['0 0 1 0deg', '0 1 1 100deg'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation1); + await waitForAnimationReadyToRestyle(animation2); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation1, + 'rotate animation in the 3d rendering context should be running on the ' + + 'compositor'); + assert_animation_is_running_on_compositor(animation2, + 'rotate animation in the 3d rendering context should be running on the ' + + 'compositor'); +}, 'Transform-like animations in the 3d rendering context should runs on the ' + + 'compositor'); + +promise_test(async t => { + const container = addDiv(t, { style: 'transform-style: preserve-3d;' }); + const target = addDiv(t, { style: 'transform-style: preserve-3d;' }); + const innerA = addDiv(t, { style: 'width: 50px; height: 50px;' }); + // The frame of innerB is too large, so this makes its ancenstors and children + // in the 3d context be not allowed the async animations. + const innerB = addDiv(t, { style: 'rotate: 0 1 1 100deg; ' + + 'transform-style: preserve-3d; ' + + 'text-indent: -9999em' }); + const innerB2 = addDiv(t, { style: 'rotate: 0 1 1 45deg;' }); + const innerBText = document.createTextNode("innerB"); + container.appendChild(target); + target.appendChild(innerA); + target.appendChild(innerB); + innerB.appendChild(innerBText); + innerB.appendChild(innerB2); + + const animation1 = target.animate({ rotate: ['0 0 1 0deg', '1 1 1 45deg'] }, + 100 * MS_PER_SEC); + + const animation2 = innerA.animate({ rotate: ['0 0 1 0deg', '0 1 1 100deg'] }, + 100 * MS_PER_SEC); + + const animation3 = innerB2.animate({ rotate: ['0 0 1 0deg', '0 1 1 90deg'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation1); + await waitForAnimationReadyToRestyle(animation2); + await waitForAnimationReadyToRestyle(animation3); + await waitForPaints(); + + const isPartialPrerenderEnabled = + SpecialPowers.getBoolPref('layout.animation.prerender.partial'); + + if (isPartialPrerenderEnabled) { + assert_animation_is_running_on_compositor(animation1, + 'rotate animation in the 3d rendering context should be running on ' + + 'the compositor even if one of its inner frames is too large'); + assert_animation_is_running_on_compositor(animation2, + 'rotate animation in the 3d rendering context is still running on ' + + 'the compositor because its display item is created earlier'); + assert_animation_is_running_on_compositor(animation3, + 'rotate animation in the 3d rendering context should be running on ' + + 'the compositor even if one of its parent frames is too large'); + } else { + assert_animation_is_not_running_on_compositor(animation1, + 'rotate animation in the 3d rendering context should not be running on ' + + 'the compositor because one of its inner frames is too large'); + assert_animation_is_running_on_compositor(animation2, + 'rotate animation in the 3d rendering context is still running on ' + + 'the compositor because its display item is created earlier'); + assert_animation_is_not_running_on_compositor(animation3, + 'rotate animation in the 3d rendering context should not be running on ' + + 'the compositor because one of its parent frames is too large'); + } + innerBText.remove(); +}, 'Transform-like animations in the 3d rendering context should run on ' + + 'the compositor even if it is the ancestor of ones whose frames are too ' + + 'large or its ancestor is not allowed to run on the compositor'); + +promise_test(async t => { + const container = addDiv(t, { style: 'transform-style: preserve-3d;' }); + const target = addDiv(t, { style: 'transform-style: preserve-3d;' }); + // The frame of innerA is too large, so this makes its ancenstors and children + // in the 3d context be not allowed the async animations. + const innerA = addDiv(t, { style: 'transform-style: preserve-3d; ' + + 'text-indent: -9999em' }); + const innerAText = document.createTextNode("innerA"); + const innerB = addDiv(t, { style: 'width: 50px; height: 50px;' }); + container.appendChild(target); + target.appendChild(innerA); + target.appendChild(innerB); + innerA.appendChild(innerAText); + + const animation1 = target.animate({ rotate: ['0 0 1 0deg', '1 1 1 45deg'] }, + 100 * MS_PER_SEC); + + const animation2 = innerA.animate({ rotate: ['0 0 1 0deg', '0 1 1 100deg'] }, + 100 * MS_PER_SEC); + + const animation3 = innerB.animate({ rotate: ['0 0 1 0deg', '0 1 1 90deg'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation1); + await waitForAnimationReadyToRestyle(animation2); + await waitForAnimationReadyToRestyle(animation3); + await waitForPaints(); + + const isPartialPrerenderEnabled = + SpecialPowers.getBoolPref('layout.animation.prerender.partial'); + + if (isPartialPrerenderEnabled) { + assert_animation_is_running_on_compositor(animation1, + 'rotate animation in the 3d rendering context should be running on ' + + 'the compositor even if one of its inner frames is too large'); + assert_animation_is_running_on_compositor(animation2, + 'rotate animation in the 3d rendering context should be running on ' + + 'the compositor even if its frame size is too large'); + assert_animation_is_running_on_compositor(animation3, + 'rotate animation in the 3d rendering context should be running on ' + + 'the compositor even if its previous sibling frame is too large'); + } else { + assert_animation_is_not_running_on_compositor(animation1, + 'rotate animation in the 3d rendering context should not be running on ' + + 'the compositor because one of its inner frames is too large'); + assert_animation_is_not_running_on_compositor(animation2, + 'rotate animation in the 3d rendering context should not be running on ' + + 'the compositor because its frame size is too large'); + assert_animation_is_not_running_on_compositor(animation3, + 'rotate animation in the 3d rendering context should not be running on ' + + 'the compositor because its previous sibling frame is too large'); + } + innerAText.remove(); +}, 'Transform-like animations in the 3d rendering context should run on ' + + 'the compositor even if its previous sibling frame size is too large'); + +</script> +</body> |