diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /dom/animation/test/chrome | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'dom/animation/test/chrome')
9 files changed, 5895 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 000000000..8a68fc548 --- /dev/null +++ b/dom/animation/test/chrome/file_animate_xrays.html @@ -0,0 +1,19 @@ +<!doctype html> +<html> +<head> +<meta charset=utf-8> +<script> +Element.prototype.animate = function() { + throw 'Called animate() as defined in content document'; +} +// Bug 1211783: Use KeyframeEffect (not KeyframeEffectReadOnly) here +for (var obj of [KeyframeEffectReadOnly, Animation]) { + obj = function() { + throw 'Called overridden ' + String(obj) + ' 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 000000000..56b981bf1 --- /dev/null +++ b/dom/animation/test/chrome/test_animate_xrays.html @@ -0,0 +1,31 @@ +<!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=1045994" + target="_blank">Mozilla Bug 1045994</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: [ 0, 1 ] }, 100 * MS_PER_SEC); + // In the x-ray case, the frames object will be given an opaque wrapper + // so it won't be possible to fetch any frames from it. + assert_equals(anim.effect.getKeyframes().length, 0); + t.done(); + })); +}, 'Calling animate() across x-rays'); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_animation_observers.html b/dom/animation/test/chrome/test_animation_observers.html new file mode 100644 index 000000000..237128e04 --- /dev/null +++ b/dom/animation/test/chrome/test_animation_observers.html @@ -0,0 +1,1177 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Test chrome-only MutationObserver animation notifications</title> +<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> +<script src="../testcommon.js"></script> +<link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +<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(function(newRecords) { + gRecords.push(...newRecords); +}); + +// Asynchronous testing framework based on layout/style/test/animation_utils.js. + +var gTests = []; +var gCurrentTestName; + +function addAsyncAnimTest(aName, aOptions, aTestGenerator) { + aTestGenerator.testName = aName; + aTestGenerator.options = aOptions || {}; + gTests.push(aTestGenerator); +} + +function runAsyncTest(aTestGenerator) { + return waitForFrame().then(function() { + var generator; + + function step(arg) { + var next; + try { + next = generator.next(arg); + } catch (e) { + return Promise.reject(e); + } + if (next.done) { + return Promise.resolve(next.value); + } else { + return Promise.resolve(next.value).then(step); + } + } + + var subtree = aTestGenerator.options.subtree; + + gCurrentTestName = aTestGenerator.testName; + if (subtree) { + gCurrentTestName += ":subtree"; + } + + gRecords = []; + gObserver.disconnect(); + gObserver.observe(aTestGenerator.options.observe, + { animations: true, subtree: subtree}); + + generator = aTestGenerator(); + return step(); + }); +}; + +function runAllAsyncTests() { + return gTests.reduce(function(sequence, test) { + return sequence.then(() => runAsyncTest(test)); + }, Promise.resolve()); +} + +// Wrap is and ok with versions that prepend the current sub-test name +// to the assertion description. +var old_is = is, old_ok = ok; +is = function(a, b, message) { + if (gCurrentTestName && message) { + message = `[${gCurrentTestName}] ${message}`; + } + old_is(a, b, message); +} +ok = function(a, message) { + if (gCurrentTestName && message) { + message = `[${gCurrentTestName}] ${message}`; + } + old_ok(a, message); +} + +// 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(function(aResolve) { + function listener(aEvent) { + aElement.removeEventListener(aEventName, listener); + aResolve(); + } + aElement.addEventListener(aEventName, listener, false); + }); +} + +function assert_record_list(actual, expected, desc, index, listName) { + is(actual.length, expected.length, `${desc} - record[${index}].${listName} length`); + if (actual.length != expected.length) { + return; + } + for (var i = 0; i < actual.length; i++) { + ok(actual.indexOf(expected[i]) != -1, + `${desc} - record[${index}].${listName} contains expected Animation`); + } +} + +function assert_records(expected, desc) { + var records = gRecords; + gRecords = []; + is(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"); + } +} + +// -- 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. + +[ + { observe: div, target: div, subtree: false }, + { observe: div.parentNode, target: div, subtree: true }, +].forEach(function(aOptions) { + + var e = aOptions.target; + + // Test that starting a single transition that completes normally + // dispatches an added notification and then a removed notification. + addAsyncAnimTest("single_transition", aOptions, function*() { + // 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(); + is(animations.length, 1, "getAnimations().length after transition start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + 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; + yield transitionEnd; + + // After the transition has finished, the Animation should disappear. + is(e.getAnimations().length, 0, + "getAnimations().length after transition end"); + + // Wait for the change MutationRecord for seeking the Animation to be + // delivered, followed by the the removal MutationRecord. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }, + { added: [], changed: [], removed: animations }], + "records after transition end"); + + e.style = ""; + }); + + // Test that starting a single transition that is cancelled by resetting + // the transition-property property dispatches an added notification and + // then a removed notification. + addAsyncAnimTest("single_transition_cancelled_property", aOptions, function*() { + // Start a long transition. + e.style = "transition: background-color 100s; background-color: lime;"; + + // The transition should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after transition start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after transition start"); + + // Cancel the transition by setting transition-property. + e.style.transitionProperty = "none"; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after transition end"); + + e.style = ""; + }); + + // 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. + addAsyncAnimTest("single_transition_cancelled_value", aOptions, function*() { + // Start a long transition with a predictable value. + e.style = "transition: background-color 100s steps(2, end) -51s; background-color: lime;"; + + // The transition should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after transition start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after transition start"); + + // Cancel the transition by setting the current animation value. + var value = "rgb(128, 255, 0)"; + is(getComputedStyle(e).backgroundColor, value, "half-way transition value"); + e.style.backgroundColor = value; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after transition end"); + + e.style = ""; + }); + + // 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. + addAsyncAnimTest("single_transition_cancelled_noninterpolable", aOptions, function*() { + // Start a long transition. + e.style = "transition: line-height 100s; line-height: 100px;"; + + // The transition should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after transition start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after transition start"); + + // Cancel the transition by setting line-height to a non-interpolable value. + e.style.lineHeight = "normal"; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after transition end"); + + e.style = ""; + }); + + // 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. + addAsyncAnimTest("single_transition_reversed", aOptions, function*() { + // Start a long transition. + e.style = "transition: background-color 100s step-start; background-color: lime;"; + + // The transition should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after transition start"); + + var firstAnimation = animations[0]; + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [firstAnimation], changed: [], removed: [] }], + "records after transition start"); + + // Wait for the Animation to be playing, then seek well into + // the transition. + yield firstAnimation.ready; + firstAnimation.currentTime = 50 * MS_PER_SEC; + + // Reverse the transition by setting the background-color back to its + // original value. + e.style.backgroundColor = "yellow"; + + // The reversal should cause the creation of a new Animation. + animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after transition reversal"); + + var secondAnimation = animations[0]; + + ok(firstAnimation != secondAnimation, + "second Animation should be different from the first"); + + // Wait for the change Mutation record from seeking the first animation + // to be delivered, followed by a subsequent MutationRecord for the removal + // of the original Animation and the addition of the new Animation. + yield waitForFrame(); + assert_records([{ added: [], changed: [firstAnimation], removed: [] }, + { added: [secondAnimation], changed: [], + removed: [firstAnimation] }], + "records after transition reversal"); + + // Cancel the transition. + e.style.transitionProperty = "none"; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: [secondAnimation] }], + "records after transition end"); + + e.style = ""; + }); + + // Test that multiple transitions starting and ending on an element + // at the same time get batched up into a single MutationRecord. + addAsyncAnimTest("multiple_transitions", aOptions, function*() { + // Start three long transitions. + e.style = "transition-duration: 100s; " + + "transition-property: color, background-color, line-height; " + + "color: blue; background-color: lime; line-height: 24px;"; + + // The transitions should cause the creation of three Animations. + var animations = e.getAnimations(); + is(animations.length, 3, "getAnimations().length after transition starts"); + + // Wait for the single MutationRecord for the Animation additions to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after transition starts"); + + // Wait for the Animations to get going. + yield animations[0].ready; + is(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. + e.style.transitionProperty = "background-color, line-height"; + + var colorAnimation = animations.filter(p => p.playState != "running"); + var otherAnimations = animations.filter(p => p.playState == "running"); + + is(colorAnimation.length, 1, + "number of non-running Animations after cancelling one"); + is(otherAnimations.length, 2, + "number of running Animations after cancelling one"); + + // Wait for the MutationRecords to be delivered: one for each animation + // that was seeked, followed by one for the removal of the color animation. + yield waitForFrame(); + assert_records(seekRecords.concat( + { added: [], changed: [], removed: colorAnimation }), + "records after color transition end"); + + // Cancel the remaining transitions. + e.style.transitionProperty = "none"; + + // Wait for the MutationRecord for the other two Animation + // removals to be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: otherAnimations }], + "records after other transition ends"); + + e.style = ""; + }); + + // Test that starting a single animation that completes normally + // dispatches an added notification and then a removed notification. + addAsyncAnimTest("single_animation", aOptions, function*() { + // 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(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + 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; + yield animationEnd; + + // After the animation has finished, the Animation should disappear. + is(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. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }, + { added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + + // Test that starting a single animation that is cancelled by resetting + // the animation-name property dispatches an added notification and + // then a removed notification. + addAsyncAnimTest("single_animation_cancelled_name", aOptions, function*() { + // Start a long animation. + e.style = "animation: anim 100s;"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Cancel the animation by setting animation-name. + e.style.animationName = "none"; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + + // Test that starting a single animation that is cancelled by updating + // the animation-duration property dispatches an added notification and + // then a removed notification. + addAsyncAnimTest("single_animation_cancelled_duration", aOptions, function*() { + // Start a long animation. + e.style = "animation: anim 100s;"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ 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. + e.style.animationDuration = "0.1s"; + + // Wait for the change MutationRecord from seeking the Animation to + // be delivered, followed by a further MutationRecord for the Animation + // removal. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }, + { added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + + // Test that starting a single animation that is cancelled by updating + // the animation-delay property dispatches an added notification and + // then a removed notification. + addAsyncAnimTest("single_animation_cancelled_delay", aOptions, function*() { + // Start a long animation. + e.style = "animation: anim 100s;"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Cancel the animation by setting animation-delay. + e.style.animationDelay = "-200s"; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + + // 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. + addAsyncAnimTest("single_animation_cancelled_fill", aOptions, function*() { + // 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(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + 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; + yield animationEnd; + + // 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"; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + + // 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. + addAsyncAnimTest("single_animation_cancelled_iteration_count", + aOptions, function*() { + // Start a short, repeated animation. + e.style = "animation: anim 0.5s infinite;"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ 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; + + // The only MutationRecord at this point should be the change from + // seeking the Animation. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }], + "records after seeking animations"); + + // Cancel the animation by setting animation-iteration-count. + e.style.animationIterationCount = "1"; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + + // 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(function(aChangeTest) { + addAsyncAnimTest(`single_animation_change_${aChangeTest.name}`, aOptions, function*() { + // Start a long animation. + e.style = "animation: anim 100s;"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Change a property of the animation such that it keeps running. + e.style[aChangeTest.prop] = aChangeTest.val; + + // Wait for the single MutationRecord for the Animation change to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }], + "records after animation change"); + + // Cancel the animation. + e.style.animationName = "none"; + + // Wait for the addition, change and removal MutationRecords to be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + }); + + // Test that calling finish() on a paused (but otherwise finished) animation + // dispatches a changed notification. + addAsyncAnimTest("finish_from_pause", aOptions, function*() { + // Start a long animation + e.style = "animation: anim 100s forwards"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is playing. + yield animations[0].ready; + + // Finish and pause. + animations[0].finish(); + animations[0].pause(); + + // Wait for the pause to complete. + yield animations[0].ready; + is(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(); + is(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. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }], + "records after finish() and pause()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that calling finish() on a pause-pending (but otherwise finished) + // animation dispatches a changed notification. + addAsyncAnimTest("finish_from_pause_pending", aOptions, function*() { + // Start a long animation + e.style = "animation: anim 100s forwards"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is playing. + yield animations[0].ready; + + // Finish and pause. + animations[0].finish(); + animations[0].pause(); + is(animations[0].playState, "pending", + "playState after finishing and calling pause()"); + + // Call finish() again to abort the pause + animations[0].finish(); + is(animations[0].playState, "finished", + "playState after finishing and calling pause()"); + + // Wait for three MutationRecords for the Animation changes to + // be delivered: one for each finish(), pause(), finish() operation. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after finish(), pause(), finish()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that calling play() on a paused Animation dispatches a changed + // notification. + addAsyncAnimTest("play", aOptions, function*() { + // 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(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is ready + yield animations[0].ready; + + // Play + animations[0].play(); + + // Wait for the single MutationRecord for the Animation change to + // be delivered. + yield animations[0].ready; + assert_records([{ added: [], changed: animations, removed: [] }], + "records after play()"); + + // Redundant play + animations[0].play(); + + // Wait to ensure no change is dispatched + yield waitForFrame(); + assert_records([], "records after redundant play()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that calling play() on a finished Animation that fills forwards + // dispatches a changed notification. + addAsyncAnimTest("play_filling_forwards", aOptions, function*() { + // Start a long animation with a forwards fill + e.style = "animation: anim 100s forwards"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is ready + yield animations[0].ready; + + // Seek to the end + animations[0].finish(); + + // Wait for the single MutationRecord for the Animation change to + // be delivered. + yield waitForFrame(); + assert_records([{ 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(); + + // Wait for the single MutationRecord for the Animation change to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }], + "records after play()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that calling play() on a finished Animation that does *not* fill + // forwards dispatches an addition notification. + addAsyncAnimTest("play_after_finish", aOptions, function*() { + // Start a long animation + e.style = "animation: anim 100s"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is ready + yield animations[0].ready; + + // Seek to the end + animations[0].finish(); + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ 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(); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after play()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that calling pause() on an Animation dispatches a changed + // notification. + addAsyncAnimTest("pause", aOptions, function*() { + // Start a long animation + e.style = "animation: anim 100s"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is ready + yield animations[0].ready; + + // Pause + animations[0].pause(); + + // Wait for the single MutationRecord for the Animation change to + // be delivered. + yield animations[0].ready; + assert_records([{ added: [], changed: animations, removed: [] }], + "records after pause()"); + + // Redundant pause + animations[0].pause(); + + // Wait to ensure no change is dispatched + yield animations[0].ready; + assert_records([], "records after redundant pause()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that calling pause() on an Animation that is pause-pending + // does not dispatch an additional changed notification. + addAsyncAnimTest("pause_while_pause_pending", aOptions, function*() { + // Start a long animation + e.style = "animation: anim 100s"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is ready + yield animations[0].ready; + + // Pause + animations[0].pause(); + + // We are now pause-pending, but pause again + animations[0].pause(); + + // We should only get a single change record + yield animations[0].ready; + assert_records([{ added: [], changed: animations, removed: [] }], + "records after pause()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that calling play() on an Animation that is pause-pending + // dispatches a changed notification. + addAsyncAnimTest("aborted_pause", aOptions, function*() { + // Start a long animation + e.style = "animation: anim 100s"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is ready + yield animations[0].ready; + + // Pause + animations[0].pause(); + + // We are now pause-pending. If we play() now, we will abort the pause + animations[0].play(); + + // We should get two change records + yield animations[0].ready; + assert_records([{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after aborting a pause()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that a non-cancelling change to an animation followed immediately by a + // cancelling change will only send an animation removal notification. + addAsyncAnimTest("coalesce_change_cancel", aOptions, function*() { + // Start a long animation. + e.style = "animation: anim 100s;"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + 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. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + +}); + +addAsyncAnimTest("tree_ordering", { observe: div, subtree: true }, function*() { + // 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;", + ".after::after" : "animation: anim 100s, anim 100s;" }; + 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(); + is(docAnims.length, 10, "total animations"); + + var divAnimations = div.getAnimations(); + var childAAnimations = childA.getAnimations(); + var childBAnimations = childB.getAnimations(); + var divBeforeAnimations = + [ for (x of docAnims) if (x.effect.target.parentElement == div && + x.effect.target.type == "::before") x ]; + var divAfterAnimations = + [ for (x of docAnims) if (x.effect.target.parentElement == div && + x.effect.target.type == "::after") x ]; + var childBPseudoAnimations = + [ for (x of docAnims) if (x.effect.target.parentElement == childB) x ]; + + // 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 order + // these records are dispatched is consistent between runs. + // + // We currently expect to get records in order div::after, childA, childB, + // childB::before, div, div::before + yield waitForFrame(); + assert_records([{ added: divAfterAnimations, changed: [], removed: [] }, + { added: childAAnimations, changed: [], removed: [] }, + { added: childBAnimations, changed: [], removed: [] }, + { added: childBPseudoAnimations, changed: [], removed: [] }, + { added: divAnimations, changed: [], removed: [] }, + { added: divBeforeAnimations, changed: [], removed: [] }], + "records after simultaneous animation start"); + + // The one case where we *do* currently perform document-level (or actually + // timeline-level) batching is when animations are updated from a refresh + // driver tick. In particular, this means that when animations finish + // naturally the removed records should be dispatched according to the + // position of the elements in the tree. + + // First, flatten the set of animations. we put the animations targeting to + // pseudo elements last. (Actually, we don't care the order in the list.) + var animations = [ ...divAnimations, + ...childAAnimations, + ...childBAnimations, + ...divBeforeAnimations, + ...divAfterAnimations, + ...childBPseudoAnimations ]; + + // Fast-forward to *just* before the end of the animation. + animations.forEach(animation => animation.currentTime = 99999); + + // Prepare the set of expected change MutationRecords, one for each + // animation that was seeked. + var seekRecords = animations.map( + p => ({ added: [], changed: [p], removed: [] }) + ); + + yield await_event(div, "animationend"); + + // 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(); +}); + +// Run the tests. +SimpleTest.requestLongerTimeout(2); +SimpleTest.waitForExplicitFinish(); + +runAllAsyncTests().then(function() { + SimpleTest.finish(); +}, function(aError) { + ok(false, "Something failed: " + aError); +}); +</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 000000000..a3bd63efc --- /dev/null +++ b/dom/animation/test/chrome/test_animation_performance_warning.html @@ -0,0 +1,957 @@ +<!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 } +} +</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); + +SpecialPowers.pushPrefEnv({ "set": [ + ["general.useragent.locale", "en-US"], + // Need to set devPixelsPerPx explicitly to gain + // consistent pixel values in warning messages + // regardless of platform DPIs. + ["layout.css.devPixelsPerPx", 1], + ] }, + 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_property_state_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'); + } +} + +var gAnimationsTests = [ + { + desc: 'animations on compositor', + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true + } + ] + }, + { + desc: 'animations on main thread', + frames: { + backgroundColor: ['white', 'red'] + }, + expected: [ + { + property: 'background-color', + runningOnCompositor: false + } + ] + }, + { + desc: 'animations on both threads', + frames: { + backgroundColor: ['white', 'red'], + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'background-color', + 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: 'opacity on compositor with animation of geometric properties', + frames: { + width: ['100px', '200px'], + opacity: [0, 1] + }, + expected: [ + { + property: 'width', + runningOnCompositor: false + }, + { + property: 'opacity', + runningOnCompositor: true + } + ] + }, +]; + +// Test cases that check results of adding/removing a 'width' property on the +// same animation object. +var gAnimationWithGeometricKeyframeTests = [ + { + desc: 'transform', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: { + withoutGeometric: [ + { + property: 'transform', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + } + }, + { + desc: 'opacity and transform', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: { + withoutGeometric: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false + }, + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + } + }, +]; + +// Performance warning tests that set and clear a style property. +var gPerformanceWarningTestsStyle = [ + { + desc: 'preserve-3d transform', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + style: 'transform-style: preserve-3d', + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformPreserve3D' + } + ] + }, + { + desc: 'transform with backface-visibility:hidden', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + style: 'backface-visibility: hidden;', + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformBackfaceVisibilityHidden' + } + ] + }, + { + desc: 'opacity and transform with preserve-3d', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'] + }, + style: 'transform-style: preserve-3d', + expected: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformPreserve3D' + } + ] + }, + { + desc: 'opacity and transform with backface-visibility:hidden', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'] + }, + style: 'backface-visibility: hidden;', + expected: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformBackfaceVisibilityHidden' + } + ] + }, +]; + +// Performance warning tests that set and clear the id property +var gPerformanceWarningTestsId= [ + { + 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' + } + ] + }, +]; + +var gMultipleAsyncAnimationsTests = [ + { + desc: 'opacity and transform with preserve-3d', + style: 'transform-style: preserve-3d', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformPreserve3D' + } + ] + }, + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + ], + }, + { + desc: 'opacity and transform with backface-visibility:hidden', + style: 'backface-visibility: hidden;', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformBackfaceVisibilityHidden' + } + ] + }, + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + ], + }, +]; + +// Test cases that check results of 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]. +var gMultipleAsyncAnimationsWithGeometricKeyframeTests = [ + { + desc: 'transform and opacity with geometric keyframes', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: { + withoutGeometric: [ + { + property: 'transform', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + } + }, + { + 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: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + } + } + ] + }, +]; + +// Test cases that check results of adding/removing 'width' animation on the +// same element which has async animations. +var gMultipleAsyncAnimationsWithGeometricAnimationTests = [ + { + desc: 'transform', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + }, + ] + }, + { + desc: 'opacity', + animations: [ + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true + } + ] + }, + ] + }, + { + desc: 'opacity and transform', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + }, + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + ], + }, +]; + +var gAnimationsOnTooSmallElementTests = [ + { + desc: 'opacity on too 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: false, + warning: /Animation cannot be run on the compositor because frame size \(8, 8\) is smaller than \(16, 16\)/ + } + ] + }, + { + desc: 'transform on too small element', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + style: { style: 'width: 8px; height: 8px; background-color: red;' }, + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: /Animation cannot be run on the compositor because frame size \(8, 8\) is smaller than \(16, 16\)/ + } + ] + }, +]; + +function start() { + var bundleService = SpecialPowers.Cc['@mozilla.org/intl/stringbundle;1'] + .getService(SpecialPowers.Ci.nsIStringBundleService); + gStringBundle = bundleService + .createBundle("chrome://global/locale/layout_errors.properties"); + + gAnimationsTests.forEach(function(subtest) { + promise_test(function(t) { + var animation = addDivAndAnimate(t, + { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + return animation.ready.then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + }); + }, subtest.desc); + }); + + gAnimationWithGeometricKeyframeTests.forEach(function(subtest) { + promise_test(function(t) { + var animation = addDivAndAnimate(t, + { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + return animation.ready.then(function() { + // First, a transform animation is running on compositor. + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected.withoutGeometric); + }).then(function() { + // Add a 'width' property. + var keyframes = animation.effect.getKeyframes(); + + keyframes[0].width = '100px'; + keyframes[1].width = '200px'; + + animation.effect.setKeyframes(keyframes); + return waitForFrame(); + }).then(function() { + // 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); + }).then(function() { + // Remove the 'width' property. + var keyframes = animation.effect.getKeyframes(); + + delete keyframes[0].width; + delete keyframes[1].width; + + animation.effect.setKeyframes(keyframes); + return waitForFrame(); + }).then(function() { + // Finally, the transform animation is running on compositor. + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected.withoutGeometric); + }); + }, 'An animation has: ' + subtest.desc); + }); + + gPerformanceWarningTestsStyle.forEach(function(subtest) { + promise_test(function(t) { + var animation = addDivAndAnimate(t, + { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + return animation.ready.then(function() { + assert_property_state_on_compositor( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.style = subtest.style; + return waitForFrame(); + }).then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.style = ''; + return waitForFrame(); + }).then(function() { + assert_property_state_on_compositor( + animation.effect.getProperties(), + subtest.expected); + }); + }, subtest.desc); + }); + + gPerformanceWarningTestsId.forEach(function(subtest) { + promise_test(function(t) { + if (subtest.createelement) { + addDiv(t, { style: subtest.createelement }); + } + + var animation = addDivAndAnimate(t, + { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + return animation.ready.then(function() { + assert_property_state_on_compositor( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.id = subtest.id; + return waitForFrame(); + }).then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.id = ''; + return waitForFrame(); + }).then(function() { + assert_property_state_on_compositor( + animation.effect.getProperties(), + subtest.expected); + }); + }, subtest.desc); + }); + + gMultipleAsyncAnimationsTests.forEach(function(subtest) { + promise_test(function(t) { + var div = addDiv(t, { class: 'compositable' }); + var animations = subtest.animations.map(function(anim) { + var animation = div.animate(anim.frames, 100 * MS_PER_SEC); + + // Bind expected values to animation object. + animation.expected = anim.expected; + return animation; + }); + return waitForAllAnimations(animations).then(function() { + animations.forEach(function(anim) { + assert_property_state_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + div.style = subtest.style; + return waitForFrame(); + }).then(function() { + animations.forEach(function(anim) { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected); + }); + div.style = ''; + return waitForFrame(); + }).then(function() { + animations.forEach(function(anim) { + assert_property_state_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + }); + }, 'Multiple animations: ' + subtest.desc); + }); + + gMultipleAsyncAnimationsWithGeometricKeyframeTests.forEach(function(subtest) { + promise_test(function(t) { + var div = addDiv(t, { class: 'compositable' }); + var animations = subtest.animations.map(function(anim) { + var animation = div.animate(anim.frames, 100 * MS_PER_SEC); + + // Bind expected values to animation object. + animation.expected = anim.expected; + return animation; + }); + return waitForAllAnimations(animations).then(function() { + // First, all animations are running on compositor. + animations.forEach(function(anim) { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected.withoutGeometric); + }); + }).then(function() { + // 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); + return waitForFrame(); + }).then(function() { + // Now the transform animation is not running on compositor because of + // the 'width' property. + animations.forEach(function(anim) { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected.withGeometric); + }); + }).then(function() { + // 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); + return waitForFrame(); + }).then(function() { + // Finally, all animations are running on compositor. + animations.forEach(function(anim) { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected.withoutGeometric); + }); + }); + }, 'Multiple animations with geometric property: ' + subtest.desc); + }); + + gMultipleAsyncAnimationsWithGeometricAnimationTests.forEach(function(subtest) { + promise_test(function(t) { + var div = addDiv(t, { class: 'compositable' }); + var animations = subtest.animations.map(function(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; + + return waitForAllAnimations(animations).then(function() { + animations.forEach(function(anim) { + assert_property_state_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + }).then(function() { + // Append 'width' animation on the same element. + widthAnimation = div.animate({ width: ['100px', '200px'] }, + 100 * MS_PER_SEC); + return waitForFrame(); + }).then(function() { + // Now transform animations are not running on compositor because of + // the 'width' animation. + animations.forEach(function(anim) { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected); + }); + // Remove the 'width' animation. + widthAnimation.cancel(); + return waitForFrame(); + }).then(function() { + // Now all animations are running on compositor. + animations.forEach(function(anim) { + assert_property_state_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + }); + }, 'Multiple async animations and geometric animation: ' + subtest.desc); + }); + + gAnimationsOnTooSmallElementTests.forEach(function(subtest) { + promise_test(function(t) { + var div = addDiv(t, subtest.style); + var animation = div.animate(subtest.frames, 100 * MS_PER_SEC); + return animation.ready.then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + }); + }, subtest.desc); + }); + + promise_test(function(t) { + var animation = addDivAndAnimate(t, + { class: 'compositable' }, + { transform: [ 'translate(0px)', + 'translate(100px)'] }, + 100 * MS_PER_SEC); + return animation.ready.then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: 'transform', runningOnCompositor: true } ]); + animation.effect.target.style = 'width: 10000px; height: 10000px'; + return waitForFrame(); + }).then(function() { + // viewport depends on test environment. + var expectedWarning = new RegExp( + "Animation cannot be run on the compositor because the frame size " + + "\\(10000, 10000\\) is bigger than the viewport \\(\\d+, \\d+\\) " + + "or the visual rectangle \\(10000, 10000\\) is larger than the " + + "maximum allowed value \\(\\d+\\)"); + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { + property: 'transform', + runningOnCompositor: false, + warning: expectedWarning + } ]); + animation.effect.target.style = 'width: 100px; height: 100px'; + return waitForFrame(); + }).then(function() { + // FIXME: Bug 1253164: the animation should get back on compositor. + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: 'transform', runningOnCompositor: false } ]); + }); + }, 'transform on too big element'); + + promise_test(function(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(function() { + svg.remove(); + }); + + var animation = svg.animate( + { transform: ['translate(0px)', 'translate(100px)'] }, 100 * MS_PER_SEC); + return animation.ready.then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: 'transform', runningOnCompositor: true } ]); + svg.setAttribute('transform', 'translate(10, 20)'); + return waitForFrame(); + }).then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformSVG' + } ]); + svg.removeAttribute('transform'); + return waitForFrame(); + }).then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: 'transform', runningOnCompositor: true } ]); + }); + }, 'transform of nsIFrame with SVG transform'); + + promise_test(function(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); + return scriptAnimation.ready.then(function() { + 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'); +} + +</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 000000000..534901306 --- /dev/null +++ b/dom/animation/test/chrome/test_animation_properties.html @@ -0,0 +1,993 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1254419 - Test the values returned by + KeyframeEffectReadOnly.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> + +:root { + --var-100px: 100px; + --var-100px-200px: 100px 200px; +} +div { + font-size: 10px; /* For calculating em-based units */ +} +</style> +<script> +'use strict'; + +function assert_properties_equal(actual, expected) { + assert_equals(actual.length, expected.length); + + var compareProperties = (a, b) => + a.property == b.property ? 0 : (a.property < b.property ? -1 : 1); + + var sortedActual = actual.sort(compareProperties); + var sortedExpected = expected.sort(compareProperties); + + // We want to serialize the values in the following form: + // + // { offset: 0, easing: linear, composite: replace, value: 5px }, ... + // + // So that we can just compare strings and, in the failure case, + // easily see where the differences lie. + var serializeMember = value => { + return typeof value === 'undefined' ? '<not set>' : value; + } + var serializeValues = values => + values.map(value => + '{ ' + + [ 'offset', 'value', 'easing', 'composite' ].map( + member => `${member}: ${serializeMember(value[member])}` + ).join(', ') + + ' }') + .join(', '); + + for (var i = 0; i < sortedActual.length; i++) { + assert_equals(sortedActual[i].property, + sortedExpected[i].property, + 'CSS property name should match'); + assert_equals(serializeValues(sortedActual[i].values), + serializeValues(sortedExpected[i].values), + `Values arrays do not match for ` + + `${sortedActual[i].property} property`); + } +} + +// Shorthand for constructing a value object +function value(offset, value, composite, easing) { + return { offset: offset, value: value, easing: easing, composite: composite }; +} + +var gTests = [ + + // --------------------------------------------------------------------- + // + // Tests for property-indexed specifications + // + // --------------------------------------------------------------------- + + { desc: 'a one-property two-value property-indexed specification', + frames: { left: ['10px', '20px'] }, + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(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: [ value(0, '10px', 'replace', 'linear'), + value(1, '10px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '10px', 'replace', 'linear'), + value(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: [ value(0, '50px', 'replace', 'linear'), + value(1, '60px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '10px', 'replace', 'linear'), + value(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: [ value(0, '10px', 'replace', 'linear'), + value(0.5, '20px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'top', + values: [ value(0, '40px', 'replace', 'linear'), + value(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: [ value(0, '10px', 'replace', 'linear'), + value(0.25, '20px', 'replace', 'linear'), + value(0.5, '30px', 'replace', 'linear'), + value(0.75, '40px', 'replace', 'linear'), + value(1, '50px', 'replace') ] }, + { property: 'top', + values: [ value(0, '15px', 'replace', 'linear'), + value(0.25, '25px', 'replace', 'linear'), + value(0.75, '45px', 'replace', 'linear'), + value(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: [ value(0, '0', 'replace', 'linear'), + value(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: [ value(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + value(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-left-color', + values: [ value(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + value(1, 'rgb(4, 5, 6)', 'replace') ] }, + { property: 'border-right-color', + values: [ value(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + value(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-top-color', + values: [ value(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + value(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ value(0, '3px', 'replace', 'linear'), + value(1, '4px', 'replace') ] }, + { property: 'border-left-width', + values: [ value(0, '1px', 'replace', 'linear'), + value(1, '2px', 'replace') ] }, + { property: 'border-right-width', + values: [ value(0, '3px', 'replace', 'linear'), + value(1, '4px', 'replace') ] }, + { property: 'border-top-width', + values: [ value(0, '3px', 'replace', 'linear'), + value(1, '4px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ value(0, 'solid', 'replace', 'linear'), + value(1, 'solid', 'replace') ] }, + { property: 'border-right-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ value(0, '0 0 0 0', 'replace', 'linear'), + value(1, '0 0 0 0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ value(0, 'stretch stretch', 'replace', 'linear'), + value(1, 'stretch stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ value(0, '100% 100% 100% 100%', + 'replace', 'linear'), + value(1, '100% 100% 100% 100%', 'replace') ] }, + { property: 'border-image-source', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ value(0, '1 1 1 1', 'replace', 'linear'), + value(1, '1 1 1 1', 'replace') ] }, + { property: '-moz-border-bottom-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-left-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-right-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-top-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', '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: [ value(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + value(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-left-color', + values: [ value(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + value(1, 'rgb(4, 5, 6)', 'replace') ] }, + { property: 'border-right-color', + values: [ value(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + value(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-top-color', + values: [ value(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + value(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ value(0, '3px', 'replace', 'linear'), + value(1, '4px', 'replace') ] }, + { property: 'border-left-width', + values: [ value(0, '1px', 'replace', 'linear'), + value(1, '2px', 'replace') ] }, + { property: 'border-right-width', + values: [ value(0, '3px', 'replace', 'linear'), + value(1, '4px', 'replace') ] }, + { property: 'border-top-width', + values: [ value(0, '3px', 'replace', 'linear'), + value(1, '4px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ value(0, 'solid', 'replace', 'linear'), + value(1, 'solid', 'replace') ] }, + { property: 'border-right-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ value(0, '0 0 0 0', 'replace', 'linear'), + value(1, '0 0 0 0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ value(0, 'stretch stretch', 'replace', 'linear'), + value(1, 'stretch stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ value(0, '100% 100% 100% 100%', + 'replace', 'linear'), + value(1, '100% 100% 100% 100%', 'replace') ] }, + { property: 'border-image-source', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ value(0, '1 1 1 1', 'replace', 'linear'), + value(1, '1 1 1 1', 'replace') ] }, + { property: '-moz-border-bottom-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-left-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-right-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-top-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', '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: [ value(0, '100px', 'replace'), + value(0, '200px', 'replace', 'ease'), + value(0.5, '300px', 'replace', 'linear'), + value(1, '400px', 'replace'), + value(1, '500px', 'replace') ] } ] + }, + { desc: 'a one-property two-keyframe sequence', + frames: [ { offset: 0, left: '10px' }, + { offset: 1, left: '20px' } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(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: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] }, + { property: 'top', + values: [ value(0, '30px', 'replace', 'linear'), + value(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: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '40px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '10px', 'replace', 'linear'), + value(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: [ value(0, '20px', 'replace', 'linear'), + value(1, '70px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '40px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '50px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '10px', 'replace', 'linear'), + value(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: [ value(0, '10px', 'replace', 'linear'), + value(0.5, '20px', 'replace'), + value(0.5, '40px', 'replace', 'linear'), + value(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: [ value(0, '10px', 'replace'), + value(0, '30px', 'replace', 'linear'), + value(1, '40px', 'replace'), + value(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: [ value(0, '10px', 'replace', 'linear'), + value(1, '40px', 'replace') ] }, + { property: 'top', + values: [ value(0, '20px', 'replace', 'linear'), + value(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: [ value(0, '10px', 'replace', 'linear'), + value(0.25, '20px', 'replace', 'linear'), + value(0.5, '30px', 'replace', 'linear'), + value(0.75, '40px', 'replace', 'linear'), + value(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: [ value(0, '10px', 'replace', 'linear'), + value(0.25, '30px', 'replace', 'linear'), + value(0.5, '40px', 'replace', 'linear'), + value(0.75, '50px', 'replace', 'linear'), + value(1, '70px', 'replace') ] }, + { property: 'top', + values: [ value(0, '20px', 'replace', 'linear'), + value(0.75, '60px', 'replace', 'linear'), + value(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: [ value(0, '10px', 'replace', 'linear'), + value(0.25, '20px', 'replace', 'linear'), + value(0.5, '30px', 'replace', 'linear'), + value(0.75, '40px', 'replace', 'linear'), + value(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: [ value(0, '10px', 'replace', 'ease'), + value(0.5, '30px', 'replace', 'linear'), + value(1, '50px', 'replace') ] }, + { property: 'top', + values: [ value(0, '20px', 'replace', 'ease'), + value(0.5, '40px', 'replace', 'linear'), + value(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: [ value(0, '0', 'replace', 'linear'), + value(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: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '20px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '10px', 'replace', 'linear'), + value(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: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '20px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '10px', 'replace', 'linear'), + value(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: [ value(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-left-color', + values: [ value(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-right-color', + values: [ value(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-top-color', + values: [ value(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ value(0, '2px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-left-width', + values: [ value(0, '1px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-right-width', + values: [ value(0, '2px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-top-width', + values: [ value(0, '2px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ value(0, 'solid', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-right-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ value(0, '0 0 0 0', 'replace', 'linear'), + value(1, '0 0 0 0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ value(0, 'stretch stretch', 'replace', 'linear'), + value(1, 'stretch stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ value(0, '100% 100% 100% 100%', + 'replace', 'linear'), + value(1, '100% 100% 100% 100%', 'replace') ] }, + { property: 'border-image-source', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ value(0, '1 1 1 1', 'replace', 'linear'), + value(1, '1 1 1 1', 'replace') ] }, + { property: '-moz-border-bottom-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-left-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-right-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-top-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', '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: [ value(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-left-color', + values: [ value(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-right-color', + values: [ value(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-top-color', + values: [ value(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ value(0, '2px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-left-width', + values: [ value(0, '1px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-right-width', + values: [ value(0, '2px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-top-width', + values: [ value(0, '2px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ value(0, 'solid', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-right-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ value(0, '0 0 0 0', 'replace', 'linear'), + value(1, '0 0 0 0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ value(0, 'stretch stretch', 'replace', 'linear'), + value(1, 'stretch stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ value(0, '100% 100% 100% 100%', + 'replace', 'linear'), + value(1, '100% 100% 100% 100%', 'replace') ] }, + { property: 'border-image-source', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ value(0, '1 1 1 1', 'replace', 'linear'), + value(1, '1 1 1 1', 'replace') ] }, + { property: '-moz-border-bottom-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-left-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-right-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-top-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] } ] + }, + + // --------------------------------------------------------------------- + // + // Tests for unit conversion + // + // --------------------------------------------------------------------- + + { desc: 'em units are resolved to px values', + frames: { left: ['10em', '20em'] }, + expected: [ { property: 'left', + values: [ value(0, '100px', 'replace', 'linear'), + value(1, '200px', 'replace') ] } ] + }, + { desc: 'calc() expressions are resolved to the equivalent units', + frames: { left: ['calc(10em + 10px)', 'calc(10em + 10%)'] }, + expected: [ { property: 'left', + values: [ value(0, 'calc(110px)', 'replace', 'linear'), + value(1, 'calc(100px + 10%)', '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: [ value(0, '10px', 'replace', 'linear'), + value(1, '100px', 'replace') ] } ] + }, + { desc: 'CSS variables in calc() expressions are resolved', + frames: { left: ['10px', 'calc(var(--var-100px) / 2 - 10%)'] }, + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, 'calc(50px + -10%)', 'replace') ] } ] + }, + { desc: 'CSS variables in shorthands are resolved to their corresponding' + + ' values', + frames: { margin: ['10px', 'var(--var-100px-200px)'] }, + expected: [ { property: 'margin-top', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '100px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '200px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '100px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '200px', 'replace') ] } ] + }, + + // --------------------------------------------------------------------- + // + // Tests for properties that parse correctly but which we fail to + // convert to computed values. + // + // --------------------------------------------------------------------- + + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial keyframe', + frames: [ { margin: '5px', simulateComputeValuesFailure: true }, + { margin: '5px' } ], + expected: [ ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial keyframe where we have enough values to create' + + ' a final segment', + frames: [ { margin: '5px', simulateComputeValuesFailure: true }, + { margin: '5px' }, + { margin: '5px' } ], + expected: [ ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial overlapping keyframes (first in series of two)', + frames: [ { margin: '5px', offset: 0, + simulateComputeValuesFailure: true }, + { margin: '5px', offset: 0 }, + { margin: '5px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial overlapping keyframes (second in series of two)', + frames: [ { margin: '5px', offset: 0 }, + { margin: '5px', offset: 0, + simulateComputeValuesFailure: true }, + { margin: '5px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial overlapping keyframes (second in series of three)', + frames: [ { margin: '5px', offset: 0 }, + { margin: '5px', offset: 0, + simulateComputeValuesFailure: true }, + { margin: '5px', offset: 0 }, + { margin: '5px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace'), + value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace'), + value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace'), + value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace'), + value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe', + frames: [ { margin: '5px' }, + { margin: '5px', simulateComputeValuesFailure: true } ], + expected: [ ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe where it forms the last segment in the series', + frames: [ { margin: '5px' }, + { margin: '5px', + marginLeft: '5px', + marginRight: '5px', + marginBottom: '5px', + // margin-top sorts last and only it will be missing since + // the other longhand components are specified + simulateComputeValuesFailure: true } ], + expected: [ { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe where we have enough values to create' + + ' an initial segment', + frames: [ { margin: '5px' }, + { margin: '5px' }, + { margin: '5px', simulateComputeValuesFailure: true } ], + expected: [ ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final overlapping keyframes (first in series of two)', + frames: [ { margin: '5px' }, + { margin: '5px', offset: 1, + simulateComputeValuesFailure: true }, + { margin: '5px', offset: 1 } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final overlapping keyframes (second in series of two)', + frames: [ { margin: '5px' }, + { margin: '5px', offset: 1 }, + { margin: '5px', offset: 1, + simulateComputeValuesFailure: true } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final overlapping keyframes (second in series of three)', + frames: [ { margin: '5px' }, + { margin: '5px', offset: 1 }, + { margin: '5px', offset: 1, + simulateComputeValuesFailure: true }, + { margin: '5px', offset: 1 } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' intermediate keyframe', + frames: [ { margin: '5px' }, + { margin: '5px', simulateComputeValuesFailure: true }, + { margin: '5px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial keyframe along with other values', + // simulateComputeValuesFailure only applies to shorthands so we can set + // it on the same keyframe and it will only apply to |margin| and not + // |left|. + frames: [ { margin: '77%', left: '10px', + simulateComputeValuesFailure: true }, + { margin: '5px', left: '20px' } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] } ], + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial keyframe along with other values where those' + + ' values sort after the property with missing values', + frames: [ { margin: '77%', right: '10px', + simulateComputeValuesFailure: true }, + { margin: '5px', right: '20px' } ], + expected: [ { property: 'right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] } ], + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe along with other values', + frames: [ { margin: '5px', left: '10px' }, + { margin: '5px', left: '20px', + simulateComputeValuesFailure: true } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] } ], + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe along with other values where those' + + ' values sort after the property with missing values', + frames: [ { margin: '5px', right: '10px' }, + { margin: '5px', right: '20px', + simulateComputeValuesFailure: true } ], + expected: [ { property: 'right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] } ], + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' an intermediate keyframe along with other values', + frames: [ { margin: '5px', left: '10px' }, + { margin: '5px', left: '20px', + simulateComputeValuesFailure: true }, + { margin: '5px', left: '30px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(0.5, '20px', 'replace', 'linear'), + value(1, '30px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' an intermediate keyframe by itself', + frames: [ { margin: '5px', left: '10px' }, + { margin: '5px', + simulateComputeValuesFailure: true }, + { margin: '5px', left: '30px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] } ] + }, +]; + +gTests.forEach(function(subtest) { + test(function(t) { + var div = addDiv(t); + var animation = div.animate(subtest.frames, 100 * MS_PER_SEC); + 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 000000000..04e0f9bd0 --- /dev/null +++ b/dom/animation/test/chrome/test_generated_content_getAnimations.html @@ -0,0 +1,83 @@ +<!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'; + +const {Cc, Ci, Cu} = SpecialPowers; + +function getWalker(node) { + var walker = Cc["@mozilla.org/inspector/deep-tree-walker;1"]. + createInstance(Ci.inIDeepTreeWalker); + walker.showAnonymousContent = true; + walker.init(node.ownerDocument, Ci.nsIDOMNodeFilter.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_observers_for_sync_api.html b/dom/animation/test/chrome/test_observers_for_sync_api.html new file mode 100644 index 000000000..20c3f3670 --- /dev/null +++ b/dom/animation/test/chrome/test_observers_for_sync_api.html @@ -0,0 +1,854 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title> +Test chrome-only MutationObserver animation notifications for sync APIs +</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> +<div id="log"></div> +<style> +@keyframes anim { + to { transform: translate(100px); } +} +@keyframes anotherAnim { + to { transform: translate(0px); } +} +</style> +<script> + +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"); + } +} + +// Create a pseudo element +function createPseudo(test, element, type) { + addStyle(test, { '@keyframes anim': '', + ['.pseudo::' + type]: 'animation: anim 10s;' }); + element.classList.add('pseudo'); + var anims = document.getAnimations(); + assert_true(anims.length >= 1); + var anim = anims[anims.length - 1]; + assert_equals(anim.effect.target.parentElement, element); + assert_equals(anim.effect.target.type, '::' + type); + anim.cancel(); + return anim.effect.target; +} + +[ { 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.timing.duration = 100 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after duration is changed"); + + anim.effect.timing.duration = 100 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.currentTime = anim.effect.timing.duration * 2; + anim.finish(); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.timing.duration = anim.effect.timing.duration * 3; + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation restarted"); + + anim.effect.timing.duration = "auto"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after duration set \"auto\""); + + anim.effect.timing.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.timing.endDelay = 10 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after endDelay is changed"); + + anim.effect.timing.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.timing.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.timing.iterations = 2; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after iterations is changed"); + + anim.effect.timing.iterations = 2; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.effect.timing.iterations = 0; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.timing.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.timing.delay = 100; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after delay is changed"); + + anim.effect.timing.delay = 100; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.effect.timing.delay = -100 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.timing.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.timing.easing = "steps(2, end)"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after easing is changed"); + + anim.effect.timing.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 KeyframeEffectReadOnly(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([ { marginLeft: "0px" }, + { marginLeft: "-20px" }, + { marginLeft: "100px" }, + { marginLeft: "50px" } ], + { duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.spacing = "paced(margin-left)"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after animation is changed"); + }, "set_spacing"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate([ { marginLeft: "0px" }, + { marginLeft: "-20px" }, + { marginLeft: "100px" }, + { marginLeft: "50px" } ], + { duration: 100 * MS_PER_SEC, + spacing: "paced(margin-left)" }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.spacing = "paced(animation-duration)"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after setting a non-animatable paced property"); + }, "set_spacing_on_a_non-animatable_property"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate([ { marginLeft: "0px" }, + { marginLeft: "-20px" }, + { marginLeft: "100px" }, + { marginLeft: "50px" } ], + { duration: 100 * MS_PER_SEC, + spacing: "paced(margin-left)" }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.spacing = "paced(margin-left)"; + assert_equals_records(observer.takeRecords(), + [], "no record after setting the same spacing"); + }, "set_the_same_spacing"); + + // 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(function(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. + 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. + 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 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, 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 pseudoTarget = createPseudo(t, div, 'before'); + var observer = setupSynchronousObserver(t, div, true); + + var anim = pseudoTarget.animate({ opacity: [ 0, 1 ] }, 200 * MS_PER_SEC); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.timing.duration = 100 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after duration is changed"); + + anim.effect.timing.duration = 100 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.currentTime = anim.effect.timing.duration * 2; + anim.finish(); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.timing.duration = anim.effect.timing.duration * 3; + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation restarted"); + + anim.effect.timing.duration = "auto"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after duration set \"auto\""); + + anim.effect.timing.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 pseudoTarget = createPseudo(t, div, 'before'); + var observer = setupSynchronousObserver(t, div, false); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + var pAnim = pseudoTarget.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + + 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"); + +</script> diff --git a/dom/animation/test/chrome/test_restyles.html b/dom/animation/test/chrome/test_restyles.html new file mode 100644 index 000000000..e59967c19 --- /dev/null +++ b/dom/animation/test/chrome/test_restyles.html @@ -0,0 +1,815 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Tests restyles caused by animations</title> +<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> +<script src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script> +<script src="chrome://mochikit/content/tests/SimpleTest/paint_listener.js"></script> +<script src="../testcommon.js"></script> +<link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +<style> +@keyframes opacity { + from { opacity: 1; } + to { opacity: 0; } +} +@keyframes background-color { + from { background-color: red; } + to { background-color: blue; } +} +@keyframes rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +div { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +</style> +</head> +<body> +<script> +'use strict'; + +function observeStyling(frameCount, onFrame) { + var docShell = window.QueryInterface(SpecialPowers.Ci.nsIInterfaceRequestor) + .getInterface(SpecialPowers.Ci.nsIWebNavigation) + .QueryInterface(SpecialPowers.Ci.nsIDocShell); + + docShell.recordProfileTimelineMarkers = true; + docShell.popProfileTimelineMarkers(); + + return new Promise(function(resolve) { + return waitForAnimationFrames(frameCount, onFrame).then(function() { + var markers = docShell.popProfileTimelineMarkers(); + docShell.recordProfileTimelineMarkers = false; + var stylingMarkers = markers.filter(function(marker, index) { + return marker.name == 'Styles' && + (marker.restyleHint == 'eRestyle_CSSAnimations' || + marker.restyleHint == 'eRestyle_CSSTransitions'); + }); + resolve(stylingMarkers); + }); + }); +} + +function ensureElementRemoval(aElement) { + return new Promise(function(resolve) { + aElement.remove(); + waitForAllPaintsFlushed(resolve); + }); +} + +SimpleTest.waitForExplicitFinish(); + +var omtaEnabled = isOMTAEnabled(); + +var isAndroid = !!navigator.userAgent.includes("Android"); + +function add_task_if_omta_enabled(test) { + if (!omtaEnabled) { + info(test.name + " is skipped because OMTA is disabled"); + return; + } + add_task(test); +} + +// We need to wait for all paints before running tests to avoid contaminations +// from styling of this document itself. +waitForAllPaints(function() { + add_task(function* restyling_for_main_thread_animations() { + var div = addDiv(null, { style: 'animation: background-color 100s' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(!animation.isRunningOnCompositor); + + var markers = yield observeStyling(5); + is(markers.length, 5, + 'CSS animations running on the main-thread should update style ' + + 'on the main thread'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* no_restyling_for_compositor_animations() { + var div = addDiv(null, { style: 'animation: opacity 100s' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(animation.isRunningOnCompositor); + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'CSS animations running on the compositor should not update style ' + + 'on the main thread'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* no_restyling_for_compositor_transitions() { + var div = addDiv(null, { style: 'transition: opacity 100s; opacity: 0' }); + getComputedStyle(div).opacity; + div.style.opacity = 1; + + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(animation.isRunningOnCompositor); + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'CSS transitions running on the compositor should not update style ' + + 'on the main thread'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* no_restyling_when_animation_duration_is_changed() { + var div = addDiv(null, { style: 'animation: opacity 100s' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(animation.isRunningOnCompositor); + + div.animationDuration = '200s'; + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Animations running on the compositor should not update style ' + + 'on the main thread'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* only_one_restyling_after_finish_is_called() { + var div = addDiv(null, { style: 'animation: opacity 100s' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(animation.isRunningOnCompositor); + + animation.finish(); + + var markers = yield observeStyling(5); + is(markers.length, 1, + 'Animations running on the compositor should only update style ' + + 'once after finish() is called'); + yield ensureElementRemoval(div); + }); + + add_task(function* no_restyling_mouse_movement_on_finished_transition() { + var div = addDiv(null, { style: 'transition: opacity 1ms; opacity: 0' }); + getComputedStyle(div).opacity; + div.style.opacity = 1; + + var animation = div.getAnimations()[0]; + var initialRect = div.getBoundingClientRect(); + + yield animation.finished; + + var mouseX = initialRect.left + initialRect.width / 2; + var mouseY = initialRect.top + initialRect.height / 2; + var markers = yield observeStyling(5, function() { + // We can't use synthesizeMouse here since synthesizeMouse causes + // layout flush. + synthesizeMouseAtPoint(mouseX++, mouseY++, + { type: 'mousemove' }, window); + }); + + is(markers.length, 0, + 'Bug 1219236: Finished transitions should never cause restyles ' + + 'when mouse is moved on the animations'); + yield ensureElementRemoval(div); + }); + + add_task(function* no_restyling_mouse_movement_on_finished_animation() { + var div = addDiv(null, { style: 'animation: opacity 1ms' }); + var animation = div.getAnimations()[0]; + + var initialRect = div.getBoundingClientRect(); + + yield animation.finished; + + var mouseX = initialRect.left + initialRect.width / 2; + var mouseY = initialRect.top + initialRect.height / 2; + var markers = yield observeStyling(5, function() { + // We can't use synthesizeMouse here since synthesizeMouse causes + // layout flush. + synthesizeMouseAtPoint(mouseX++, mouseY++, + { type: 'mousemove' }, window); + }); + + is(markers.length, 0, + 'Bug 1219236: Finished animations should never cause restyles ' + + 'when mouse is moved on the animations'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* no_restyling_compositor_animations_out_of_view_element() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + var div = addDiv(null, + { style: 'animation: opacity 100s; transform: translateY(-400px);' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(!animation.isRunningOnCompositor); + + var markers = yield observeStyling(5); + + is(markers.length, 0, + 'Animations running on the compositor in an out-of-view element ' + + 'should never cause restyles'); + yield ensureElementRemoval(div); + }); + + add_task(function* no_restyling_main_thread_animations_out_of_view_element() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + var div = addDiv(null, + { style: 'animation: background-color 100s; transform: translateY(-400px);' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + var markers = yield observeStyling(5); + + is(markers.length, 0, + 'Animations running on the main-thread in an out-of-view element ' + + 'should never cause restyles'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* no_restyling_compositor_animations_in_scrolled_out_element() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + /* + On Android the opacity animation runs on the compositor even if it is + scrolled out of view. We will fix this in bug 1247800. + */ + if (isAndroid) { + return; + } + var parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + var div = addDiv(null, + { style: 'animation: opacity 100s; position: relative; top: 100px;' }); + parentElement.appendChild(div); + var animation = div.getAnimations()[0]; + + yield animation.ready; + + var markers = yield observeStyling(5); + + is(markers.length, 0, + 'Animations running on the compositor for elements ' + + 'which are scrolled out should never cause restyles'); + + yield ensureElementRemoval(parentElement); + }); + + add_task(function* no_restyling_main_thread_animations_in_scrolled_out_element() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + /* + On Android throttled animations are left behind on the main thread in some + frames, We will fix this in bug 1247800. + */ + if (isAndroid) { + return; + } + + var parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + var div = addDiv(null, + { style: 'animation: background-color 100s; position: relative; top: 100px;' }); + parentElement.appendChild(div); + var animation = div.getAnimations()[0]; + + yield animation.ready; + var markers = yield observeStyling(5); + + is(markers.length, 0, + 'Animations running on the main-thread for elements ' + + 'which are scrolled out should never cause restyles'); + + yield ensureElementRemoval(parentElement); + }); + + add_task(function* no_restyling_main_thread_animations_in_nested_scrolled_out_element() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + /* + On Android throttled animations are left behind on the main thread in some + frames, We will fix this in bug 1247800. + */ + if (isAndroid) { + return; + } + + var grandParent = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + var parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 100px;' }); + var div = addDiv(null, + { style: 'animation: background-color 100s; position: relative; top: 100px;' }); + grandParent.appendChild(parentElement); + parentElement.appendChild(div); + var animation = div.getAnimations()[0]; + + yield animation.ready; + var markers = yield observeStyling(5); + + is(markers.length, 0, + 'Animations running on the main-thread which are in nested elements ' + + 'which are scrolled out should never cause restyles'); + + yield ensureElementRemoval(grandParent); + }); + + add_task_if_omta_enabled(function* no_restyling_compositor_animations_in_visiblily_hidden_element() { + var div = addDiv(null, + { style: 'animation: opacity 100s; visibility: hidden' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(!animation.isRunningOnCompositor); + + var markers = yield observeStyling(5); + + todo_is(markers.length, 0, + 'Bug 1237454: Animations running on the compositor in ' + + 'visibility hidden element should never cause restyles'); + yield ensureElementRemoval(div); + }); + + add_task(function* restyling_main_thread_animations_moved_in_view_by_scrolling() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + /* + On Android throttled animations are left behind on the main thread in some + frames, We will fix this in bug 1247800. + */ + if (isAndroid) { + return; + } + + var parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + var div = addDiv(null, + { style: 'animation: background-color 100s; position: relative; top: 100px;' }); + parentElement.appendChild(div); + var animation = div.getAnimations()[0]; + + var parentRect = parentElement.getBoundingClientRect(); + var centerX = parentRect.left + parentRect.width / 2; + var centerY = parentRect.top + parentRect.height / 2; + + yield animation.ready; + + var markers = yield observeStyling(1, function() { + // We can't use synthesizeWheel here since synthesizeWheel causes + // layout flush. + synthesizeWheelAtPoint(centerX, centerY, + { deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: 100 }); + }); + + is(markers.length, 1, + 'Animations running on the main-thread which were in scrolled out ' + + 'elements should update restyling soon after the element moved in ' + + 'view by scrolling'); + + yield ensureElementRemoval(parentElement); + }); + + add_task(function* restyling_main_thread_animations_moved_in_view_by_scrolling() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + var grandParent = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + var parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 200px;' }); + var div = addDiv(null, + { style: 'animation: background-color 100s; position: relative; top: 100px;' }); + grandParent.appendChild(parentElement); + parentElement.appendChild(div); + var animation = div.getAnimations()[0]; + + var parentRect = grandParent.getBoundingClientRect(); + var centerX = parentRect.left + parentRect.width / 2; + var centerY = parentRect.top + parentRect.height / 2; + + yield animation.ready; + + var markers = yield observeStyling(1, function() { + // We can't use synthesizeWheel here since synthesizeWheel causes + // layout flush. + synthesizeWheelAtPoint(centerX, centerY, + { deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: 100 }); + }); + + // FIXME: We should reduce a redundant restyle here. + ok(markers.length >= 1, + 'Animations running on the main-thread which were in nested scrolled ' + + 'out elements should update restyle soon after the element moved ' + + 'in view by scrolling'); + + yield ensureElementRemoval(grandParent); + }); + + add_task(function* restyling_main_thread_animations_move_out_of_view_by_scrolling() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + /* + On Android throttled animations are left behind on the main thread in some + frames, We will fix this in bug 1247800. + */ + if (isAndroid) { + return; + } + + var parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 200px;' }); + var div = addDiv(null, + { style: 'animation: background-color 100s;' }); + var pad = addDiv(null, + { style: 'height: 400px;' }); + parentElement.appendChild(div); + parentElement.appendChild(pad); + var animation = div.getAnimations()[0]; + + var parentRect = parentElement.getBoundingClientRect(); + var centerX = parentRect.left + parentRect.width / 2; + var centerY = parentRect.top + parentRect.height / 2; + + yield animation.ready; + + // We can't use synthesizeWheel here since synthesizeWheel causes + // layout flush. + synthesizeWheelAtPoint(centerX, centerY, + { deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: 200 }); + + var markers = yield observeStyling(5); + + // FIXME: We should reduce a redundant restyle here. + ok(markers.length >= 0, + 'Animations running on the main-thread which are in scrolled out ' + + 'elements should throttle restyling'); + + yield ensureElementRemoval(parentElement); + }); + + add_task(function* restyling_main_thread_animations_moved_in_view_by_resizing() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + var parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + var div = addDiv(null, + { style: 'animation: background-color 100s; position: relative; top: 100px;' }); + parentElement.appendChild(div); + var animation = div.getAnimations()[0]; + + yield animation.ready; + + var markers = yield observeStyling(1, function() { + parentElement.style.height = '100px'; + }); + + is(markers.length, 1, + 'Animations running on the main-thread which was in scrolled out ' + + 'elements should update restyling soon after the element moved in ' + + 'view by resizing'); + + yield ensureElementRemoval(parentElement); + }); + + add_task(function* no_restyling_main_thread_animations_in_visiblily_hidden_element() { + var div = addDiv(null, + { style: 'animation: background-color 100s; visibility: hidden' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + var markers = yield observeStyling(5); + + todo_is(markers.length, 0, + 'Bug 1237454: Animations running on the main-thread in ' + + 'visibility hidden element should never cause restyles'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* no_restyling_compositor_animations_after_pause_is_called() { + var div = addDiv(null, { style: 'animation: opacity 100s' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(animation.isRunningOnCompositor); + + animation.pause(); + + yield animation.ready; + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Bug 1232563: Paused animations running on the compositor should ' + + 'never cause restyles once after pause() is called'); + yield ensureElementRemoval(div); + }); + + add_task(function* no_restyling_main_thread_animations_after_pause_is_called() { + var div = addDiv(null, { style: 'animation: background-color 100s' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + + animation.pause(); + + yield animation.ready; + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Bug 1232563: Paused animations running on the main-thread should ' + + 'never cause restyles after pause() is called'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* only_one_restyling_when_current_time_is_set_to_middle_of_duration() { + var div = addDiv(null, { style: 'animation: opacity 100s' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + + animation.currentTime = 50 * MS_PER_SEC; + + var markers = yield observeStyling(5); + is(markers.length, 1, + 'Bug 1235478: Animations running on the compositor should only once ' + + 'update style when currentTime is set to middle of duration time'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* change_duration_and_currenttime() { + var div = addDiv(null); + var animation = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + yield animation.ready; + ok(animation.isRunningOnCompositor); + + // Set currentTime to a time longer than duration. + animation.currentTime = 500 * MS_PER_SEC; + + // Now the animation immediately get back from compositor. + ok(!animation.isRunningOnCompositor); + + // Extend the duration. + animation.effect.timing.duration = 800 * MS_PER_SEC; + var markers = yield observeStyling(5); + is(markers.length, 1, + 'Animations running on the compositor should update style ' + + 'when timing.duration is made longer than the current time'); + + yield ensureElementRemoval(div); + }); + + add_task(function* script_animation_on_display_none_element() { + var div = addDiv(null); + var animation = div.animate({ backgroundColor: [ 'red', 'blue' ] }, + 100 * MS_PER_SEC); + + yield animation.ready; + + div.style.display = 'none'; + + // We need to wait a frame to apply display:none style. + yield waitForFrame(); + + is(animation.playState, 'running', + 'Script animations keep running even when the target element has ' + + '"display: none" style'); + + ok(!animation.isRunningOnCompositor, + 'Script animations on "display:none" element should not run on the ' + + 'compositor'); + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Script animations on "display: none" element should not update styles'); + + div.style.display = ''; + + // We need to wait a frame to unapply display:none style. + yield waitForFrame(); + + var markers = yield observeStyling(5); + is(markers.length, 5, + 'Script animations restored from "display: none" state should update ' + + 'styles'); + + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* compositable_script_animation_on_display_none_element() { + var div = addDiv(null); + var animation = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + yield animation.ready; + + div.style.display = 'none'; + + // We need to wait a frame to apply display:none style. + yield waitForFrame(); + + is(animation.playState, 'running', + 'Opacity script animations keep running even when the target element ' + + 'has "display: none" style'); + + ok(!animation.isRunningOnCompositor, + 'Opacity script animations on "display:none" element should not ' + + 'run on the compositor'); + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Opacity script animations on "display: none" element should not ' + + 'update styles'); + + div.style.display = ''; + + // We need to wait a frame to unapply display:none style. + yield waitForFrame(); + + ok(animation.isRunningOnCompositor, + 'Opacity script animations restored from "display: none" should be ' + + 'run on the compositor'); + + yield ensureElementRemoval(div); + }); + + add_task(function* restyling_for_empty_keyframes() { + var div = addDiv(null); + var animation = div.animate({ }, 100 * MS_PER_SEC); + + yield animation.ready; + var markers = yield observeStyling(5); + + is(markers.length, 0, + 'Animations with no keyframes should not cause restyles'); + + animation.effect.setKeyframes({ backgroundColor: ['red', 'blue'] }); + markers = yield observeStyling(5); + + is(markers.length, 5, + 'Setting valid keyframes should cause regular animation restyles to ' + + 'occur'); + + animation.effect.setKeyframes({ }); + markers = yield observeStyling(5); + + is(markers.length, 1, + 'Setting an empty set of keyframes should trigger a single restyle ' + + 'to remove the previous animated style'); + + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* no_restyling_when_animation_style_when_re_setting_same_animation_property() { + var div = addDiv(null, { style: 'animation: opacity 100s' }); + var animation = div.getAnimations()[0]; + yield animation.ready; + ok(animation.isRunningOnCompositor); + // Apply the same animation style + div.style.animation = 'opacity 100s'; + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Applying same animation style ' + + 'should never cause restyles'); + yield ensureElementRemoval(div); + }); + + add_task(function* necessary_update_should_be_invoked() { + var div = addDiv(null, { style: 'animation: background-color 100s' }); + var animation = div.getAnimations()[0]; + yield animation.ready; + yield waitForAnimationFrames(5); + // Apply another animation style + div.style.animation = 'background-color 110s'; + var animation = div.getAnimations()[0]; + var markers = yield observeStyling(5); + is(markers.length, 5, + 'Applying animation style with different duration ' + + 'should cause restyles on every frame.'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled( + function* changing_cascading_result_for_main_thread_animation() { + var div = addDiv(null, { style: 'background-color: blue' }); + var animation = div.animate({ opacity: [0, 1], + backgroundColor: ['green', 'red'] }, + 100 * MS_PER_SEC); + yield animation.ready; + ok(animation.isRunningOnCompositor, + 'The opacity animation is running on the compositor'); + // Make the background-color style as !important to cause an update + // to the cascade. + // Bug 1300982: The background-color animation should be no longer + // running on the main thread. + div.style.setProperty('background-color', '1', 'important'); + var markers = yield observeStyling(5); + todo_is(markers.length, 0, + 'Changing cascading result for the property running on the main ' + + 'thread does not cause synchronization layer of opacity animation ' + + 'running on the compositor'); + yield ensureElementRemoval(div); + } + ); + + add_task(function* restyling_for_animation_on_orphaned_element() { + var div = addDiv(null); + var animation = div.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + yield animation.ready; + + div.remove(); + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Animation on orphaned element should not cause restyles'); + + document.body.appendChild(div); + + markers = yield observeStyling(1); + // We are observing restyles in rAF callback which is processed before + // restyling process in each frame, so in the first frame there should be + // no observed restyle since we don't process restyle while the element + // is not attached to the document. + is(markers.length, 0, + 'We observe no restyle in the first frame right after re-atatching ' + + 'to the document'); + markers = yield observeStyling(5); + is(markers.length, 5, + 'Animation on re-attached to the document begins to update style'); + + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled( + // Tests that if we remove an element from the document whose animation + // cascade needs recalculating, that it is correctly updated when it is + // re-attached to the document. + function* restyling_for_opacity_animation_on_re_attached_element() { + var div = addDiv(null, { style: 'opacity: 1 ! important' }); + var animation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + yield animation.ready; + ok(!animation.isRunningOnCompositor, + 'The opacity animation overridden by an !important rule is NOT ' + + 'running on the compositor'); + + // Drop the !important rule to update the cascade. + div.style.setProperty('opacity', '1', ''); + + div.remove(); + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Opacity animation on orphaned element should not cause restyles'); + + document.body.appendChild(div); + + // Need a frame to give the animation a chance to be sent to the + // compositor. + yield waitForFrame(); + + ok(animation.isRunningOnCompositor, + 'The opacity animation which is no longer overridden by the ' + + '!important rule begins running on the compositor even if the ' + + '!important rule had been dropped before the target element was ' + + 'removed'); + + yield ensureElementRemoval(div); + } + ); + +}); + +</script> +</body> 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 000000000..cd6c679b8 --- /dev/null +++ b/dom/animation/test/chrome/test_running_on_compositor.html @@ -0,0 +1,966 @@ +<!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 background_and_translate { + to { background-color: red; transform: translate(100px); } +} +@keyframes background { + to { background-color: red; } +} +@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 **/ + +var 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(function(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]; + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' during playback'); + + div.style.animationPlayState = 'paused'; + + return animation.ready; + }).then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when paused'); + }); +}, ''); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: background 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' for animation of "background"'); + }); +}, 'isRunningOnCompositor is false for animation of "background"'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: background_and_translate 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + 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(function(t) { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + animation.pause(); + return animation.ready; + }).then(function() { + 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(function(t) { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + 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. + return waitForFrame(); + }).then(function() { + 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(function(t) { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + 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. + return waitForFrame(); + }).then(function() { + 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(function(t) { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + 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. + return waitForFrame(); + }).then(function() { + 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(function(t) { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + return new Promise(function(resolve) { + window.requestAnimationFrame(function() { + t.step(function() { + 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(function(t) { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + return new Promise(function(resolve) { + var observer = new MutationObserver(function(records) { + var changedAnimation; + + records.forEach(function(record) { + changedAnimation = + record.changedAnimations.find(function(changedAnim) { + return changedAnim == animation; + }); + }); + + t.step(function() { + 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(function() { + 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(function(t) { + return new Promise(function(resolve) { + // Needs scrollbars to cause overflow. + SpecialPowers.pushPrefEnv({ set: [["ui.showHideScrollbars", 1]] }, + resolve); + }).then(function() { + var div = addDiv(t, { style: 'animation: rotate 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + return new Promise(function(resolve) { + var timeAtStart = window.performance.now(); + function handleFrame() { + t.step(function() { + 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 http://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(function(t) { + var div = addDiv(t, { style: 'transition: opacity 100s; opacity: 1' }); + + getComputedStyle(div).opacity; + + div.style.opacity = 0; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + 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(function(t) { + var div = addDiv(t, { style: 'animation: rotate-and-opacity 100s; ' + + 'backface-visibility: hidden; ' + + 'transform: none !important;' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + 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(function(t) { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 200 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.currentTime = 150 * MS_PER_SEC; + animation.effect.timing.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 timing.duration is made shorter than the current time'); + +promise_test(function(t) { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + return animation.ready.then(function() { + 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.timing.duration = 1000 * MS_PER_SEC; + return waitForFrame(); + }).then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' when restarted'); + }); +}, 'animation is added to compositor' + + ' when timing.duration is made longer than the current time'); + +promise_test(function(t) { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.timing.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; + return waitForFrame(); + }).then(function() { + 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(function(t) { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.timing.endDelay = -200 * MS_PER_SEC; + return waitForFrame(); + }).then(function() { + 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(function(t) { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 200 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.timing.endDelay = -100 * MS_PER_SEC; + return waitForFrame(); + }).then(function() { + 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; + return waitForFrame(); + }).then(function() { + 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(function(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); + + return animation.ready.then(function() { + 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; + return waitForFrame(); + }).then(function() { + 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(function(t) { + var div = addDiv(t); + var animation = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + return animation.ready.then(function() { + 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(function(t) { + var div = addDiv(t); + var animation = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC, + delay: 100 * MS_PER_SEC, + fill: 'backwards' }); + + return animation.ready.then(function() { + 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; + return waitForFrame(); + }).then(function() { + 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 ' + + ' main-thread while it is in delay phase'); + +promise_test(function(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); + + return animation.ready.then(function() { + 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; + return waitForFrame(); + }).then(function() { + 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(function(t) { + var div = addDiv(t); + var animation = div.animate({ color: ['red', 'black'] }, 100 * MS_PER_SEC); + + return animation.ready.then(function() { + 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 }]); + return waitForFrame(); + }).then(function() { + 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(function(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); + + return animation.ready.then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Color animation reports that it is not running on the ' + + 'compositor'); + + animation.effect = effect; + return waitForFrame(); + }).then(function() { + 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(function(t) { + var div = addDiv(t, { style: "opacity: 1 ! important" }); + + var animation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + return animation.ready.then(function() { + 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", ""); + return waitForFrame(); + }).then(function() { + 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(function(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); + + return Promise.all([lowerAnimation.ready, higherAnimation.ready]).then(function() { + 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(function(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); + + return Promise.all([transition.ready, animation.ready]).then(function() { + 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(function(t) { + var div = addDiv(t); + var importantOpacityElement = addDiv(t, { style: "opacity: 1 ! important" }); + + var animation = div.animate({ opacity: [1, 0] }, 100 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Opacity animation on an element reports ' + + 'that it is running on the compositor'); + + animation.effect.target = null; + return waitForFrame(); + }).then(function() { + 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; + return waitForFrame(); + }).then(function() { + 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(function(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); + + return Promise.all([lowerAnimation.ready, higherAnimation.ready]).then(function() { + 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; + return waitForFrame(); + }).then(function() { + 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; + return waitForFrame(); + }).then(function() { + 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(function(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); + + return Promise.all([lowerAnimation.ready, higherAnimation.ready]).then(function() { + 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; + return waitForFrame(); + }).then(function() { + 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; + return waitForFrame(); + }).then(function() { + 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: function(t) { + return addDiv(t).animate( + { opacity: [0, 1] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + }, + }, + { + desc: 'script animation of transform', + setupAnimation: function(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: function(t) { + return addDiv(t, { style: 'animation: opacity 100s 100s' }) + .getAnimations()[0]; + }, + }, + { + desc: 'CSS animation of transform', + setupAnimation: function(t) { + return addDiv(t, { style: 'animation: anim 100s 100s' }) + .getAnimations()[0]; + }, + }, + { + desc: 'CSS transition of opacity', + setupAnimation: function(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: function(t) { + var div = addDiv(t, { style: 'transition: transform 100s 100s' }); + getComputedStyle(div).transform; + + div.style.transform = 'translateX(100px)'; + return div.getAnimations()[0]; + }, + }, +]; + +delayPhaseTests.forEach(function(test) { + promise_test(function(t) { + var animation = test.setupAnimation(t); + + return animation.ready.then(function() { + 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: function(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: function(t) { + return addDiv(t, { style: 'animation: anim 100s 100s;' + + 'transform: translateX(10px)' }) + .getAnimations()[0]; + }, + }, + { + desc: 'CSS transition of transform with transform style', + setupAnimation: function(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(function(test) { + promise_test(function(t) { + var animation = test.setupAnimation(t); + + return animation.ready.then(function() { + 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'); + }).then(function() { + // Remove the initial transform style during delay phase. + animation.effect.target.style.transform = 'none'; + return animation.ready; + }).then(function() { + 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: function(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: function(t) { + return addDiv(t, + { style: 'animation: transform-starts-with-none 100s 100s' }) + .getAnimations()[0]; + }, + }, +]; + +startsWithNoneTests.forEach(function(test) { + promise_test(function(t) { + var animation = test.setupAnimation(t); + + return animation.ready.then(function() { + 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(function(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 }); + + return animation.ready.then(function() { + 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", ""); + return waitForFrame(); + }).then(function() { + 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(function(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 }); + + return animation.ready.then(function() { + 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; + return waitForFrame(); + }).then(function() { + 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(function(t) { + var animation = + addDivAndAnimate(t, + {}, + { width: ['100px', '200px'] }, + { duration: 100 * MS_PER_SEC, delay: 100 * MS_PER_SEC }); + + return animation.ready.then(function() { + 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] }); + return waitForFrame(); + }).then(function() { + 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(function(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]; + + return animation.ready.then(function() { + 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(function(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(); + + return Promise.all([transition.ready, animation.ready]).then(function() { + 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'); + +</script> +</body> |