diff options
Diffstat (limited to 'devtools/client/animationinspector')
79 files changed, 6778 insertions, 0 deletions
diff --git a/devtools/client/animationinspector/animation-controller.js b/devtools/client/animationinspector/animation-controller.js new file mode 100644 index 000000000..03c6e0e95 --- /dev/null +++ b/devtools/client/animationinspector/animation-controller.js @@ -0,0 +1,390 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* animation-panel.js is loaded in the same scope but we don't use + import-globals-from to avoid infinite loops since animation-panel.js already + imports globals from animation-controller.js */ +/* globals AnimationsPanel */ +/* eslint no-unused-vars: [2, {"vars": "local", "args": "none"}] */ + +"use strict"; + +var { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +var { loader, require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); +var { Task } = require("devtools/shared/task"); + +loader.lazyRequireGetter(this, "promise"); +loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); +loader.lazyRequireGetter(this, "AnimationsFront", "devtools/shared/fronts/animation", true); + +const { LocalizationHelper } = require("devtools/shared/l10n"); +const L10N = + new LocalizationHelper("devtools/client/locales/animationinspector.properties"); + +// Global toolbox/inspector, set when startup is called. +var gToolbox, gInspector; + +/** + * Startup the animationinspector controller and view, called by the sidebar + * widget when loading/unloading the iframe into the tab. + */ +var startup = Task.async(function* (inspector) { + gInspector = inspector; + gToolbox = inspector.toolbox; + + // Don't assume that AnimationsPanel is defined here, it's in another file. + if (!typeof AnimationsPanel === "undefined") { + throw new Error("AnimationsPanel was not loaded in the " + + "animationinspector window"); + } + + // Startup first initalizes the controller and then the panel, in sequence. + // If you want to know when everything's ready, do: + // AnimationsPanel.once(AnimationsPanel.PANEL_INITIALIZED) + yield AnimationsController.initialize(); + yield AnimationsPanel.initialize(); +}); + +/** + * Shutdown the animationinspector controller and view, called by the sidebar + * widget when loading/unloading the iframe into the tab. + */ +var shutdown = Task.async(function* () { + yield AnimationsController.destroy(); + // Don't assume that AnimationsPanel is defined here, it's in another file. + if (typeof AnimationsPanel !== "undefined") { + yield AnimationsPanel.destroy(); + } + gToolbox = gInspector = null; +}); + +// This is what makes the sidebar widget able to load/unload the panel. +function setPanel(panel) { + return startup(panel).catch(e => console.error(e)); +} +function destroy() { + return shutdown().catch(e => console.error(e)); +} + +/** + * Get all the server-side capabilities (traits) so the UI knows whether or not + * features should be enabled/disabled. + * @param {Target} target The current toolbox target. + * @return {Object} An object with boolean properties. + */ +var getServerTraits = Task.async(function* (target) { + let config = [ + { name: "hasToggleAll", actor: "animations", + method: "toggleAll" }, + { name: "hasToggleSeveral", actor: "animations", + method: "toggleSeveral" }, + { name: "hasSetCurrentTime", actor: "animationplayer", + method: "setCurrentTime" }, + { name: "hasMutationEvents", actor: "animations", + method: "stopAnimationPlayerUpdates" }, + { name: "hasSetPlaybackRate", actor: "animationplayer", + method: "setPlaybackRate" }, + { name: "hasSetPlaybackRates", actor: "animations", + method: "setPlaybackRates" }, + { name: "hasTargetNode", actor: "domwalker", + method: "getNodeFromActor" }, + { name: "hasSetCurrentTimes", actor: "animations", + method: "setCurrentTimes" }, + { name: "hasGetFrames", actor: "animationplayer", + method: "getFrames" }, + { name: "hasGetProperties", actor: "animationplayer", + method: "getProperties" }, + { name: "hasSetWalkerActor", actor: "animations", + method: "setWalkerActor" }, + ]; + + let traits = {}; + for (let {name, actor, method} of config) { + traits[name] = yield target.actorHasMethod(actor, method); + } + + return traits; +}); + +/** + * The animationinspector controller's job is to retrieve AnimationPlayerFronts + * from the server. It is also responsible for keeping the list of players up to + * date when the node selection changes in the inspector, as well as making sure + * no updates are done when the animationinspector sidebar panel is not visible. + * + * AnimationPlayerFronts are available in AnimationsController.animationPlayers. + * + * Usage example: + * + * AnimationsController.on(AnimationsController.PLAYERS_UPDATED_EVENT, + * onPlayers); + * function onPlayers() { + * for (let player of AnimationsController.animationPlayers) { + * // do something with player + * } + * } + */ +var AnimationsController = { + PLAYERS_UPDATED_EVENT: "players-updated", + ALL_ANIMATIONS_TOGGLED_EVENT: "all-animations-toggled", + + initialize: Task.async(function* () { + if (this.initialized) { + yield this.initialized; + return; + } + + let resolver; + this.initialized = new Promise(resolve => { + resolver = resolve; + }); + + this.onPanelVisibilityChange = this.onPanelVisibilityChange.bind(this); + this.onNewNodeFront = this.onNewNodeFront.bind(this); + this.onAnimationMutations = this.onAnimationMutations.bind(this); + + let target = gInspector.target; + this.animationsFront = new AnimationsFront(target.client, target.form); + + // Expose actor capabilities. + this.traits = yield getServerTraits(target); + + if (this.destroyed) { + console.warn("Could not fully initialize the AnimationsController"); + return; + } + + // Let the AnimationsActor know what WalkerActor we're using. This will + // come in handy later to return references to DOM Nodes. + if (this.traits.hasSetWalkerActor) { + yield this.animationsFront.setWalkerActor(gInspector.walker); + } + + this.startListeners(); + yield this.onNewNodeFront(); + + resolver(); + }), + + destroy: Task.async(function* () { + if (!this.initialized) { + return; + } + + if (this.destroyed) { + yield this.destroyed; + return; + } + + let resolver; + this.destroyed = new Promise(resolve => { + resolver = resolve; + }); + + this.stopListeners(); + this.destroyAnimationPlayers(); + this.nodeFront = null; + + if (this.animationsFront) { + this.animationsFront.destroy(); + this.animationsFront = null; + } + resolver(); + }), + + startListeners: function () { + // Re-create the list of players when a new node is selected, except if the + // sidebar isn't visible. + gInspector.selection.on("new-node-front", this.onNewNodeFront); + gInspector.sidebar.on("select", this.onPanelVisibilityChange); + gToolbox.on("select", this.onPanelVisibilityChange); + }, + + stopListeners: function () { + gInspector.selection.off("new-node-front", this.onNewNodeFront); + gInspector.sidebar.off("select", this.onPanelVisibilityChange); + gToolbox.off("select", this.onPanelVisibilityChange); + if (this.isListeningToMutations) { + this.animationsFront.off("mutations", this.onAnimationMutations); + } + }, + + isPanelVisible: function () { + return gToolbox.currentToolId === "inspector" && + gInspector.sidebar && + gInspector.sidebar.getCurrentTabID() == "animationinspector"; + }, + + onPanelVisibilityChange: Task.async(function* () { + if (this.isPanelVisible()) { + this.onNewNodeFront(); + } + }), + + onNewNodeFront: Task.async(function* () { + // Ignore if the panel isn't visible or the node selection hasn't changed. + if (!this.isPanelVisible() || + this.nodeFront === gInspector.selection.nodeFront) { + return; + } + + this.nodeFront = gInspector.selection.nodeFront; + let done = gInspector.updating("animationscontroller"); + + if (!gInspector.selection.isConnected() || + !gInspector.selection.isElementNode()) { + this.destroyAnimationPlayers(); + this.emit(this.PLAYERS_UPDATED_EVENT); + done(); + return; + } + + yield this.refreshAnimationPlayers(this.nodeFront); + this.emit(this.PLAYERS_UPDATED_EVENT, this.animationPlayers); + + done(); + }), + + /** + * Toggle (pause/play) all animations in the current target. + */ + toggleAll: function () { + if (!this.traits.hasToggleAll) { + return promise.resolve(); + } + + return this.animationsFront.toggleAll() + .then(() => this.emit(this.ALL_ANIMATIONS_TOGGLED_EVENT, this)) + .catch(e => console.error(e)); + }, + + /** + * Similar to toggleAll except that it only plays/pauses the currently known + * animations (those listed in this.animationPlayers). + * @param {Boolean} shouldPause True if the animations should be paused, false + * if they should be played. + * @return {Promise} Resolves when the playState has been changed. + */ + toggleCurrentAnimations: Task.async(function* (shouldPause) { + if (this.traits.hasToggleSeveral) { + yield this.animationsFront.toggleSeveral(this.animationPlayers, + shouldPause); + } else { + // Fall back to pausing/playing the players one by one, which is bound to + // introduce some de-synchronization. + for (let player of this.animationPlayers) { + if (shouldPause) { + yield player.pause(); + } else { + yield player.play(); + } + } + } + }), + + /** + * Set all known animations' currentTimes to the provided time. + * @param {Number} time. + * @param {Boolean} shouldPause Should the animations be paused too. + * @return {Promise} Resolves when the current time has been set. + */ + setCurrentTimeAll: Task.async(function* (time, shouldPause) { + if (this.traits.hasSetCurrentTimes) { + yield this.animationsFront.setCurrentTimes(this.animationPlayers, time, + shouldPause); + } else { + // Fall back to pausing and setting the current time on each player, one + // by one, which is bound to introduce some de-synchronization. + for (let animation of this.animationPlayers) { + if (shouldPause) { + yield animation.pause(); + } + yield animation.setCurrentTime(time); + } + } + }), + + /** + * Set all known animations' playback rates to the provided rate. + * @param {Number} rate. + * @return {Promise} Resolves when the rate has been set. + */ + setPlaybackRateAll: Task.async(function* (rate) { + if (this.traits.hasSetPlaybackRates) { + // If the backend can set all playback rates at the same time, use that. + yield this.animationsFront.setPlaybackRates(this.animationPlayers, rate); + } else if (this.traits.hasSetPlaybackRate) { + // Otherwise, fall back to setting each rate individually. + for (let animation of this.animationPlayers) { + yield animation.setPlaybackRate(rate); + } + } + }), + + // AnimationPlayerFront objects are managed by this controller. They are + // retrieved when refreshAnimationPlayers is called, stored in the + // animationPlayers array, and destroyed when refreshAnimationPlayers is + // called again. + animationPlayers: [], + + refreshAnimationPlayers: Task.async(function* (nodeFront) { + this.destroyAnimationPlayers(); + + this.animationPlayers = yield this.animationsFront + .getAnimationPlayersForNode(nodeFront); + + // Start listening for animation mutations only after the first method call + // otherwise events won't be sent. + if (!this.isListeningToMutations && this.traits.hasMutationEvents) { + this.animationsFront.on("mutations", this.onAnimationMutations); + this.isListeningToMutations = true; + } + }), + + onAnimationMutations: function (changes) { + // Insert new players into this.animationPlayers when new animations are + // added. + for (let {type, player} of changes) { + if (type === "added") { + this.animationPlayers.push(player); + } + + if (type === "removed") { + let index = this.animationPlayers.indexOf(player); + this.animationPlayers.splice(index, 1); + } + } + + // Let the UI know the list has been updated. + this.emit(this.PLAYERS_UPDATED_EVENT, this.animationPlayers); + }, + + /** + * Get the latest known current time of document.timeline. + * This value is sent along with all AnimationPlayerActors' states, but it + * isn't updated after that, so this function loops over all know animations + * to find the highest value. + * @return {Number|Boolean} False is returned if this server version doesn't + * provide document's current time. + */ + get documentCurrentTime() { + let time = 0; + for (let {state} of this.animationPlayers) { + if (!state.documentCurrentTime) { + return false; + } + time = Math.max(time, state.documentCurrentTime); + } + return time; + }, + + destroyAnimationPlayers: function () { + this.animationPlayers = []; + } +}; + +EventEmitter.decorate(AnimationsController); diff --git a/devtools/client/animationinspector/animation-inspector.xhtml b/devtools/client/animationinspector/animation-inspector.xhtml new file mode 100644 index 000000000..26115be31 --- /dev/null +++ b/devtools/client/animationinspector/animation-inspector.xhtml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> + <link rel="stylesheet" href="chrome://devtools/skin/animationinspector.css" type="text/css"/> + <script type="application/javascript;version=1.8" src="chrome://devtools/content/shared/theme-switching.js"/> + </head> + <body class="theme-sidebar devtools-monospace" role="application" empty="true"> + <div id="global-toolbar" class="theme-toolbar"> + <span id="all-animations-label" class="label"></span> + <button id="toggle-all" standalone="true" class="devtools-button pause-button"></button> + </div> + <div id="timeline-toolbar" class="theme-toolbar"> + <button id="rewind-timeline" standalone="true" class="devtools-button"></button> + <button id="pause-resume-timeline" standalone="true" class="devtools-button pause-button paused"></button> + <span id="timeline-rate" standalone="true" class="devtools-button"></span> + <span id="timeline-current-time" class="label"></span> + </div> + <div id="players"></div> + <div id="error-message"> + <p id="error-type"></p> + <p id="error-hint"></p> + <button id="element-picker" standalone="true" class="devtools-button"></button> + </div> + <script type="application/javascript;version=1.8" src="animation-controller.js"></script> + <script type="application/javascript;version=1.8" src="animation-panel.js"></script> + </body> +</html> diff --git a/devtools/client/animationinspector/animation-panel.js b/devtools/client/animationinspector/animation-panel.js new file mode 100644 index 000000000..25fd84b87 --- /dev/null +++ b/devtools/client/animationinspector/animation-panel.js @@ -0,0 +1,347 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from animation-controller.js */ +/* globals document */ + +"use strict"; + +const {AnimationsTimeline} = require("devtools/client/animationinspector/components/animation-timeline"); +const {RateSelector} = require("devtools/client/animationinspector/components/rate-selector"); +const {formatStopwatchTime} = require("devtools/client/animationinspector/utils"); +const {KeyCodes} = require("devtools/client/shared/keycodes"); + +var $ = (selector, target = document) => target.querySelector(selector); + +/** + * The main animations panel UI. + */ +var AnimationsPanel = { + UI_UPDATED_EVENT: "ui-updated", + PANEL_INITIALIZED: "panel-initialized", + + initialize: Task.async(function* () { + if (AnimationsController.destroyed) { + console.warn("Could not initialize the animation-panel, controller " + + "was destroyed"); + return; + } + if (this.initialized) { + yield this.initialized; + return; + } + + let resolver; + this.initialized = new Promise(resolve => { + resolver = resolve; + }); + + this.playersEl = $("#players"); + this.errorMessageEl = $("#error-message"); + this.pickerButtonEl = $("#element-picker"); + this.toggleAllButtonEl = $("#toggle-all"); + this.playTimelineButtonEl = $("#pause-resume-timeline"); + this.rewindTimelineButtonEl = $("#rewind-timeline"); + this.timelineCurrentTimeEl = $("#timeline-current-time"); + this.rateSelectorEl = $("#timeline-rate"); + + this.rewindTimelineButtonEl.setAttribute("title", + L10N.getStr("timeline.rewindButtonTooltip")); + + $("#all-animations-label").textContent = L10N.getStr("panel.allAnimations"); + + // If the server doesn't support toggling all animations at once, hide the + // whole global toolbar. + if (!AnimationsController.traits.hasToggleAll) { + $("#global-toolbar").style.display = "none"; + } + + // Binding functions that need to be called in scope. + for (let functionName of ["onKeyDown", "onPickerStarted", + "onPickerStopped", "refreshAnimationsUI", "onToggleAllClicked", + "onTabNavigated", "onTimelineDataChanged", "onTimelinePlayClicked", + "onTimelineRewindClicked", "onRateChanged"]) { + this[functionName] = this[functionName].bind(this); + } + let hUtils = gToolbox.highlighterUtils; + this.togglePicker = hUtils.togglePicker.bind(hUtils); + + this.animationsTimelineComponent = new AnimationsTimeline(gInspector, + AnimationsController.traits); + this.animationsTimelineComponent.init(this.playersEl); + + if (AnimationsController.traits.hasSetPlaybackRate) { + this.rateSelectorComponent = new RateSelector(); + this.rateSelectorComponent.init(this.rateSelectorEl); + } + + this.startListeners(); + + yield this.refreshAnimationsUI(); + + resolver(); + this.emit(this.PANEL_INITIALIZED); + }), + + destroy: Task.async(function* () { + if (!this.initialized) { + return; + } + + if (this.destroyed) { + yield this.destroyed; + return; + } + + let resolver; + this.destroyed = new Promise(resolve => { + resolver = resolve; + }); + + this.stopListeners(); + + this.animationsTimelineComponent.destroy(); + this.animationsTimelineComponent = null; + + if (this.rateSelectorComponent) { + this.rateSelectorComponent.destroy(); + this.rateSelectorComponent = null; + } + + this.playersEl = this.errorMessageEl = null; + this.toggleAllButtonEl = this.pickerButtonEl = null; + this.playTimelineButtonEl = this.rewindTimelineButtonEl = null; + this.timelineCurrentTimeEl = this.rateSelectorEl = null; + + resolver(); + }), + + startListeners: function () { + AnimationsController.on(AnimationsController.PLAYERS_UPDATED_EVENT, + this.refreshAnimationsUI); + + this.pickerButtonEl.addEventListener("click", this.togglePicker); + gToolbox.on("picker-started", this.onPickerStarted); + gToolbox.on("picker-stopped", this.onPickerStopped); + + this.toggleAllButtonEl.addEventListener("click", this.onToggleAllClicked); + this.playTimelineButtonEl.addEventListener( + "click", this.onTimelinePlayClicked); + this.rewindTimelineButtonEl.addEventListener( + "click", this.onTimelineRewindClicked); + + document.addEventListener("keydown", this.onKeyDown, false); + + gToolbox.target.on("navigate", this.onTabNavigated); + + this.animationsTimelineComponent.on("timeline-data-changed", + this.onTimelineDataChanged); + + if (this.rateSelectorComponent) { + this.rateSelectorComponent.on("rate-changed", this.onRateChanged); + } + }, + + stopListeners: function () { + AnimationsController.off(AnimationsController.PLAYERS_UPDATED_EVENT, + this.refreshAnimationsUI); + + this.pickerButtonEl.removeEventListener("click", this.togglePicker); + gToolbox.off("picker-started", this.onPickerStarted); + gToolbox.off("picker-stopped", this.onPickerStopped); + + this.toggleAllButtonEl.removeEventListener("click", + this.onToggleAllClicked); + this.playTimelineButtonEl.removeEventListener("click", + this.onTimelinePlayClicked); + this.rewindTimelineButtonEl.removeEventListener("click", + this.onTimelineRewindClicked); + + document.removeEventListener("keydown", this.onKeyDown, false); + + gToolbox.target.off("navigate", this.onTabNavigated); + + this.animationsTimelineComponent.off("timeline-data-changed", + this.onTimelineDataChanged); + + if (this.rateSelectorComponent) { + this.rateSelectorComponent.off("rate-changed", this.onRateChanged); + } + }, + + onKeyDown: function (event) { + // If the space key is pressed, it should toggle the play state of + // the animations displayed in the panel, or of all the animations on + // the page if the selected node does not have any animation on it. + if (event.keyCode === KeyCodes.DOM_VK_SPACE) { + if (AnimationsController.animationPlayers.length > 0) { + this.playPauseTimeline().catch(ex => console.error(ex)); + } else { + this.toggleAll().catch(ex => console.error(ex)); + } + event.preventDefault(); + } + }, + + togglePlayers: function (isVisible) { + if (isVisible) { + document.body.removeAttribute("empty"); + document.body.setAttribute("timeline", "true"); + } else { + document.body.setAttribute("empty", "true"); + document.body.removeAttribute("timeline"); + $("#error-type").textContent = L10N.getStr("panel.invalidElementSelected"); + $("#error-hint").textContent = L10N.getStr("panel.selectElement"); + } + }, + + onPickerStarted: function () { + this.pickerButtonEl.setAttribute("checked", "true"); + }, + + onPickerStopped: function () { + this.pickerButtonEl.removeAttribute("checked"); + }, + + onToggleAllClicked: function () { + this.toggleAll().catch(ex => console.error(ex)); + }, + + /** + * Toggle (pause/play) all animations in the current target + * and update the UI the toggleAll button. + */ + toggleAll: Task.async(function* () { + this.toggleAllButtonEl.classList.toggle("paused"); + yield AnimationsController.toggleAll(); + }), + + onTimelinePlayClicked: function () { + this.playPauseTimeline().catch(ex => console.error(ex)); + }, + + /** + * Depending on the state of the timeline either pause or play the animations + * displayed in it. + * If the animations are finished, this will play them from the start again. + * If the animations are playing, this will pause them. + * If the animations are paused, this will resume them. + * + * @return {Promise} Resolves when the playState is changed and the UI + * is refreshed + */ + playPauseTimeline: function () { + return AnimationsController + .toggleCurrentAnimations(this.timelineData.isMoving) + .then(() => this.refreshAnimationsStateAndUI()); + }, + + onTimelineRewindClicked: function () { + this.rewindTimeline().catch(ex => console.error(ex)); + }, + + /** + * Reset the startTime of all current animations shown in the timeline and + * pause them. + * + * @return {Promise} Resolves when currentTime is set and the UI is refreshed + */ + rewindTimeline: function () { + return AnimationsController + .setCurrentTimeAll(0, true) + .then(() => this.refreshAnimationsStateAndUI()); + }, + + /** + * Set the playback rate of all current animations shown in the timeline to + * the value of this.rateSelectorEl. + */ + onRateChanged: function (e, rate) { + AnimationsController.setPlaybackRateAll(rate) + .then(() => this.refreshAnimationsStateAndUI()) + .catch(ex => console.error(ex)); + }, + + onTabNavigated: function () { + this.toggleAllButtonEl.classList.remove("paused"); + }, + + onTimelineDataChanged: function (e, data) { + this.timelineData = data; + let {isMoving, isUserDrag, time} = data; + + this.playTimelineButtonEl.classList.toggle("paused", !isMoving); + + let l10nPlayProperty = isMoving ? "timeline.resumedButtonTooltip" : + "timeline.pausedButtonTooltip"; + + this.playTimelineButtonEl.setAttribute("title", + L10N.getStr(l10nPlayProperty)); + + // If the timeline data changed as a result of the user dragging the + // scrubber, then pause all animations and set their currentTimes. + // (Note that we want server-side requests to be sequenced, so we only do + // this after the previous currentTime setting was done). + if (isUserDrag && !this.setCurrentTimeAllPromise) { + this.setCurrentTimeAllPromise = + AnimationsController.setCurrentTimeAll(time, true) + .catch(error => console.error(error)) + .then(() => { + this.setCurrentTimeAllPromise = null; + }); + } + + this.displayTimelineCurrentTime(); + }, + + displayTimelineCurrentTime: function () { + let {time} = this.timelineData; + this.timelineCurrentTimeEl.textContent = formatStopwatchTime(time); + }, + + /** + * Make sure all known animations have their states up to date (which is + * useful after the playState or currentTime has been changed and in case the + * animations aren't auto-refreshing), and then refresh the UI. + */ + refreshAnimationsStateAndUI: Task.async(function* () { + for (let player of AnimationsController.animationPlayers) { + yield player.refreshState(); + } + yield this.refreshAnimationsUI(); + }), + + /** + * Refresh the list of animations UI. This will empty the panel and re-render + * the various components again. + */ + refreshAnimationsUI: Task.async(function* () { + // Empty the whole panel first. + this.togglePlayers(true); + + // Re-render the timeline component. + this.animationsTimelineComponent.render( + AnimationsController.animationPlayers, + AnimationsController.documentCurrentTime); + + // Re-render the rate selector component. + if (this.rateSelectorComponent) { + this.rateSelectorComponent.render(AnimationsController.animationPlayers); + } + + // If there are no players to show, show the error message instead and + // return. + if (!AnimationsController.animationPlayers.length) { + this.togglePlayers(false); + this.emit(this.UI_UPDATED_EVENT); + return; + } + + this.emit(this.UI_UPDATED_EVENT); + }) +}; + +EventEmitter.decorate(AnimationsPanel); diff --git a/devtools/client/animationinspector/components/animation-details.js b/devtools/client/animationinspector/components/animation-details.js new file mode 100644 index 000000000..c042ccac0 --- /dev/null +++ b/devtools/client/animationinspector/components/animation-details.js @@ -0,0 +1,222 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const {Task} = require("devtools/shared/task"); +const EventEmitter = require("devtools/shared/event-emitter"); +const {createNode, TimeScale} = require("devtools/client/animationinspector/utils"); +const {Keyframes} = require("devtools/client/animationinspector/components/keyframes"); + +/** + * UI component responsible for displaying detailed information for a given + * animation. + * This includes information about timing, easing, keyframes, animated + * properties. + * + * @param {Object} serverTraits The list of server-side capabilities. + */ +function AnimationDetails(serverTraits) { + EventEmitter.decorate(this); + + this.onFrameSelected = this.onFrameSelected.bind(this); + + this.keyframeComponents = []; + this.serverTraits = serverTraits; +} + +exports.AnimationDetails = AnimationDetails; + +AnimationDetails.prototype = { + // These are part of frame objects but are not animated properties. This + // array is used to skip them. + NON_PROPERTIES: ["easing", "composite", "computedOffset", "offset"], + + init: function (containerEl) { + this.containerEl = containerEl; + }, + + destroy: function () { + this.unrender(); + this.containerEl = null; + this.serverTraits = null; + }, + + unrender: function () { + for (let component of this.keyframeComponents) { + component.off("frame-selected", this.onFrameSelected); + component.destroy(); + } + this.keyframeComponents = []; + + while (this.containerEl.firstChild) { + this.containerEl.firstChild.remove(); + } + }, + + getPerfDataForProperty: function (animation, propertyName) { + let warning = ""; + let className = ""; + if (animation.state.propertyState) { + let isRunningOnCompositor; + for (let propState of animation.state.propertyState) { + if (propState.property == propertyName) { + isRunningOnCompositor = propState.runningOnCompositor; + if (typeof propState.warning != "undefined") { + warning = propState.warning; + } + break; + } + } + if (isRunningOnCompositor && warning == "") { + className = "oncompositor"; + } else if (!isRunningOnCompositor && warning != "") { + className = "warning"; + } + } + return {className, warning}; + }, + + /** + * Get a list of the tracks of the animation actor + * @return {Object} A list of tracks, one per animated property, each + * with a list of keyframes + */ + getTracks: Task.async(function* () { + let tracks = {}; + + /* + * getFrames is a AnimationPlayorActor method that returns data about the + * keyframes of the animation. + * In FF48, the data it returns change, and will hold only longhand + * properties ( e.g. borderLeftWidth ), which does not match what we + * want to display in the animation detail. + * A new AnimationPlayerActor function, getProperties, is introduced, + * that returns the animated css properties of the animation and their + * keyframes values. + * If the animation actor has the getProperties function, we use it, and if + * not, we fall back to getFrames, which then returns values we used to + * handle. + */ + if (this.serverTraits.hasGetProperties) { + let properties = yield this.animation.getProperties(); + for (let {name, values} of properties) { + if (!tracks[name]) { + tracks[name] = []; + } + + for (let {value, offset} of values) { + tracks[name].push({value, offset}); + } + } + } else { + let frames = yield this.animation.getFrames(); + for (let frame of frames) { + for (let name in frame) { + if (this.NON_PROPERTIES.indexOf(name) != -1) { + continue; + } + + if (!tracks[name]) { + tracks[name] = []; + } + + tracks[name].push({ + value: frame[name], + offset: frame.computedOffset + }); + } + } + } + + return tracks; + }), + + render: Task.async(function* (animation) { + this.unrender(); + + if (!animation) { + return; + } + this.animation = animation; + + // We might have been destroyed in the meantime, or the component might + // have been re-rendered. + if (!this.containerEl || this.animation !== animation) { + return; + } + + // Build an element for each animated property track. + this.tracks = yield this.getTracks(animation, this.serverTraits); + + // Useful for tests to know when the keyframes have been retrieved. + this.emit("keyframes-retrieved"); + + for (let propertyName in this.tracks) { + let line = createNode({ + parent: this.containerEl, + attributes: {"class": "property"} + }); + let {warning, className} = + this.getPerfDataForProperty(animation, propertyName); + createNode({ + // text-overflow doesn't work in flex items, so we need a second level + // of container to actually have an ellipsis on the name. + // See bug 972664. + parent: createNode({ + parent: line, + attributes: {"class": "name"} + }), + textContent: getCssPropertyName(propertyName), + attributes: {"title": warning, + "class": className} + }); + + // Add the keyframes diagram for this property. + let framesWrapperEl = createNode({ + parent: line, + attributes: {"class": "track-container"} + }); + + let framesEl = createNode({ + parent: framesWrapperEl, + attributes: {"class": "frames"} + }); + + // Scale the list of keyframes according to the current time scale. + let {x, w} = TimeScale.getAnimationDimensions(animation); + framesEl.style.left = `${x}%`; + framesEl.style.width = `${w}%`; + + let keyframesComponent = new Keyframes(); + keyframesComponent.init(framesEl); + keyframesComponent.render({ + keyframes: this.tracks[propertyName], + propertyName: propertyName, + animation: animation + }); + keyframesComponent.on("frame-selected", this.onFrameSelected); + + this.keyframeComponents.push(keyframesComponent); + } + }), + + onFrameSelected: function (e, args) { + // Relay the event up, it's needed in parents too. + this.emit(e, args); + } +}; + +/** + * Turn propertyName into property-name. + * @param {String} jsPropertyName A camelcased CSS property name. Typically + * something that comes out of computed styles. E.g. borderBottomColor + * @return {String} The corresponding CSS property name: border-bottom-color + */ +function getCssPropertyName(jsPropertyName) { + return jsPropertyName.replace(/[A-Z]/g, "-$&").toLowerCase(); +} +exports.getCssPropertyName = getCssPropertyName; diff --git a/devtools/client/animationinspector/components/animation-target-node.js b/devtools/client/animationinspector/components/animation-target-node.js new file mode 100644 index 000000000..c300e9ce7 --- /dev/null +++ b/devtools/client/animationinspector/components/animation-target-node.js @@ -0,0 +1,80 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const {Task} = require("devtools/shared/task"); +const EventEmitter = require("devtools/shared/event-emitter"); +const {DomNodePreview} = require("devtools/client/inspector/shared/dom-node-preview"); + +// Map dom node fronts by animation fronts so we don't have to get them from the +// walker every time the timeline is refreshed. +var nodeFronts = new WeakMap(); + +/** + * UI component responsible for displaying a preview of the target dom node of + * a given animation. + * Accepts the same parameters as the DomNodePreview component. See + * devtools/client/inspector/shared/dom-node-preview.js for documentation. + */ +function AnimationTargetNode(inspector, options) { + this.inspector = inspector; + this.previewer = new DomNodePreview(inspector, options); + EventEmitter.decorate(this); +} + +exports.AnimationTargetNode = AnimationTargetNode; + +AnimationTargetNode.prototype = { + init: function (containerEl) { + this.previewer.init(containerEl); + this.isDestroyed = false; + }, + + destroy: function () { + this.previewer.destroy(); + this.inspector = null; + this.isDestroyed = true; + }, + + render: Task.async(function* (playerFront) { + // Get the nodeFront from the cache if it was stored previously. + let nodeFront = nodeFronts.get(playerFront); + + // Try and get it from the playerFront directly next. + if (!nodeFront) { + nodeFront = playerFront.animationTargetNodeFront; + } + + // Finally, get it from the walkerActor if it wasn't found. + if (!nodeFront) { + try { + nodeFront = yield this.inspector.walker.getNodeFromActor( + playerFront.actorID, ["node"]); + } catch (e) { + // If an error occured while getting the nodeFront and if it can't be + // attributed to the panel having been destroyed in the meantime, this + // error needs to be logged and render needs to stop. + if (!this.isDestroyed) { + console.error(e); + } + return; + } + + // In all cases, if by now the panel doesn't exist anymore, we need to + // stop rendering too. + if (this.isDestroyed) { + return; + } + } + + // Add the nodeFront to the cache. + nodeFronts.set(playerFront, nodeFront); + + this.previewer.render(nodeFront); + this.emit("target-retrieved"); + }) +}; diff --git a/devtools/client/animationinspector/components/animation-time-block.js b/devtools/client/animationinspector/components/animation-time-block.js new file mode 100644 index 000000000..51392da8f --- /dev/null +++ b/devtools/client/animationinspector/components/animation-time-block.js @@ -0,0 +1,719 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("devtools/shared/event-emitter"); +const {createNode, TimeScale} = require("devtools/client/animationinspector/utils"); + +const { LocalizationHelper } = require("devtools/shared/l10n"); +const L10N = + new LocalizationHelper("devtools/client/locales/animationinspector.properties"); + +// In the createPathSegments function, an animation duration is divided by +// DURATION_RESOLUTION in order to draw the way the animation progresses. +// But depending on the timing-function, we may be not able to make the graph +// smoothly progress if this resolution is not high enough. +// So, if the difference of animation progress between 2 divisions is more than +// MIN_PROGRESS_THRESHOLD, then createPathSegments re-divides +// by DURATION_RESOLUTION. +// DURATION_RESOLUTION shoud be integer and more than 2. +const DURATION_RESOLUTION = 4; +// MIN_PROGRESS_THRESHOLD shoud be between more than 0 to 1. +const MIN_PROGRESS_THRESHOLD = 0.1; +// Show max 10 iterations for infinite animations +// to give users a clue that the animation does repeat. +const MAX_INFINITE_ANIMATIONS_ITERATIONS = 10; +// SVG namespace +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** + * UI component responsible for displaying a single animation timeline, which + * basically looks like a rectangle that shows the delay and iterations. + */ +function AnimationTimeBlock() { + EventEmitter.decorate(this); + this.onClick = this.onClick.bind(this); +} + +exports.AnimationTimeBlock = AnimationTimeBlock; + +AnimationTimeBlock.prototype = { + init: function (containerEl) { + this.containerEl = containerEl; + this.containerEl.addEventListener("click", this.onClick); + }, + + destroy: function () { + this.containerEl.removeEventListener("click", this.onClick); + this.unrender(); + this.containerEl = null; + this.animation = null; + }, + + unrender: function () { + while (this.containerEl.firstChild) { + this.containerEl.firstChild.remove(); + } + }, + + render: function (animation) { + this.unrender(); + + this.animation = animation; + let {state} = this.animation; + + // Create a container element to hold the delay and iterations. + // It is positioned according to its delay (divided by the playbackrate), + // and its width is according to its duration (divided by the playbackrate). + const {x, delayX, delayW, endDelayX, endDelayW} = + TimeScale.getAnimationDimensions(animation); + + // Animation summary graph element. + const summaryEl = createNode({ + parent: this.containerEl, + namespace: "http://www.w3.org/2000/svg", + nodeType: "svg", + attributes: { + "class": "summary", + "preserveAspectRatio": "none", + "style": `left: ${ x - (state.delay > 0 ? delayW : 0) }%` + } + }); + + // Total displayed duration + const totalDisplayedDuration = state.playbackRate * TimeScale.getDuration(); + + // Calculate stroke height in viewBox to display stroke of path. + const strokeHeightForViewBox = 0.5 / this.containerEl.clientHeight; + + // Set viewBox + summaryEl.setAttribute("viewBox", + `${ state.delay < 0 ? state.delay : 0 } + -${ 1 + strokeHeightForViewBox } + ${ totalDisplayedDuration } + ${ 1 + strokeHeightForViewBox * 2 }`); + + // Get a helper function that returns the path segment of timing-function. + const segmentHelper = getSegmentHelper(state, this.win); + + // Minimum segment duration is the duration of one pixel. + const minSegmentDuration = + totalDisplayedDuration / this.containerEl.clientWidth; + // Minimum progress threshold. + let minProgressThreshold = MIN_PROGRESS_THRESHOLD; + // If the easing is step function, + // minProgressThreshold should be changed by the steps. + const stepFunction = state.easing.match(/steps\((\d+)/); + if (stepFunction) { + minProgressThreshold = 1 / (parseInt(stepFunction[1], 10) + 1); + } + + // Starting time of main iteration. + let mainIterationStartTime = 0; + let iterationStart = state.iterationStart; + let iterationCount = state.iterationCount ? state.iterationCount : Infinity; + + // Append delay. + if (state.delay > 0) { + renderDelay(summaryEl, state, segmentHelper); + mainIterationStartTime = state.delay; + } else { + const negativeDelayCount = -state.delay / state.duration; + // Move to forward the starting point for negative delay. + iterationStart += negativeDelayCount; + // Consume iteration count by negative delay. + if (iterationCount !== Infinity) { + iterationCount -= negativeDelayCount; + } + } + + // Append 1st section of iterations, + // This section is only useful in cases where iterationStart has decimals. + // e.g. + // if { iterationStart: 0.25, iterations: 3 }, firstSectionCount is 0.75. + const firstSectionCount = + iterationStart % 1 === 0 + ? 0 : Math.min(iterationCount, 1) - iterationStart % 1; + if (firstSectionCount) { + renderFirstIteration(summaryEl, state, mainIterationStartTime, + firstSectionCount, minSegmentDuration, + minProgressThreshold, segmentHelper); + } + + if (iterationCount === Infinity) { + // If the animation repeats infinitely, + // we fill the remaining area with iteration paths. + renderInfinity(summaryEl, state, mainIterationStartTime, + firstSectionCount, totalDisplayedDuration, + minSegmentDuration, minProgressThreshold, segmentHelper); + } else { + // Otherwise, we show remaining iterations, endDelay and fill. + + // Append forwards fill-mode. + if (state.fill === "both" || state.fill === "forwards") { + renderForwardsFill(summaryEl, state, mainIterationStartTime, + iterationCount, totalDisplayedDuration, + segmentHelper); + } + + // Append middle section of iterations. + // e.g. + // if { iterationStart: 0.25, iterations: 3 }, middleSectionCount is 2. + const middleSectionCount = + Math.floor(iterationCount - firstSectionCount); + renderMiddleIterations(summaryEl, state, mainIterationStartTime, + firstSectionCount, middleSectionCount, + minSegmentDuration, minProgressThreshold, + segmentHelper); + + // Append last section of iterations, if there is remaining iteration. + // e.g. + // if { iterationStart: 0.25, iterations: 3 }, lastSectionCount is 0.25. + const lastSectionCount = + iterationCount - middleSectionCount - firstSectionCount; + if (lastSectionCount) { + renderLastIteration(summaryEl, state, mainIterationStartTime, + firstSectionCount, middleSectionCount, + lastSectionCount, minSegmentDuration, + minProgressThreshold, segmentHelper); + } + + // Append endDelay. + if (state.endDelay > 0) { + renderEndDelay(summaryEl, state, + mainIterationStartTime, iterationCount, segmentHelper); + } + } + + // Append negative delay (which overlap the animation). + if (state.delay < 0) { + segmentHelper.animation.effect.timing.fill = "both"; + segmentHelper.asOriginalBehavior = false; + renderNegativeDelayHiddenProgress(summaryEl, state, minSegmentDuration, + minProgressThreshold, segmentHelper); + } + // Append negative endDelay (which overlap the animation). + if (state.iterationCount && state.endDelay < 0) { + if (segmentHelper.asOriginalBehavior) { + segmentHelper.animation.effect.timing.fill = "both"; + segmentHelper.asOriginalBehavior = false; + } + renderNegativeEndDelayHiddenProgress(summaryEl, state, + minSegmentDuration, + minProgressThreshold, + segmentHelper); + } + + // The animation name is displayed over the animation. + createNode({ + parent: createNode({ + parent: this.containerEl, + attributes: { + "class": "name", + "title": this.getTooltipText(state) + }, + }), + textContent: state.name + }); + + // Delay. + if (state.delay) { + // Negative delays need to start at 0. + createNode({ + parent: this.containerEl, + attributes: { + "class": "delay" + + (state.delay < 0 ? " negative" : " positive") + + (state.fill === "both" || + state.fill === "backwards" ? " fill" : ""), + "style": `left:${ delayX }%; width:${ delayW }%;` + } + }); + } + + // endDelay + if (state.iterationCount && state.endDelay) { + createNode({ + parent: this.containerEl, + attributes: { + "class": "end-delay" + + (state.endDelay < 0 ? " negative" : " positive") + + (state.fill === "both" || + state.fill === "forwards" ? " fill" : ""), + "style": `left:${ endDelayX }%; width:${ endDelayW }%;` + } + }); + } + }, + + getTooltipText: function (state) { + let getTime = time => L10N.getFormatStr("player.timeLabel", + L10N.numberWithDecimals(time / 1000, 2)); + + let text = ""; + + // Adding the name. + text += getFormattedAnimationTitle({state}); + text += "\n"; + + // Adding the delay. + if (state.delay) { + text += L10N.getStr("player.animationDelayLabel") + " "; + text += getTime(state.delay); + text += "\n"; + } + + // Adding the duration. + text += L10N.getStr("player.animationDurationLabel") + " "; + text += getTime(state.duration); + text += "\n"; + + // Adding the endDelay. + if (state.endDelay) { + text += L10N.getStr("player.animationEndDelayLabel") + " "; + text += getTime(state.endDelay); + text += "\n"; + } + + // Adding the iteration count (the infinite symbol, or an integer). + if (state.iterationCount !== 1) { + text += L10N.getStr("player.animationIterationCountLabel") + " "; + text += state.iterationCount || + L10N.getStr("player.infiniteIterationCountText"); + text += "\n"; + } + + // Adding the iteration start. + if (state.iterationStart !== 0) { + let iterationStartTime = state.iterationStart * state.duration / 1000; + text += L10N.getFormatStr("player.animationIterationStartLabel", + state.iterationStart, + L10N.numberWithDecimals(iterationStartTime, 2)); + text += "\n"; + } + + // Adding the easing. + if (state.easing) { + text += L10N.getStr("player.animationEasingLabel") + " "; + text += state.easing; + text += "\n"; + } + + // Adding the fill mode. + if (state.fill) { + text += L10N.getStr("player.animationFillLabel") + " "; + text += state.fill; + text += "\n"; + } + + // Adding the direction mode. + if (state.direction) { + text += L10N.getStr("player.animationDirectionLabel") + " "; + text += state.direction; + text += "\n"; + } + + // Adding the playback rate if it's different than 1. + if (state.playbackRate !== 1) { + text += L10N.getStr("player.animationRateLabel") + " "; + text += state.playbackRate; + text += "\n"; + } + + // Adding a note that the animation is running on the compositor thread if + // needed. + if (state.propertyState) { + if (state.propertyState + .every(propState => propState.runningOnCompositor)) { + text += L10N.getStr("player.allPropertiesOnCompositorTooltip"); + } else if (state.propertyState + .some(propState => propState.runningOnCompositor)) { + text += L10N.getStr("player.somePropertiesOnCompositorTooltip"); + } + } else if (state.isRunningOnCompositor) { + text += L10N.getStr("player.runningOnCompositorTooltip"); + } + + return text; + }, + + onClick: function (e) { + e.stopPropagation(); + this.emit("selected", this.animation); + }, + + get win() { + return this.containerEl.ownerDocument.defaultView; + } +}; + +/** + * Get a formatted title for this animation. This will be either: + * "some-name", "some-name : CSS Transition", "some-name : CSS Animation", + * "some-name : Script Animation", or "Script Animation", depending + * if the server provides the type, what type it is and if the animation + * has a name + * @param {AnimationPlayerFront} animation + */ +function getFormattedAnimationTitle({state}) { + // Older servers don't send a type, and only know about + // CSSAnimations and CSSTransitions, so it's safe to use + // just the name. + if (!state.type) { + return state.name; + } + + // Script-generated animations may not have a name. + if (state.type === "scriptanimation" && !state.name) { + return L10N.getStr("timeline.scriptanimation.unnamedLabel"); + } + + return L10N.getFormatStr(`timeline.${state.type}.nameLabel`, state.name); +} + +/** + * Render delay section. + * @param {Element} parentEl - Parent element of this appended path element. + * @param {Object} state - State of animation. + * @param {Object} segmentHelper - The object returned by getSegmentHelper. + */ +function renderDelay(parentEl, state, segmentHelper) { + const startSegment = segmentHelper.getSegment(0); + const endSegment = { x: state.delay, y: startSegment.y }; + appendPathElement(parentEl, [startSegment, endSegment], "delay-path"); +} + +/** + * Render first iteration section. + * @param {Element} parentEl - Parent element of this appended path element. + * @param {Object} state - State of animation. + * @param {Number} mainIterationStartTime - Starting time of main iteration. + * @param {Number} firstSectionCount - Iteration count of first section. + * @param {Number} minSegmentDuration - Minimum segment duration. + * @param {Number} minProgressThreshold - Minimum progress threshold. + * @param {Object} segmentHelper - The object returned by getSegmentHelper. + */ +function renderFirstIteration(parentEl, state, mainIterationStartTime, + firstSectionCount, minSegmentDuration, + minProgressThreshold, segmentHelper) { + const startTime = mainIterationStartTime; + const endTime = startTime + firstSectionCount * state.duration; + const segments = + createPathSegments(startTime, endTime, minSegmentDuration, + minProgressThreshold, segmentHelper); + appendPathElement(parentEl, segments, "iteration-path"); +} + +/** + * Render middle iterations section. + * @param {Element} parentEl - Parent element of this appended path element. + * @param {Object} state - State of animation. + * @param {Number} mainIterationStartTime - Starting time of main iteration. + * @param {Number} firstSectionCount - Iteration count of first section. + * @param {Number} middleSectionCount - Iteration count of middle section. + * @param {Number} minSegmentDuration - Minimum segment duration. + * @param {Number} minProgressThreshold - Minimum progress threshold. + * @param {Object} segmentHelper - The object returned by getSegmentHelper. + */ +function renderMiddleIterations(parentEl, state, mainIterationStartTime, + firstSectionCount, middleSectionCount, + minSegmentDuration, minProgressThreshold, + segmentHelper) { + const offset = mainIterationStartTime + firstSectionCount * state.duration; + for (let i = 0; i < middleSectionCount; i++) { + // Get the path segments of each iteration. + const startTime = offset + i * state.duration; + const endTime = startTime + state.duration; + const segments = + createPathSegments(startTime, endTime, minSegmentDuration, + minProgressThreshold, segmentHelper); + appendPathElement(parentEl, segments, "iteration-path"); + } +} + +/** + * Render last iteration section. + * @param {Element} parentEl - Parent element of this appended path element. + * @param {Object} state - State of animation. + * @param {Number} mainIterationStartTime - Starting time of main iteration. + * @param {Number} firstSectionCount - Iteration count of first section. + * @param {Number} middleSectionCount - Iteration count of middle section. + * @param {Number} lastSectionCount - Iteration count of last section. + * @param {Number} minSegmentDuration - Minimum segment duration. + * @param {Number} minProgressThreshold - Minimum progress threshold. + * @param {Object} segmentHelper - The object returned by getSegmentHelper. + */ +function renderLastIteration(parentEl, state, mainIterationStartTime, + firstSectionCount, middleSectionCount, + lastSectionCount, minSegmentDuration, + minProgressThreshold, segmentHelper) { + const startTime = mainIterationStartTime + + (firstSectionCount + middleSectionCount) * state.duration; + const endTime = startTime + lastSectionCount * state.duration; + const segments = + createPathSegments(startTime, endTime, minSegmentDuration, + minProgressThreshold, segmentHelper); + appendPathElement(parentEl, segments, "iteration-path"); +} + +/** + * Render Infinity iterations. + * @param {Element} parentEl - Parent element of this appended path element. + * @param {Object} state - State of animation. + * @param {Number} mainIterationStartTime - Starting time of main iteration. + * @param {Number} firstSectionCount - Iteration count of first section. + * @param {Number} totalDuration - Displayed max duration. + * @param {Number} minSegmentDuration - Minimum segment duration. + * @param {Number} minProgressThreshold - Minimum progress threshold. + * @param {Object} segmentHelper - The object returned by getSegmentHelper. + */ +function renderInfinity(parentEl, state, mainIterationStartTime, + firstSectionCount, totalDuration, minSegmentDuration, + minProgressThreshold, segmentHelper) { + // Calculate the number of iterations to display, + // with a maximum of MAX_INFINITE_ANIMATIONS_ITERATIONS + let uncappedInfinityIterationCount = + (totalDuration - firstSectionCount * state.duration) / state.duration; + // If there is a small floating point error resulting in, e.g. 1.0000001 + // ceil will give us 2 so round first. + uncappedInfinityIterationCount = + parseFloat(uncappedInfinityIterationCount.toPrecision(6)); + const infinityIterationCount = + Math.min(MAX_INFINITE_ANIMATIONS_ITERATIONS, + Math.ceil(uncappedInfinityIterationCount)); + + // Append first full iteration path. + const firstStartTime = + mainIterationStartTime + firstSectionCount * state.duration; + const firstEndTime = firstStartTime + state.duration; + const firstSegments = + createPathSegments(firstStartTime, firstEndTime, minSegmentDuration, + minProgressThreshold, segmentHelper); + appendPathElement(parentEl, firstSegments, "iteration-path infinity"); + + // Append other iterations. We can copy first segments. + const isAlternate = state.direction.match(/alternate/); + for (let i = 1; i < infinityIterationCount; i++) { + const startTime = firstStartTime + i * state.duration; + let segments; + if (isAlternate && i % 2) { + // Copy as reverse. + segments = firstSegments.map(segment => { + return { x: firstEndTime - segment.x + startTime, y: segment.y }; + }); + } else { + // Copy as is. + segments = firstSegments.map(segment => { + return { x: segment.x - firstStartTime + startTime, y: segment.y }; + }); + } + appendPathElement(parentEl, segments, "iteration-path infinity copied"); + } +} + +/** + * Render endDelay section. + * @param {Element} parentEl - Parent element of this appended path element. + * @param {Object} state - State of animation. + * @param {Number} mainIterationStartTime - Starting time of main iteration. + * @param {Number} iterationCount - Whole iteration count. + * @param {Object} segmentHelper - The object returned by getSegmentHelper. + */ +function renderEndDelay(parentEl, state, + mainIterationStartTime, iterationCount, segmentHelper) { + const startTime = mainIterationStartTime + iterationCount * state.duration; + const startSegment = segmentHelper.getSegment(startTime); + const endSegment = { x: startTime + state.endDelay, y: startSegment.y }; + appendPathElement(parentEl, [startSegment, endSegment], "enddelay-path"); +} + +/** + * Render forwards fill section. + * @param {Element} parentEl - Parent element of this appended path element. + * @param {Object} state - State of animation. + * @param {Number} mainIterationStartTime - Starting time of main iteration. + * @param {Number} iterationCount - Whole iteration count. + * @param {Number} totalDuration - Displayed max duration. + * @param {Object} segmentHelper - The object returned by getSegmentHelper. + */ +function renderForwardsFill(parentEl, state, mainIterationStartTime, + iterationCount, totalDuration, segmentHelper) { + const startTime = mainIterationStartTime + iterationCount * state.duration + + (state.endDelay > 0 ? state.endDelay : 0); + const startSegment = segmentHelper.getSegment(startTime); + const endSegment = { x: totalDuration, y: startSegment.y }; + appendPathElement(parentEl, [startSegment, endSegment], "fill-forwards-path"); +} + +/** + * Render hidden progress of negative delay. + * @param {Element} parentEl - Parent element of this appended path element. + * @param {Object} state - State of animation. + * @param {Number} minSegmentDuration - Minimum segment duration. + * @param {Number} minProgressThreshold - Minimum progress threshold. + * @param {Object} segmentHelper - The object returned by getSegmentHelper. + */ +function renderNegativeDelayHiddenProgress(parentEl, state, minSegmentDuration, + minProgressThreshold, + segmentHelper) { + const startTime = state.delay; + const endTime = 0; + const segments = + createPathSegments(startTime, endTime, minSegmentDuration, + minProgressThreshold, segmentHelper); + appendPathElement(parentEl, segments, "delay-path negative"); +} + +/** + * Render hidden progress of negative endDelay. + * @param {Element} parentEl - Parent element of this appended path element. + * @param {Object} state - State of animation. + * @param {Number} minSegmentDuration - Minimum segment duration. + * @param {Number} minProgressThreshold - Minimum progress threshold. + * @param {Object} segmentHelper - The object returned by getSegmentHelper. + */ +function renderNegativeEndDelayHiddenProgress(parentEl, state, + minSegmentDuration, + minProgressThreshold, + segmentHelper) { + const endTime = state.delay + state.iterationCount * state.duration; + const startTime = endTime + state.endDelay; + const segments = + createPathSegments(startTime, endTime, minSegmentDuration, + minProgressThreshold, segmentHelper); + appendPathElement(parentEl, segments, "enddelay-path negative"); +} + +/** + * Get a helper function which returns the segment coord from given time. + * @param {Object} state - animation state + * @param {Object} win - window object + * @return {Object} A segmentHelper object that has the following properties: + * - animation: The script animation used to get the progress + * - endTime: The end time of the animation + * - asOriginalBehavior: The spec is that the progress of animation is changed + * if the time of setCurrentTime is during the endDelay. + * Likewise, in case the time is less than 0. + * If this flag is true, we prevent the time + * to make the same animation behavior as the original. + * - getSegment: Helper function that, given a time, + * will calculate the progress through the dummy animation. + */ +function getSegmentHelper(state, win) { + // Create a dummy Animation timing data as the + // state object we're being passed in. + const timing = Object.assign({}, state, { + iterations: state.iterationCount ? state.iterationCount : Infinity + }); + + // Create a dummy Animation with the given timing. + const dummyAnimation = + new win.Animation(new win.KeyframeEffect(null, null, timing), null); + + // Returns segment helper object. + return { + animation: dummyAnimation, + endTime: dummyAnimation.effect.getComputedTiming().endTime, + asOriginalBehavior: true, + getSegment: function (time) { + if (this.asOriginalBehavior) { + // If the given time is less than 0, returned progress is 0. + if (time < 0) { + return { x: time, y: 0 }; + } + // Avoid to apply over endTime. + this.animation.currentTime = time < this.endTime ? time : this.endTime; + } else { + this.animation.currentTime = time; + } + const progress = this.animation.effect.getComputedTiming().progress; + return { x: time, y: Math.max(progress, 0) }; + } + }; +} + +/** + * Create the path segments from given parameters. + * @param {Number} startTime - Starting time of animation. + * @param {Number} endTime - Ending time of animation. + * @param {Number} minSegmentDuration - Minimum segment duration. + * @param {Number} minProgressThreshold - Minimum progress threshold. + * @param {Object} segmentHelper - The object of getSegmentHelper. + * @return {Array} path segments - + * [{x: {Number} time, y: {Number} progress}, ...] + */ +function createPathSegments(startTime, endTime, minSegmentDuration, + minProgressThreshold, segmentHelper) { + // If the duration is too short, early return. + if (endTime - startTime < minSegmentDuration) { + return [segmentHelper.getSegment(startTime), + segmentHelper.getSegment(endTime)]; + } + + // Otherwise, start creating segments. + let pathSegments = []; + + // Append the segment for the startTime position. + const startTimeSegment = segmentHelper.getSegment(startTime); + pathSegments.push(startTimeSegment); + let previousSegment = startTimeSegment; + + // Split the duration in equal intervals, and iterate over them. + // See the definition of DURATION_RESOLUTION for more information about this. + const interval = (endTime - startTime) / DURATION_RESOLUTION; + for (let index = 1; index <= DURATION_RESOLUTION; index++) { + // Create a segment for this interval. + const currentSegment = + segmentHelper.getSegment(startTime + index * interval); + + // If the distance between the Y coordinate (the animation's progress) of + // the previous segment and the Y coordinate of the current segment is too + // large, then recurse with a smaller duration to get more details + // in the graph. + if (Math.abs(currentSegment.y - previousSegment.y) > minProgressThreshold) { + // Divide the current interval (excluding start and end bounds + // by adding/subtracting 1ms). + pathSegments = pathSegments.concat( + createPathSegments(previousSegment.x + 1, currentSegment.x - 1, + minSegmentDuration, minProgressThreshold, + segmentHelper)); + } + + pathSegments.push(currentSegment); + previousSegment = currentSegment; + } + + return pathSegments; +} + +/** + * Append path element. + * @param {Element} parentEl - Parent element of this appended path element. + * @param {Array} pathSegments - Path segments. Please see createPathSegments. + * @param {String} cls - Class name. + * @return {Element} path element. + */ +function appendPathElement(parentEl, pathSegments, cls) { + // Create path string. + let path = `M${ pathSegments[0].x },0`; + pathSegments.forEach(pathSegment => { + path += ` L${ pathSegment.x },${ pathSegment.y }`; + }); + path += ` L${ pathSegments[pathSegments.length - 1].x },0 Z`; + // Append and return the path element. + return createNode({ + parent: parentEl, + namespace: SVG_NS, + nodeType: "path", + attributes: { + "d": path, + "class": cls, + "vector-effect": "non-scaling-stroke", + "transform": "scale(1, -1)" + } + }); +} diff --git a/devtools/client/animationinspector/components/animation-timeline.js b/devtools/client/animationinspector/components/animation-timeline.js new file mode 100644 index 000000000..49995d729 --- /dev/null +++ b/devtools/client/animationinspector/components/animation-timeline.js @@ -0,0 +1,502 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("devtools/shared/event-emitter"); +const { + createNode, + findOptimalTimeInterval, + TimeScale +} = require("devtools/client/animationinspector/utils"); +const {AnimationDetails} = require("devtools/client/animationinspector/components/animation-details"); +const {AnimationTargetNode} = require("devtools/client/animationinspector/components/animation-target-node"); +const {AnimationTimeBlock} = require("devtools/client/animationinspector/components/animation-time-block"); + +// The minimum spacing between 2 time graduation headers in the timeline (px). +const TIME_GRADUATION_MIN_SPACING = 40; +// When the container window is resized, the timeline background gets refreshed, +// but only after a timer, and the timer is reset if the window is continuously +// resized. +const TIMELINE_BACKGROUND_RESIZE_DEBOUNCE_TIMER = 50; + +/** + * UI component responsible for displaying a timeline for animations. + * The timeline is essentially a graph with time along the x axis and animations + * along the y axis. + * The time is represented with a graduation header at the top and a current + * time play head. + * Animations are organized by lines, with a left margin containing the preview + * of the target DOM element the animation applies to. + * The current time play head can be moved by clicking/dragging in the header. + * when this happens, the component emits "current-data-changed" events with the + * new time and state of the timeline. + * + * @param {InspectorPanel} inspector. + * @param {Object} serverTraits The list of server-side capabilities. + */ +function AnimationsTimeline(inspector, serverTraits) { + this.animations = []; + this.targetNodes = []; + this.timeBlocks = []; + this.details = []; + this.inspector = inspector; + this.serverTraits = serverTraits; + + this.onAnimationStateChanged = this.onAnimationStateChanged.bind(this); + this.onScrubberMouseDown = this.onScrubberMouseDown.bind(this); + this.onScrubberMouseUp = this.onScrubberMouseUp.bind(this); + this.onScrubberMouseOut = this.onScrubberMouseOut.bind(this); + this.onScrubberMouseMove = this.onScrubberMouseMove.bind(this); + this.onAnimationSelected = this.onAnimationSelected.bind(this); + this.onWindowResize = this.onWindowResize.bind(this); + this.onFrameSelected = this.onFrameSelected.bind(this); + + EventEmitter.decorate(this); +} + +exports.AnimationsTimeline = AnimationsTimeline; + +AnimationsTimeline.prototype = { + init: function (containerEl) { + this.win = containerEl.ownerDocument.defaultView; + + this.rootWrapperEl = createNode({ + parent: containerEl, + attributes: { + "class": "animation-timeline" + } + }); + + let scrubberContainer = createNode({ + parent: this.rootWrapperEl, + attributes: {"class": "scrubber-wrapper"} + }); + + this.scrubberEl = createNode({ + parent: scrubberContainer, + attributes: { + "class": "scrubber" + } + }); + + this.scrubberHandleEl = createNode({ + parent: this.scrubberEl, + attributes: { + "class": "scrubber-handle" + } + }); + this.scrubberHandleEl.addEventListener("mousedown", + this.onScrubberMouseDown); + + this.headerWrapper = createNode({ + parent: this.rootWrapperEl, + attributes: { + "class": "header-wrapper" + } + }); + + this.timeHeaderEl = createNode({ + parent: this.headerWrapper, + attributes: { + "class": "time-header track-container" + } + }); + + this.timeHeaderEl.addEventListener("mousedown", + this.onScrubberMouseDown); + + this.timeTickEl = createNode({ + parent: this.rootWrapperEl, + attributes: { + "class": "time-body track-container" + } + }); + + this.animationsEl = createNode({ + parent: this.rootWrapperEl, + nodeType: "ul", + attributes: { + "class": "animations" + } + }); + + this.win.addEventListener("resize", + this.onWindowResize); + }, + + destroy: function () { + this.stopAnimatingScrubber(); + this.unrender(); + + this.win.removeEventListener("resize", + this.onWindowResize); + this.timeHeaderEl.removeEventListener("mousedown", + this.onScrubberMouseDown); + this.scrubberHandleEl.removeEventListener("mousedown", + this.onScrubberMouseDown); + + this.rootWrapperEl.remove(); + this.animations = []; + + this.rootWrapperEl = null; + this.timeHeaderEl = null; + this.animationsEl = null; + this.scrubberEl = null; + this.scrubberHandleEl = null; + this.win = null; + this.inspector = null; + this.serverTraits = null; + }, + + /** + * Destroy sub-components that have been created and stored on this instance. + * @param {String} name An array of components will be expected in this[name] + * @param {Array} handlers An option list of event handlers information that + * should be used to remove these handlers. + */ + destroySubComponents: function (name, handlers = []) { + for (let component of this[name]) { + for (let {event, fn} of handlers) { + component.off(event, fn); + } + component.destroy(); + } + this[name] = []; + }, + + unrender: function () { + for (let animation of this.animations) { + animation.off("changed", this.onAnimationStateChanged); + } + this.stopAnimatingScrubber(); + TimeScale.reset(); + this.destroySubComponents("targetNodes"); + this.destroySubComponents("timeBlocks"); + this.destroySubComponents("details", [{ + event: "frame-selected", + fn: this.onFrameSelected + }]); + this.animationsEl.innerHTML = ""; + }, + + onWindowResize: function () { + // Don't do anything if the root element has a width of 0 + if (this.rootWrapperEl.offsetWidth === 0) { + return; + } + + if (this.windowResizeTimer) { + this.win.clearTimeout(this.windowResizeTimer); + } + + this.windowResizeTimer = this.win.setTimeout(() => { + this.drawHeaderAndBackground(); + }, TIMELINE_BACKGROUND_RESIZE_DEBOUNCE_TIMER); + }, + + onAnimationSelected: function (e, animation) { + let index = this.animations.indexOf(animation); + if (index === -1) { + return; + } + + let el = this.rootWrapperEl; + let animationEl = el.querySelectorAll(".animation")[index]; + let propsEl = el.querySelectorAll(".animated-properties")[index]; + + // Toggle the selected state on this animation. + animationEl.classList.toggle("selected"); + propsEl.classList.toggle("selected"); + + // Render the details component for this animation if it was shown. + if (animationEl.classList.contains("selected")) { + this.details[index].render(animation); + this.emit("animation-selected", animation); + } else { + this.emit("animation-unselected", animation); + } + }, + + /** + * When a frame gets selected, move the scrubber to the corresponding position + */ + onFrameSelected: function (e, {x}) { + this.moveScrubberTo(x, true); + }, + + onScrubberMouseDown: function (e) { + this.moveScrubberTo(e.pageX); + this.win.addEventListener("mouseup", this.onScrubberMouseUp); + this.win.addEventListener("mouseout", this.onScrubberMouseOut); + this.win.addEventListener("mousemove", this.onScrubberMouseMove); + + // Prevent text selection while dragging. + e.preventDefault(); + }, + + onScrubberMouseUp: function () { + this.cancelTimeHeaderDragging(); + }, + + onScrubberMouseOut: function (e) { + // Check that mouseout happened on the window itself, and if yes, cancel + // the dragging. + if (!this.win.document.contains(e.relatedTarget)) { + this.cancelTimeHeaderDragging(); + } + }, + + cancelTimeHeaderDragging: function () { + this.win.removeEventListener("mouseup", this.onScrubberMouseUp); + this.win.removeEventListener("mouseout", this.onScrubberMouseOut); + this.win.removeEventListener("mousemove", this.onScrubberMouseMove); + }, + + onScrubberMouseMove: function (e) { + this.moveScrubberTo(e.pageX); + }, + + moveScrubberTo: function (pageX, noOffset) { + this.stopAnimatingScrubber(); + + // The offset needs to be in % and relative to the timeline's area (so we + // subtract the scrubber's left offset, which is equal to the sidebar's + // width). + let offset = pageX; + if (!noOffset) { + offset -= this.timeHeaderEl.offsetLeft; + } + offset = offset * 100 / this.timeHeaderEl.offsetWidth; + if (offset < 0) { + offset = 0; + } + + this.scrubberEl.style.left = offset + "%"; + + let time = TimeScale.distanceToRelativeTime(offset); + + this.emit("timeline-data-changed", { + isPaused: true, + isMoving: false, + isUserDrag: true, + time: time + }); + }, + + getCompositorStatusClassName: function (state) { + let className = state.isRunningOnCompositor + ? " fast-track" + : ""; + + if (state.isRunningOnCompositor && state.propertyState) { + className += + state.propertyState.some(propState => !propState.runningOnCompositor) + ? " some-properties" + : " all-properties"; + } + + return className; + }, + + render: function (animations, documentCurrentTime) { + this.unrender(); + + this.animations = animations; + if (!this.animations.length) { + return; + } + + // Loop first to set the time scale for all current animations. + for (let {state} of animations) { + TimeScale.addAnimation(state); + } + + this.drawHeaderAndBackground(); + + for (let animation of this.animations) { + animation.on("changed", this.onAnimationStateChanged); + // Each line contains the target animated node and the animation time + // block. + let animationEl = createNode({ + parent: this.animationsEl, + nodeType: "li", + attributes: { + "class": "animation " + + animation.state.type + + this.getCompositorStatusClassName(animation.state) + } + }); + + // Right below the line is a hidden-by-default line for displaying the + // inline keyframes. + let detailsEl = createNode({ + parent: this.animationsEl, + nodeType: "li", + attributes: { + "class": "animated-properties " + animation.state.type + } + }); + + let details = new AnimationDetails(this.serverTraits); + details.init(detailsEl); + details.on("frame-selected", this.onFrameSelected); + this.details.push(details); + + // Left sidebar for the animated node. + let animatedNodeEl = createNode({ + parent: animationEl, + attributes: { + "class": "target" + } + }); + + // Draw the animated node target. + let targetNode = new AnimationTargetNode(this.inspector, {compact: true}); + targetNode.init(animatedNodeEl); + targetNode.render(animation); + this.targetNodes.push(targetNode); + + // Right-hand part contains the timeline itself (called time-block here). + let timeBlockEl = createNode({ + parent: animationEl, + attributes: { + "class": "time-block track-container" + } + }); + + // Draw the animation time block. + let timeBlock = new AnimationTimeBlock(); + timeBlock.init(timeBlockEl); + timeBlock.render(animation); + this.timeBlocks.push(timeBlock); + + timeBlock.on("selected", this.onAnimationSelected); + } + + // Use the document's current time to position the scrubber (if the server + // doesn't provide it, hide the scrubber entirely). + // Note that because the currentTime was sent via the protocol, some time + // may have gone by since then, and so the scrubber might be a bit late. + if (!documentCurrentTime) { + this.scrubberEl.style.display = "none"; + } else { + this.scrubberEl.style.display = "block"; + this.startAnimatingScrubber(this.wasRewound() + ? TimeScale.minStartTime + : documentCurrentTime); + } + }, + + isAtLeastOneAnimationPlaying: function () { + return this.animations.some(({state}) => state.playState === "running"); + }, + + wasRewound: function () { + return !this.isAtLeastOneAnimationPlaying() && + this.animations.every(({state}) => state.currentTime === 0); + }, + + hasInfiniteAnimations: function () { + return this.animations.some(({state}) => !state.iterationCount); + }, + + startAnimatingScrubber: function (time) { + let isOutOfBounds = time < TimeScale.minStartTime || + time > TimeScale.maxEndTime; + let isAllPaused = !this.isAtLeastOneAnimationPlaying(); + let hasInfinite = this.hasInfiniteAnimations(); + + let x = TimeScale.startTimeToDistance(time); + if (x > 100 && !hasInfinite) { + x = 100; + } + this.scrubberEl.style.left = x + "%"; + + // Only stop the scrubber if it's out of bounds or all animations have been + // paused, but not if at least an animation is infinite. + if (isAllPaused || (isOutOfBounds && !hasInfinite)) { + this.stopAnimatingScrubber(); + this.emit("timeline-data-changed", { + isPaused: !this.isAtLeastOneAnimationPlaying(), + isMoving: false, + isUserDrag: false, + time: TimeScale.distanceToRelativeTime(x) + }); + return; + } + + this.emit("timeline-data-changed", { + isPaused: false, + isMoving: true, + isUserDrag: false, + time: TimeScale.distanceToRelativeTime(x) + }); + + let now = this.win.performance.now(); + this.rafID = this.win.requestAnimationFrame(() => { + if (!this.rafID) { + // In case the scrubber was stopped in the meantime. + return; + } + this.startAnimatingScrubber(time + this.win.performance.now() - now); + }); + }, + + stopAnimatingScrubber: function () { + if (this.rafID) { + this.win.cancelAnimationFrame(this.rafID); + this.rafID = null; + } + }, + + onAnimationStateChanged: function () { + // For now, simply re-render the component. The animation front's state has + // already been updated. + this.render(this.animations); + }, + + drawHeaderAndBackground: function () { + let width = this.timeHeaderEl.offsetWidth; + let animationDuration = TimeScale.maxEndTime - TimeScale.minStartTime; + let minTimeInterval = TIME_GRADUATION_MIN_SPACING * + animationDuration / width; + let intervalLength = findOptimalTimeInterval(minTimeInterval); + let intervalWidth = intervalLength * width / animationDuration; + + // And the time graduation header. + this.timeHeaderEl.innerHTML = ""; + this.timeTickEl.innerHTML = ""; + + for (let i = 0; i <= width / intervalWidth; i++) { + let pos = 100 * i * intervalWidth / width; + + // This element is the header of time tick for displaying animation + // duration time. + createNode({ + parent: this.timeHeaderEl, + nodeType: "span", + attributes: { + "class": "header-item", + "style": `left:${pos}%` + }, + textContent: TimeScale.formatTime(TimeScale.distanceToRelativeTime(pos)) + }); + + // This element is displayed as a vertical line separator corresponding + // the header of time tick for indicating time slice for animation + // iterations. + createNode({ + parent: this.timeTickEl, + nodeType: "span", + attributes: { + "class": "time-tick", + "style": `left:${pos}%` + } + }); + } + } +}; diff --git a/devtools/client/animationinspector/components/keyframes.js b/devtools/client/animationinspector/components/keyframes.js new file mode 100644 index 000000000..a017935a3 --- /dev/null +++ b/devtools/client/animationinspector/components/keyframes.js @@ -0,0 +1,81 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("devtools/shared/event-emitter"); +const {createNode} = require("devtools/client/animationinspector/utils"); + +/** + * UI component responsible for displaying a list of keyframes. + */ +function Keyframes() { + EventEmitter.decorate(this); + this.onClick = this.onClick.bind(this); +} + +exports.Keyframes = Keyframes; + +Keyframes.prototype = { + init: function (containerEl) { + this.containerEl = containerEl; + + this.keyframesEl = createNode({ + parent: this.containerEl, + attributes: {"class": "keyframes"} + }); + + this.containerEl.addEventListener("click", this.onClick); + }, + + destroy: function () { + this.containerEl.removeEventListener("click", this.onClick); + this.keyframesEl.remove(); + this.containerEl = this.keyframesEl = this.animation = null; + }, + + render: function ({keyframes, propertyName, animation}) { + this.keyframes = keyframes; + this.propertyName = propertyName; + this.animation = animation; + + let iterationStartOffset = + animation.state.iterationStart % 1 == 0 + ? 0 + : 1 - animation.state.iterationStart % 1; + + this.keyframesEl.classList.add(animation.state.type); + for (let frame of this.keyframes) { + let offset = frame.offset + iterationStartOffset; + createNode({ + parent: this.keyframesEl, + attributes: { + "class": "frame", + "style": `left:${offset * 100}%;`, + "data-offset": frame.offset, + "data-property": propertyName, + "title": frame.value + } + }); + } + }, + + onClick: function (e) { + // If the click happened on a frame, tell our parent about it. + if (!e.target.classList.contains("frame")) { + return; + } + + e.stopPropagation(); + this.emit("frame-selected", { + animation: this.animation, + propertyName: this.propertyName, + offset: parseFloat(e.target.dataset.offset), + value: e.target.getAttribute("title"), + x: e.target.offsetLeft + e.target.closest(".frames").offsetLeft + }); + } +}; diff --git a/devtools/client/animationinspector/components/moz.build b/devtools/client/animationinspector/components/moz.build new file mode 100644 index 000000000..2265f8c28 --- /dev/null +++ b/devtools/client/animationinspector/components/moz.build @@ -0,0 +1,12 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + 'animation-details.js', + 'animation-target-node.js', + 'animation-time-block.js', + 'animation-timeline.js', + 'keyframes.js', + 'rate-selector.js' +) diff --git a/devtools/client/animationinspector/components/rate-selector.js b/devtools/client/animationinspector/components/rate-selector.js new file mode 100644 index 000000000..e46664e6a --- /dev/null +++ b/devtools/client/animationinspector/components/rate-selector.js @@ -0,0 +1,105 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("devtools/shared/event-emitter"); +const {createNode} = require("devtools/client/animationinspector/utils"); +const { LocalizationHelper } = require("devtools/shared/l10n"); +const L10N = + new LocalizationHelper("devtools/client/locales/animationinspector.properties"); + +// List of playback rate presets displayed in the timeline toolbar. +const PLAYBACK_RATES = [.1, .25, .5, 1, 2, 5, 10]; + +/** + * UI component responsible for displaying a playback rate selector UI. + * The rendering logic is such that a predefined list of rates is generated. + * If *all* animations passed to render share the same rate, then that rate is + * selected in the <select> element, otherwise, the empty value is selected. + * If the rate that all animations share isn't part of the list of predefined + * rates, than that rate is added to the list. + */ +function RateSelector() { + this.onRateChanged = this.onRateChanged.bind(this); + EventEmitter.decorate(this); +} + +exports.RateSelector = RateSelector; + +RateSelector.prototype = { + init: function (containerEl) { + this.selectEl = createNode({ + parent: containerEl, + nodeType: "select", + attributes: { + "class": "devtools-button", + "title": L10N.getStr("timeline.rateSelectorTooltip") + } + }); + + this.selectEl.addEventListener("change", this.onRateChanged); + }, + + destroy: function () { + this.selectEl.removeEventListener("change", this.onRateChanged); + this.selectEl.remove(); + this.selectEl = null; + }, + + getAnimationsRates: function (animations) { + return sortedUnique(animations.map(a => a.state.playbackRate)); + }, + + getAllRates: function (animations) { + let animationsRates = this.getAnimationsRates(animations); + if (animationsRates.length > 1) { + return PLAYBACK_RATES; + } + + return sortedUnique(PLAYBACK_RATES.concat(animationsRates)); + }, + + render: function (animations) { + let allRates = this.getAnimationsRates(animations); + let hasOneRate = allRates.length === 1; + + this.selectEl.innerHTML = ""; + + if (!hasOneRate) { + // When the animations displayed have mixed playback rates, we can't + // select any of the predefined ones, instead, insert an empty rate. + createNode({ + parent: this.selectEl, + nodeType: "option", + attributes: {value: "", selector: "true"}, + textContent: "-" + }); + } + for (let rate of this.getAllRates(animations)) { + let option = createNode({ + parent: this.selectEl, + nodeType: "option", + attributes: {value: rate}, + textContent: L10N.getFormatStr("player.playbackRateLabel", rate) + }); + + // If there's only one rate and this is the option for it, select it. + if (hasOneRate && rate === allRates[0]) { + option.setAttribute("selected", "true"); + } + } + }, + + onRateChanged: function () { + let rate = parseFloat(this.selectEl.value); + if (!isNaN(rate)) { + this.emit("rate-changed", rate); + } + } +}; + +let sortedUnique = arr => [...new Set(arr)].sort((a, b) => a > b); diff --git a/devtools/client/animationinspector/moz.build b/devtools/client/animationinspector/moz.build new file mode 100644 index 000000000..60527da7d --- /dev/null +++ b/devtools/client/animationinspector/moz.build @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +BROWSER_CHROME_MANIFESTS += ['test/browser.ini'] +XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini'] + +DIRS += [ + 'components' +] + +DevToolsModules( + 'utils.js', +) diff --git a/devtools/client/animationinspector/test/.eslintrc.js b/devtools/client/animationinspector/test/.eslintrc.js new file mode 100644 index 000000000..8d15a76d9 --- /dev/null +++ b/devtools/client/animationinspector/test/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + "extends": "../../../.eslintrc.mochitests.js" +}; diff --git a/devtools/client/animationinspector/test/browser.ini b/devtools/client/animationinspector/test/browser.ini new file mode 100644 index 000000000..08bce344d --- /dev/null +++ b/devtools/client/animationinspector/test/browser.ini @@ -0,0 +1,71 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + doc_body_animation.html + doc_end_delay.html + doc_frame_script.js + doc_keyframes.html + doc_modify_playbackRate.html + doc_negative_animation.html + doc_pseudo_elements.html + doc_script_animation.html + doc_simple_animation.html + doc_multiple_animation_types.html + doc_timing_combination_animation.html + head.js + !/devtools/client/commandline/test/helpers.js + !/devtools/client/framework/test/shared-head.js + !/devtools/client/inspector/test/head.js + !/devtools/client/inspector/test/shared-head.js + !/devtools/client/shared/test/test-actor-registry.js + !/devtools/client/shared/test/test-actor.js + +[browser_animation_animated_properties_displayed.js] +[browser_animation_click_selects_animation.js] +[browser_animation_controller_exposes_document_currentTime.js] +skip-if = os == "linux" && !debug # Bug 1234567 +[browser_animation_empty_on_invalid_nodes.js] +[browser_animation_keyframe_click_to_set_time.js] +[browser_animation_keyframe_markers.js] +[browser_animation_mutations_with_same_names.js] +[browser_animation_panel_exists.js] +[browser_animation_participate_in_inspector_update.js] +[browser_animation_playerFronts_are_refreshed.js] +[browser_animation_playerWidgets_appear_on_panel_init.js] +[browser_animation_playerWidgets_target_nodes.js] +[browser_animation_pseudo_elements.js] +[browser_animation_refresh_on_added_animation.js] +[browser_animation_refresh_on_removed_animation.js] +skip-if = os == "linux" && !debug # Bug 1227792 +[browser_animation_refresh_when_active.js] +[browser_animation_running_on_compositor.js] +[browser_animation_same_nb_of_playerWidgets_and_playerFronts.js] +[browser_animation_shows_player_on_valid_node.js] +[browser_animation_spacebar_toggles_animations.js] +[browser_animation_spacebar_toggles_node_animations.js] +[browser_animation_target_highlight_select.js] +[browser_animation_target_highlighter_lock.js] +[browser_animation_timeline_currentTime.js] +[browser_animation_timeline_header.js] +[browser_animation_timeline_iterationStart.js] +[browser_animation_timeline_pause_button_01.js] +[browser_animation_timeline_pause_button_02.js] +[browser_animation_timeline_pause_button_03.js] +[browser_animation_timeline_rate_selector.js] +[browser_animation_timeline_rewind_button.js] +[browser_animation_timeline_scrubber_exists.js] +[browser_animation_timeline_scrubber_movable.js] +[browser_animation_timeline_scrubber_moves.js] +[browser_animation_timeline_setCurrentTime.js] +[browser_animation_timeline_shows_delay.js] +[browser_animation_timeline_shows_endDelay.js] +[browser_animation_timeline_shows_iterations.js] +[browser_animation_timeline_shows_name_label.js] +[browser_animation_timeline_shows_time_info.js] +[browser_animation_timeline_takes_rate_into_account.js] +[browser_animation_timeline_ui.js] +[browser_animation_toggle_button_resets_on_navigate.js] +[browser_animation_toggle_button_toggles_animations.js] +[browser_animation_toolbar_exists.js] +[browser_animation_ui_updates_when_animation_data_changes.js] diff --git a/devtools/client/animationinspector/test/browser_animation_animated_properties_displayed.js b/devtools/client/animationinspector/test/browser_animation_animated_properties_displayed.js new file mode 100644 index 000000000..214a33bd4 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_animated_properties_displayed.js @@ -0,0 +1,91 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const LAYOUT_ERRORS_L10N = + new LocalizationHelper("toolkit/locales/layout_errors.properties"); + +// Test that when an animation is selected, its list of animated properties is +// displayed below it. + +const EXPECTED_PROPERTIES = [ + "background-attachment", + "background-clip", + "background-color", + "background-image", + "background-origin", + "background-position-x", + "background-position-y", + "background-repeat", + "background-size", + "border-bottom-left-radius", + "border-bottom-right-radius", + "border-top-left-radius", + "border-top-right-radius", + "filter", + "height", + "transform", + "width" +].sort(); + +add_task(function* () { + yield addTab(URL_ROOT + "doc_keyframes.html"); + let {panel} = yield openAnimationInspector(); + let timeline = panel.animationsTimelineComponent; + let propertiesList = timeline.rootWrapperEl + .querySelector(".animated-properties"); + + ok(!isNodeVisible(propertiesList), + "The list of properties panel is hidden by default"); + + info("Click to select the animation"); + yield clickOnAnimation(panel, 0); + + ok(isNodeVisible(propertiesList), + "The list of properties panel is shown"); + ok(propertiesList.querySelectorAll(".property").length, + "The list of properties panel actually contains properties"); + ok(hasExpectedProperties(propertiesList), + "The list of properties panel contains the right properties"); + + ok(hasExpectedWarnings(propertiesList), + "The list of properties panel contains the right warnings"); + + info("Click to unselect the animation"); + yield clickOnAnimation(panel, 0, true); + + ok(!isNodeVisible(propertiesList), + "The list of properties panel is hidden again"); +}); + +function hasExpectedProperties(containerEl) { + let names = [...containerEl.querySelectorAll(".property .name")] + .map(n => n.textContent) + .sort(); + + if (names.length !== EXPECTED_PROPERTIES.length) { + return false; + } + + for (let i = 0; i < names.length; i++) { + if (names[i] !== EXPECTED_PROPERTIES[i]) { + return false; + } + } + + return true; +} + +function hasExpectedWarnings(containerEl) { + let warnings = [...containerEl.querySelectorAll(".warning")]; + for (let warning of warnings) { + let warningID = + "CompositorAnimationWarningTransformWithGeometricProperties"; + if (warning.getAttribute("title") == LAYOUT_ERRORS_L10N.getStr(warningID)) { + return true; + } + } + return false; +} diff --git a/devtools/client/animationinspector/test/browser_animation_click_selects_animation.js b/devtools/client/animationinspector/test/browser_animation_click_selects_animation.js new file mode 100644 index 000000000..d6d393d5a --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_click_selects_animation.js @@ -0,0 +1,44 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that animations displayed in the timeline can be selected by clicking +// them, and that this emits the right events and adds the right classes. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {panel} = yield openAnimationInspector(); + let timeline = panel.animationsTimelineComponent; + + let selected = timeline.rootWrapperEl.querySelectorAll(".animation.selected"); + ok(!selected.length, "There are no animations selected by default"); + + info("Click on the first animation, expect the right event and right class"); + let animation0 = yield clickOnAnimation(panel, 0); + is(animation0, timeline.animations[0], + "The selected event was emitted with the right animation"); + ok(isTimeBlockSelected(timeline, 0), + "The time block has the right selected class"); + + info("Click on the second animation, expect it to be selected too"); + let animation1 = yield clickOnAnimation(panel, 1); + is(animation1, timeline.animations[1], + "The selected event was emitted with the right animation"); + ok(isTimeBlockSelected(timeline, 1), + "The second time block has the right selected class"); + + info("Click again on the first animation and check if it unselects"); + yield clickOnAnimation(panel, 0, true); + ok(!isTimeBlockSelected(timeline, 0), + "The first time block has been unselected"); +}); + +function isTimeBlockSelected(timeline, index) { + let animation = timeline.rootWrapperEl.querySelectorAll(".animation")[index]; + let animatedProperties = timeline.rootWrapperEl.querySelectorAll( + ".animated-properties")[index]; + return animation.classList.contains("selected") && + animatedProperties.classList.contains("selected"); +} diff --git a/devtools/client/animationinspector/test/browser_animation_controller_exposes_document_currentTime.js b/devtools/client/animationinspector/test/browser_animation_controller_exposes_document_currentTime.js new file mode 100644 index 000000000..ae970a426 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_controller_exposes_document_currentTime.js @@ -0,0 +1,43 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the controller provides the document.timeline currentTime (at least +// the last known version since new animations were added). + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {panel, controller} = yield openAnimationInspector(); + + ok(controller.documentCurrentTime, "The documentCurrentTime getter exists"); + checkDocumentTimeIsCorrect(controller); + let time1 = controller.documentCurrentTime; + + yield startNewAnimation(controller, panel); + checkDocumentTimeIsCorrect(controller); + let time2 = controller.documentCurrentTime; + ok(time2 > time1, "The new documentCurrentTime is higher than the old one"); +}); + +function checkDocumentTimeIsCorrect(controller) { + let time = 0; + for (let {state} of controller.animationPlayers) { + time = Math.max(time, state.documentCurrentTime); + } + is(controller.documentCurrentTime, time, + "The documentCurrentTime is correct"); +} + +function* startNewAnimation(controller, panel) { + info("Add a new animation to the page and check the time again"); + let onPlayerAdded = controller.once(controller.PLAYERS_UPDATED_EVENT); + yield executeInContent("devtools:test:setAttribute", { + selector: ".still", + attributeName: "class", + attributeValue: "ball still short" + }); + yield onPlayerAdded; + yield waitForAllAnimationTargets(panel); +} diff --git a/devtools/client/animationinspector/test/browser_animation_empty_on_invalid_nodes.js b/devtools/client/animationinspector/test/browser_animation_empty_on_invalid_nodes.js new file mode 100644 index 000000000..9fda89a9a --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_empty_on_invalid_nodes.js @@ -0,0 +1,42 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +requestLongerTimeout(2); + +// Test that the panel shows no animation data for invalid or not animated nodes + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {inspector, panel, window} = yield openAnimationInspector(); + let {document} = window; + + info("Select node .still and check that the panel is empty"); + let stillNode = yield getNodeFront(".still", inspector); + let onUpdated = panel.once(panel.UI_UPDATED_EVENT); + yield selectNodeAndWaitForAnimations(stillNode, inspector); + yield onUpdated; + + is(panel.animationsTimelineComponent.animations.length, 0, + "No animation players stored in the timeline component for a still node"); + is(panel.animationsTimelineComponent.animationsEl.childNodes.length, 0, + "No animation displayed in the timeline component for a still node"); + is(document.querySelector("#error-type").textContent, + ANIMATION_L10N.getStr("panel.invalidElementSelected"), + "The correct error message is displayed"); + + info("Select the comment text node and check that the panel is empty"); + let commentNode = yield inspector.walker.previousSibling(stillNode); + onUpdated = panel.once(panel.UI_UPDATED_EVENT); + yield selectNodeAndWaitForAnimations(commentNode, inspector); + yield onUpdated; + + is(panel.animationsTimelineComponent.animations.length, 0, + "No animation players stored in the timeline component for a text node"); + is(panel.animationsTimelineComponent.animationsEl.childNodes.length, 0, + "No animation displayed in the timeline component for a text node"); + is(document.querySelector("#error-type").textContent, + ANIMATION_L10N.getStr("panel.invalidElementSelected"), + "The correct error message is displayed"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_keyframe_click_to_set_time.js b/devtools/client/animationinspector/test/browser_animation_keyframe_click_to_set_time.js new file mode 100644 index 000000000..ba700b7a5 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_keyframe_click_to_set_time.js @@ -0,0 +1,52 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that animated properties' keyframes can be clicked, and that doing so +// sets the current time in the timeline. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_keyframes.html"); + let {panel} = yield openAnimationInspector(); + let timeline = panel.animationsTimelineComponent; + let {scrubberEl} = timeline; + + // XXX: The scrollbar is placed in the timeline in such a way that it causes + // the animations to be slightly offset with the header when it appears. + // So for now, let's hide the scrollbar. Bug 1229340 should fix this. + timeline.animationsEl.style.overflow = "hidden"; + + info("Expand the animation"); + yield clickOnAnimation(panel, 0); + + info("Click on the first keyframe of the first animated property"); + yield clickKeyframe(panel, 0, "background-color", 0); + + info("Make sure the scrubber stopped moving and is at the right position"); + yield assertScrubberMoving(panel, false); + checkScrubberPos(scrubberEl, 0); + + info("Click on a keyframe in the middle"); + yield clickKeyframe(panel, 0, "transform", 2); + + info("Make sure the scrubber is at the right position"); + checkScrubberPos(scrubberEl, 50); +}); + +function* clickKeyframe(panel, animIndex, property, index) { + let keyframeComponent = getKeyframeComponent(panel, animIndex, property); + let keyframeEl = getKeyframeEl(panel, animIndex, property, index); + + let onSelect = keyframeComponent.once("frame-selected"); + EventUtils.sendMouseEvent({type: "click"}, keyframeEl, + keyframeEl.ownerDocument.defaultView); + yield onSelect; +} + +function checkScrubberPos(scrubberEl, pos) { + let newPos = Math.round(parseFloat(scrubberEl.style.left)); + let expectedPos = Math.round(pos); + is(newPos, expectedPos, `The scrubber is at ${pos}%`); +} diff --git a/devtools/client/animationinspector/test/browser_animation_keyframe_markers.js b/devtools/client/animationinspector/test/browser_animation_keyframe_markers.js new file mode 100644 index 000000000..789c0efb6 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_keyframe_markers.js @@ -0,0 +1,74 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that when an animation is selected and its list of properties is shown, +// there are keyframes markers next to each property being animated. + +const EXPECTED_PROPERTIES = [ + "backgroundColor", + "backgroundPosition", + "backgroundSize", + "borderBottomLeftRadius", + "borderBottomRightRadius", + "borderTopLeftRadius", + "borderTopRightRadius", + "filter", + "height", + "transform", + "width" +]; + +add_task(function* () { + yield addTab(URL_ROOT + "doc_keyframes.html"); + let {panel} = yield openAnimationInspector(); + let timeline = panel.animationsTimelineComponent; + + info("Expand the animation"); + yield clickOnAnimation(panel, 0); + + ok(timeline.rootWrapperEl.querySelectorAll(".frames .keyframes").length, + "There are container elements for displaying keyframes"); + + let data = yield getExpectedKeyframesData(timeline.animations[0]); + for (let propertyName in data) { + info("Check the keyframe markers for " + propertyName); + let widthMarkerSelector = ".frame[data-property=" + propertyName + "]"; + let markers = timeline.rootWrapperEl.querySelectorAll(widthMarkerSelector); + + is(markers.length, data[propertyName].length, + "The right number of keyframes was found for " + propertyName); + + let offsets = [...markers].map(m => parseFloat(m.dataset.offset)); + let values = [...markers].map(m => m.dataset.value); + for (let i = 0; i < markers.length; i++) { + is(markers[i].dataset.offset, offsets[i], + "Marker " + i + " for " + propertyName + " has the right offset"); + is(markers[i].dataset.value, values[i], + "Marker " + i + " for " + propertyName + " has the right value"); + } + } +}); + +function* getExpectedKeyframesData(animation) { + // We're testing the UI state here, so it's fine to get the list of expected + // properties from the animation actor. + let properties = yield animation.getProperties(); + let data = {}; + + for (let expectedProperty of EXPECTED_PROPERTIES) { + data[expectedProperty] = []; + for (let {name, values} of properties) { + if (name !== expectedProperty) { + continue; + } + for (let {offset, value} of values) { + data[expectedProperty].push({offset, value}); + } + } + } + + return data; +} diff --git a/devtools/client/animationinspector/test/browser_animation_mutations_with_same_names.js b/devtools/client/animationinspector/test/browser_animation_mutations_with_same_names.js new file mode 100644 index 000000000..1ae19c277 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_mutations_with_same_names.js @@ -0,0 +1,31 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that when animations are added later (through animation mutations) and +// if these animations have the same names, then all of them are still being +// displayed (which should be true as long as these animations apply to +// different nodes). + +add_task(function* () { + yield addTab(URL_ROOT + "doc_negative_animation.html"); + let {controller, panel} = yield openAnimationInspector(); + + info("Wait until all animations have been added " + + "(they're added with setTimeout)"); + while (controller.animationPlayers.length < 3) { + yield controller.once(controller.PLAYERS_UPDATED_EVENT); + } + yield waitForAllAnimationTargets(panel); + + is(panel.animationsTimelineComponent.animations.length, 3, + "The timeline shows 3 animations too"); + + // Reduce the known nodeFronts to a set to make them unique. + let nodeFronts = new Set(panel.animationsTimelineComponent + .targetNodes.map(n => n.previewer.nodeFront)); + is(nodeFronts.size, 3, + "The animations are applied to 3 different node fronts"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_panel_exists.js b/devtools/client/animationinspector/test/browser_animation_panel_exists.js new file mode 100644 index 000000000..1f12605a5 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_panel_exists.js @@ -0,0 +1,23 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the animation panel sidebar exists + +add_task(function* () { + yield addTab("data:text/html;charset=utf-8,welcome to the animation panel"); + let {panel, controller} = yield openAnimationInspector(); + + ok(controller, + "The animation controller exists"); + ok(controller.animationsFront, + "The animation controller has been initialized"); + ok(panel, + "The animation panel exists"); + ok(panel.playersEl, + "The animation panel has been initialized"); + ok(panel.animationsTimelineComponent, + "The animation panel has been initialized"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_participate_in_inspector_update.js b/devtools/client/animationinspector/test/browser_animation_participate_in_inspector_update.js new file mode 100644 index 000000000..fec529568 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_participate_in_inspector_update.js @@ -0,0 +1,46 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Test that the update of the animation panel participate in the +// inspector-updated event. This means that the test verifies that the +// inspector-updated event is emitted *after* the animation panel is ready. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {inspector, panel, controller} = yield openAnimationInspector(); + + info("Listen for the players-updated, ui-updated and " + + "inspector-updated events"); + let receivedEvents = []; + controller.once(controller.PLAYERS_UPDATED_EVENT, () => { + receivedEvents.push(controller.PLAYERS_UPDATED_EVENT); + }); + panel.once(panel.UI_UPDATED_EVENT, () => { + receivedEvents.push(panel.UI_UPDATED_EVENT); + }); + inspector.once("inspector-updated", () => { + receivedEvents.push("inspector-updated"); + }); + + info("Selecting an animated node"); + let node = yield getNodeFront(".animated", inspector); + yield selectNodeAndWaitForAnimations(node, inspector); + + info("Check that all events were received"); + // Only assert that the inspector-updated event is last, the order of the + // first 2 events is irrelevant. + + is(receivedEvents.length, 3, "3 events were received"); + is(receivedEvents[2], "inspector-updated", + "The third event received was the inspector-updated event"); + + ok(receivedEvents.indexOf(controller.PLAYERS_UPDATED_EVENT) !== -1, + "The players-updated event was received"); + ok(receivedEvents.indexOf(panel.UI_UPDATED_EVENT) !== -1, + "The ui-updated event was received"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_playerFronts_are_refreshed.js b/devtools/client/animationinspector/test/browser_animation_playerFronts_are_refreshed.js new file mode 100644 index 000000000..7144adf6c --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_playerFronts_are_refreshed.js @@ -0,0 +1,36 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that the AnimationPlayerFront objects lifecycle is managed by the +// AnimationController. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {controller, inspector} = yield openAnimationInspector(); + + info("Selecting an animated node"); + // selectNode waits for the inspector-updated event before resolving, which + // means the controller.PLAYERS_UPDATED_EVENT event has been emitted before + // and players are ready. + yield selectNodeAndWaitForAnimations(".animated", inspector); + + is(controller.animationPlayers.length, 1, + "One AnimationPlayerFront has been created"); + + info("Selecting a node with mutliple animations"); + yield selectNodeAndWaitForAnimations(".multi", inspector); + + is(controller.animationPlayers.length, 2, + "2 AnimationPlayerFronts have been created"); + + info("Selecting a node with no animations"); + yield selectNodeAndWaitForAnimations(".still", inspector); + + is(controller.animationPlayers.length, 0, + "There are no more AnimationPlayerFront objects"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_playerWidgets_appear_on_panel_init.js b/devtools/client/animationinspector/test/browser_animation_playerWidgets_appear_on_panel_init.js new file mode 100644 index 000000000..271b26df3 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_playerWidgets_appear_on_panel_init.js @@ -0,0 +1,41 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that player widgets are displayed right when the animation panel is +// initialized, if the selected node (<body> by default) is animated. + +const { ANIMATION_TYPES } = require("devtools/server/actors/animation"); + +add_task(function* () { + yield addTab(URL_ROOT + "doc_multiple_animation_types.html"); + + let {panel} = yield openAnimationInspector(); + is(panel.animationsTimelineComponent.animations.length, 3, + "Three animations are handled by the timeline after init"); + assertAnimationsDisplayed(panel, 3, + "Three animations are displayed after init"); + is( + panel.animationsTimelineComponent + .animationsEl + .querySelectorAll(`.animation.${ANIMATION_TYPES.SCRIPT_ANIMATION}`) + .length, + 1, + "One script-generated animation is displayed"); + is( + panel.animationsTimelineComponent + .animationsEl + .querySelectorAll(`.animation.${ANIMATION_TYPES.CSS_ANIMATION}`) + .length, + 1, + "One CSS animation is displayed"); + is( + panel.animationsTimelineComponent + .animationsEl + .querySelectorAll(`.animation.${ANIMATION_TYPES.CSS_TRANSITION}`) + .length, + 1, + "One CSS transition is displayed"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_playerWidgets_target_nodes.js b/devtools/client/animationinspector/test/browser_animation_playerWidgets_target_nodes.js new file mode 100644 index 000000000..1fbaa7ae3 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_playerWidgets_target_nodes.js @@ -0,0 +1,33 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Test that player widgets display information about target nodes + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {inspector, panel} = yield openAnimationInspector(); + + info("Select the simple animated node"); + yield selectNodeAndWaitForAnimations(".animated", inspector); + + let targetNodeComponent = panel.animationsTimelineComponent.targetNodes[0]; + let {previewer} = targetNodeComponent; + + // Make sure to wait for the target-retrieved event if the nodeFront hasn't + // yet been retrieved by the TargetNodeComponent. + if (!previewer.nodeFront) { + yield targetNodeComponent.once("target-retrieved"); + } + + is(previewer.el.textContent, "div#.ball.animated", + "The target element's content is correct"); + + let highlighterEl = previewer.el.querySelector(".node-highlighter"); + ok(highlighterEl, + "The icon to highlight the target element in the page exists"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_pseudo_elements.js b/devtools/client/animationinspector/test/browser_animation_pseudo_elements.js new file mode 100644 index 000000000..38b2f10af --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_pseudo_elements.js @@ -0,0 +1,49 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that animated pseudo-elements do show in the timeline. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_pseudo_elements.html"); + let {inspector, panel} = yield openAnimationInspector(); + let timeline = panel.animationsTimelineComponent; + + info("With <body> selected by default check the content of the timeline"); + is(timeline.timeBlocks.length, 3, "There are 3 animations in the timeline"); + + let getTargetNodeText = index => { + let el = timeline.targetNodes[index].previewer.previewEl; + return [...el.childNodes] + .map(n => n.style.display === "none" ? "" : n.textContent) + .join(""); + }; + + is(getTargetNodeText(0), "body", "The first animated node is <body>"); + is(getTargetNodeText(1), "::before", "The second animated node is ::before"); + is(getTargetNodeText(2), "::after", "The third animated node is ::after"); + + info("Getting the before and after nodeFronts"); + let bodyContainer = yield getContainerForSelector("body", inspector); + let getBodyChildNodeFront = index => { + return bodyContainer.elt.children[1].childNodes[index].container.node; + }; + let beforeNode = getBodyChildNodeFront(0); + let afterNode = getBodyChildNodeFront(1); + + info("Select the ::before pseudo-element in the inspector"); + yield selectNode(beforeNode, inspector); + is(timeline.timeBlocks.length, 1, "There is 1 animation in the timeline"); + is(timeline.targetNodes[0].previewer.nodeFront, + inspector.selection.nodeFront, + "The right node front is displayed in the timeline"); + + info("Select the ::after pseudo-element in the inspector"); + yield selectNode(afterNode, inspector); + is(timeline.timeBlocks.length, 1, "There is 1 animation in the timeline"); + is(timeline.targetNodes[0].previewer.nodeFront, + inspector.selection.nodeFront, + "The right node front is displayed in the timeline"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_refresh_on_added_animation.js b/devtools/client/animationinspector/test/browser_animation_refresh_on_added_animation.js new file mode 100644 index 000000000..0bc652476 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_refresh_on_added_animation.js @@ -0,0 +1,47 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Test that the panel content refreshes when new animations are added. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {inspector, panel} = yield openAnimationInspector(); + + info("Select a non animated node"); + yield selectNodeAndWaitForAnimations(".still", inspector); + + assertAnimationsDisplayed(panel, 0); + + info("Start an animation on the node"); + yield changeElementAndWait({ + selector: ".still", + attributeName: "class", + attributeValue: "ball animated" + }, panel, inspector); + + assertAnimationsDisplayed(panel, 1); + + info("Remove the animation class on the node"); + yield changeElementAndWait({ + selector: ".ball.animated", + attributeName: "class", + attributeValue: "ball still" + }, panel, inspector); + + assertAnimationsDisplayed(panel, 0); +}); + +function* changeElementAndWait(options, panel, inspector) { + let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); + let onInspectorUpdated = inspector.once("inspector-updated"); + + yield executeInContent("devtools:test:setAttribute", options); + + yield promise.all([ + onInspectorUpdated, onPanelUpdated, waitForAllAnimationTargets(panel)]); +} diff --git a/devtools/client/animationinspector/test/browser_animation_refresh_on_removed_animation.js b/devtools/client/animationinspector/test/browser_animation_refresh_on_removed_animation.js new file mode 100644 index 000000000..011d4a086 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_refresh_on_removed_animation.js @@ -0,0 +1,50 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Test that the panel content refreshes when animations are removed. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + + let {inspector, panel} = yield openAnimationInspector(); + yield testRefreshOnRemove(inspector, panel); +}); + +function* testRefreshOnRemove(inspector, panel) { + info("Select a animated node"); + yield selectNodeAndWaitForAnimations(".animated", inspector); + + assertAnimationsDisplayed(panel, 1); + + info("Listen to the next UI update event"); + let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); + + info("Remove the animation on the node by removing the class"); + yield executeInContent("devtools:test:setAttribute", { + selector: ".animated", + attributeName: "class", + attributeValue: "ball still test-node" + }); + + yield onPanelUpdated; + ok(true, "The panel update event was fired"); + + assertAnimationsDisplayed(panel, 0); + + info("Add an finite animation on the node again, and wait for it to appear"); + onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); + yield executeInContent("devtools:test:setAttribute", { + selector: ".test-node", + attributeName: "class", + attributeValue: "ball short test-node" + }); + yield onPanelUpdated; + yield waitForAllAnimationTargets(panel); + + assertAnimationsDisplayed(panel, 1); +} diff --git a/devtools/client/animationinspector/test/browser_animation_refresh_when_active.js b/devtools/client/animationinspector/test/browser_animation_refresh_when_active.js new file mode 100644 index 000000000..6fb244b1e --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_refresh_when_active.js @@ -0,0 +1,53 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Test that the panel only refreshes when it is visible in the sidebar. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + + let {inspector, panel} = yield openAnimationInspector(); + yield testRefresh(inspector, panel); +}); + +function* testRefresh(inspector, panel) { + info("Select a non animated node"); + yield selectNodeAndWaitForAnimations(".still", inspector); + + info("Switch to the rule-view panel"); + inspector.sidebar.select("ruleview"); + + info("Select the animated node now"); + yield selectNodeAndWaitForAnimations(".animated", inspector); + + assertAnimationsDisplayed(panel, 0, + "The panel doesn't show the animation data while inactive"); + + info("Switch to the animation panel"); + inspector.sidebar.select("animationinspector"); + yield panel.once(panel.UI_UPDATED_EVENT); + + assertAnimationsDisplayed(panel, 1, + "The panel shows the animation data after selecting it"); + + info("Switch again to the rule-view"); + inspector.sidebar.select("ruleview"); + + info("Select the non animated node again"); + yield selectNodeAndWaitForAnimations(".still", inspector); + + assertAnimationsDisplayed(panel, 1, + "The panel still shows the previous animation data since it is inactive"); + + info("Switch to the animation panel again"); + inspector.sidebar.select("animationinspector"); + yield panel.once(panel.UI_UPDATED_EVENT); + + assertAnimationsDisplayed(panel, 0, + "The panel is now empty after refreshing"); +} diff --git a/devtools/client/animationinspector/test/browser_animation_running_on_compositor.js b/devtools/client/animationinspector/test/browser_animation_running_on_compositor.js new file mode 100644 index 000000000..b23479b6c --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_running_on_compositor.js @@ -0,0 +1,57 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Test that when animations displayed in the timeline are running on the +// compositor, they get a special icon and information in the tooltip. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {inspector, panel} = yield openAnimationInspector(); + let timeline = panel.animationsTimelineComponent; + + info("Select a test node we know has an animation running on the compositor"); + yield selectNodeAndWaitForAnimations(".animated", inspector); + + let animationEl = timeline.animationsEl.querySelector(".animation"); + ok(animationEl.classList.contains("fast-track"), + "The animation element has the fast-track css class"); + ok(hasTooltip(animationEl, + ANIMATION_L10N.getStr("player.allPropertiesOnCompositorTooltip")), + "The animation element has the right tooltip content"); + + info("Select a node we know doesn't have an animation on the compositor"); + yield selectNodeAndWaitForAnimations(".no-compositor", inspector); + + animationEl = timeline.animationsEl.querySelector(".animation"); + ok(!animationEl.classList.contains("fast-track"), + "The animation element does not have the fast-track css class"); + ok(!hasTooltip(animationEl, + ANIMATION_L10N.getStr("player.allPropertiesOnCompositorTooltip")), + "The animation element does not have oncompositor tooltip content"); + ok(!hasTooltip(animationEl, + ANIMATION_L10N.getStr("player.somePropertiesOnCompositorTooltip")), + "The animation element does not have oncompositor tooltip content"); + + info("Select a node we know has animation on the compositor and not on the" + + " compositor"); + yield selectNodeAndWaitForAnimations(".compositor-notall", inspector); + + animationEl = timeline.animationsEl.querySelector(".animation"); + ok(animationEl.classList.contains("fast-track"), + "The animation element has the fast-track css class"); + ok(hasTooltip(animationEl, + ANIMATION_L10N.getStr("player.somePropertiesOnCompositorTooltip")), + "The animation element has the right tooltip content"); +}); + +function hasTooltip(animationEl, expected) { + let el = animationEl.querySelector(".name"); + let tooltip = el.getAttribute("title"); + + return tooltip.indexOf(expected) !== -1; +} diff --git a/devtools/client/animationinspector/test/browser_animation_same_nb_of_playerWidgets_and_playerFronts.js b/devtools/client/animationinspector/test/browser_animation_same_nb_of_playerWidgets_and_playerFronts.js new file mode 100644 index 000000000..a3aa8974c --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_same_nb_of_playerWidgets_and_playerFronts.js @@ -0,0 +1,23 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that when playerFronts are updated, the same number of playerWidgets +// are created in the panel. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {inspector, panel, controller} = yield openAnimationInspector(); + let timeline = panel.animationsTimelineComponent; + + info("Selecting the test animated node again"); + yield selectNodeAndWaitForAnimations(".multi", inspector); + + is(controller.animationPlayers.length, + timeline.animationsEl.querySelectorAll(".animation").length, + "As many timeline elements were created as there are playerFronts"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_shows_player_on_valid_node.js b/devtools/client/animationinspector/test/browser_animation_shows_player_on_valid_node.js new file mode 100644 index 000000000..57e6a68fb --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_shows_player_on_valid_node.js @@ -0,0 +1,21 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Test that the panel shows an animation player when an animated node is +// selected. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {inspector, panel} = yield openAnimationInspector(); + + info("Select node .animated and check that the panel is not empty"); + let node = yield getNodeFront(".animated", inspector); + yield selectNodeAndWaitForAnimations(node, inspector); + + assertAnimationsDisplayed(panel, 1); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_spacebar_toggles_animations.js b/devtools/client/animationinspector/test/browser_animation_spacebar_toggles_animations.js new file mode 100644 index 000000000..799ecc28d --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_spacebar_toggles_animations.js @@ -0,0 +1,49 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(function* setup() { + yield SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount", 1]] + }); +}); + +// Test that the spacebar key press toggles the toggleAll button state +// when a node with no animation is selected. +// This test doesn't need to test if animations actually pause/resume +// because there's an other test that does this : +// browser_animation_toggle_button_toggles_animation.js + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {panel, inspector, window, controller} = yield openAnimationInspector(); + let {toggleAllButtonEl} = panel; + + // select a node without animations + yield selectNodeAndWaitForAnimations(".still", inspector); + + // ensure the focus is on the animation panel + window.focus(); + + info("Simulate spacebar stroke and check toggleAll button" + + " is in paused state"); + + // sending the key will lead to a ALL_ANIMATIONS_TOGGLED_EVENT + let onToggled = once(controller, controller.ALL_ANIMATIONS_TOGGLED_EVENT); + EventUtils.sendKey("SPACE", window); + yield onToggled; + ok(toggleAllButtonEl.classList.contains("paused"), + "The toggle all button is in its paused state"); + + info("Simulate spacebar stroke and check toggleAll button" + + " is in playing state"); + + // sending the key will lead to a ALL_ANIMATIONS_TOGGLED_EVENT + onToggled = once(controller, controller.ALL_ANIMATIONS_TOGGLED_EVENT); + EventUtils.sendKey("SPACE", window); + yield onToggled; + ok(!toggleAllButtonEl.classList.contains("paused"), + "The toggle all button is in its playing state again"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_spacebar_toggles_node_animations.js b/devtools/client/animationinspector/test/browser_animation_spacebar_toggles_node_animations.js new file mode 100644 index 000000000..634d4bc49 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_spacebar_toggles_node_animations.js @@ -0,0 +1,45 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the spacebar key press toggles the play/resume button state. +// This test doesn't need to test if animations actually pause/resume +// because there's an other test that does this. +// There are animations in the test page and since, by default, the <body> node +// is selected, animations will be displayed in the timeline, so the timeline +// play/resume button will be displayed + +requestLongerTimeout(2); + +add_task(function* () { + requestLongerTimeout(2); + + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {panel, window} = yield openAnimationInspector(); + let {playTimelineButtonEl} = panel; + + // ensure the focus is on the animation panel + window.focus(); + + info("Simulate spacebar stroke and check playResume button" + + " is in paused state"); + + // sending the key will lead to a UI_UPDATE_EVENT + let onUpdated = panel.once(panel.UI_UPDATED_EVENT); + EventUtils.sendKey("SPACE", window); + yield onUpdated; + ok(playTimelineButtonEl.classList.contains("paused"), + "The play/resume button is in its paused state"); + + info("Simulate spacebar stroke and check playResume button" + + " is in playing state"); + + // sending the key will lead to a UI_UPDATE_EVENT + onUpdated = panel.once(panel.UI_UPDATED_EVENT); + EventUtils.sendKey("SPACE", window); + yield onUpdated; + ok(!playTimelineButtonEl.classList.contains("paused"), + "The play/resume button is in its play state again"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_target_highlight_select.js b/devtools/client/animationinspector/test/browser_animation_target_highlight_select.js new file mode 100644 index 000000000..de14e6aca --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_target_highlight_select.js @@ -0,0 +1,73 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Test that the DOM element targets displayed in animation player widgets can +// be used to highlight elements in the DOM and select them in the inspector. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + + let {toolbox, inspector, panel} = yield openAnimationInspector(); + + info("Select the simple animated node"); + let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); + yield selectNodeAndWaitForAnimations(".animated", inspector); + yield onPanelUpdated; + + let targets = yield waitForAllAnimationTargets(panel); + // Arbitrary select the first one + let targetNodeComponent = targets[0]; + + info("Retrieve the part of the widget that highlights the node on hover"); + let highlightingEl = targetNodeComponent.previewer.previewEl; + + info("Listen to node-highlight event and mouse over the widget"); + let onHighlight = toolbox.once("node-highlight"); + EventUtils.synthesizeMouse(highlightingEl, 10, 5, {type: "mouseover"}, + highlightingEl.ownerDocument.defaultView); + let nodeFront = yield onHighlight; + + // Do not forget to mouseout, otherwise we get random mouseover event + // when selecting another node, which triggers some requests in animation + // inspector. + EventUtils.synthesizeMouse(highlightingEl, 10, 5, {type: "mouseout"}, + highlightingEl.ownerDocument.defaultView); + + ok(true, "The node-highlight event was fired"); + is(targetNodeComponent.previewer.nodeFront, nodeFront, + "The highlighted node is the one stored on the animation widget"); + is(nodeFront.tagName, "DIV", + "The highlighted node has the correct tagName"); + is(nodeFront.attributes[0].name, "class", + "The highlighted node has the correct attributes"); + is(nodeFront.attributes[0].value, "ball animated", + "The highlighted node has the correct class"); + + info("Select the body node in order to have the list of all animations"); + onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); + yield selectNodeAndWaitForAnimations("body", inspector); + yield onPanelUpdated; + + targets = yield waitForAllAnimationTargets(panel); + targetNodeComponent = targets[0]; + + info("Click on the first animated node component and wait for the " + + "selection to change"); + let onSelection = inspector.selection.once("new-node-front"); + onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); + let nodeEl = targetNodeComponent.previewer.previewEl; + EventUtils.sendMouseEvent({type: "click"}, nodeEl, + nodeEl.ownerDocument.defaultView); + yield onSelection; + + is(inspector.selection.nodeFront, targetNodeComponent.previewer.nodeFront, + "The selected node is the one stored on the animation widget"); + + yield onPanelUpdated; + yield waitForAllAnimationTargets(panel); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_target_highlighter_lock.js b/devtools/client/animationinspector/test/browser_animation_target_highlighter_lock.js new file mode 100644 index 000000000..b5e952679 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_target_highlighter_lock.js @@ -0,0 +1,54 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Test that the DOM element targets displayed in animation player widgets can +// be used to highlight elements in the DOM and select them in the inspector. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {panel} = yield openAnimationInspector(); + + let targets = panel.animationsTimelineComponent.targetNodes; + + info("Click on the highlighter icon for the first animated node"); + let domNodePreview1 = targets[0].previewer; + yield lockHighlighterOn(domNodePreview1); + ok(domNodePreview1.highlightNodeEl.classList.contains("selected"), + "The highlighter icon is selected"); + + info("Click on the highlighter icon for the second animated node"); + let domNodePreview2 = targets[1].previewer; + yield lockHighlighterOn(domNodePreview2); + ok(domNodePreview2.highlightNodeEl.classList.contains("selected"), + "The highlighter icon is selected"); + ok(!domNodePreview1.highlightNodeEl.classList.contains("selected"), + "The highlighter icon for the first node is unselected"); + + info("Click again to unhighlight"); + yield unlockHighlighterOn(domNodePreview2); + ok(!domNodePreview2.highlightNodeEl.classList.contains("selected"), + "The highlighter icon for the second node is unselected"); +}); + +function* lockHighlighterOn(domNodePreview) { + let onLocked = domNodePreview.once("target-highlighter-locked"); + clickOnHighlighterIcon(domNodePreview); + yield onLocked; +} + +function* unlockHighlighterOn(domNodePreview) { + let onUnlocked = domNodePreview.once("target-highlighter-unlocked"); + clickOnHighlighterIcon(domNodePreview); + yield onUnlocked; +} + +function clickOnHighlighterIcon(domNodePreview) { + let lockEl = domNodePreview.highlightNodeEl; + EventUtils.sendMouseEvent({type: "click"}, lockEl, + lockEl.ownerDocument.defaultView); +} diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_currentTime.js b/devtools/client/animationinspector/test/browser_animation_timeline_currentTime.js new file mode 100644 index 000000000..d5caaff28 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_currentTime.js @@ -0,0 +1,48 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that the timeline toolbar displays the current time, and that it +// changes when animations are playing, gets back to 0 when animations are +// rewound, and stops when animations are paused. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + + let {panel} = yield openAnimationInspector(); + let label = panel.timelineCurrentTimeEl; + ok(label, "The current time label exists"); + + // On page load animations are playing so the time shoud change, although we + // don't want to test the exact value of the time displayed, just that it + // actually changes. + info("Make sure the time displayed actually changes"); + yield isCurrentTimeLabelChanging(panel, true); + + info("Pause the animations and check that the time stops changing"); + yield clickTimelinePlayPauseButton(panel); + yield isCurrentTimeLabelChanging(panel, false); + + info("Rewind the animations and check that the time stops changing"); + yield clickTimelineRewindButton(panel); + yield isCurrentTimeLabelChanging(panel, false); + is(label.textContent, "00:00.000"); +}); + +function* isCurrentTimeLabelChanging(panel, isChanging) { + let label = panel.timelineCurrentTimeEl; + + let time1 = label.textContent; + yield new Promise(r => setTimeout(r, 200)); + let time2 = label.textContent; + + if (isChanging) { + ok(time1 !== time2, "The text displayed in the label changes with time"); + } else { + is(time1, time2, "The text displayed in the label doesn't change"); + } +} diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_header.js b/devtools/client/animationinspector/test/browser_animation_timeline_header.js new file mode 100644 index 000000000..3a0a0412a --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_header.js @@ -0,0 +1,59 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that the timeline shows correct time graduations in the header. + +const {findOptimalTimeInterval, TimeScale} = require("devtools/client/animationinspector/utils"); + +// Should be kept in sync with TIME_GRADUATION_MIN_SPACING in +// animation-timeline.js +const TIME_GRADUATION_MIN_SPACING = 40; + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + + // System scrollbar is enabled by default on our testing envionment and it + // would shrink width of inspector and affect number of time-ticks causing + // unexpected results. So, we set it wider to avoid this kind of edge case. + yield pushPref("devtools.toolsidebar-width.inspector", 350); + + let {panel} = yield openAnimationInspector(); + + let timeline = panel.animationsTimelineComponent; + let headerEl = timeline.timeHeaderEl; + + info("Find out how many time graduations should there be"); + let width = headerEl.offsetWidth; + + let animationDuration = TimeScale.maxEndTime - TimeScale.minStartTime; + let minTimeInterval = TIME_GRADUATION_MIN_SPACING * animationDuration / width; + + // Note that findOptimalTimeInterval is tested separately in xpcshell test + // test_findOptimalTimeInterval.js, so we assume that it works here. + let interval = findOptimalTimeInterval(minTimeInterval); + let nb = Math.ceil(animationDuration / interval); + + is(headerEl.querySelectorAll(".header-item").length, nb, + "The expected number of time ticks were found"); + + info("Make sure graduations are evenly distributed and show the right times"); + [...headerEl.querySelectorAll(".time-tick")].forEach((tick, i) => { + let left = parseFloat(tick.style.left); + let expectedPos = i * interval * 100 / animationDuration; + is(Math.round(left), Math.round(expectedPos), + `Graduation ${i} is positioned correctly`); + + // Note that the distancetoRelativeTime and formatTime functions are tested + // separately in xpcshell test test_timeScale.js, so we assume that they + // work here. + let formattedTime = TimeScale.formatTime( + TimeScale.distanceToRelativeTime(expectedPos, width)); + is(tick.textContent, formattedTime, + `Graduation ${i} has the right text content`); + }); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_iterationStart.js b/devtools/client/animationinspector/test/browser_animation_timeline_iterationStart.js new file mode 100644 index 000000000..c05f15d27 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_iterationStart.js @@ -0,0 +1,71 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that the iteration start is displayed correctly in time blocks. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_script_animation.html"); + let {panel} = yield openAnimationInspector(); + let timelineComponent = panel.animationsTimelineComponent; + let timeBlockComponents = timelineComponent.timeBlocks; + let detailsComponents = timelineComponent.details; + + for (let i = 0; i < timeBlockComponents.length; i++) { + info(`Expand time block ${i} so its keyframes are visible`); + yield clickOnAnimation(panel, i); + + info(`Check the state of time block ${i}`); + let {containerEl, animation: {state}} = timeBlockComponents[i]; + + checkAnimationTooltip(containerEl, state); + checkProgressAtStartingTime(containerEl, state); + + // Get the first set of keyframes (there's only one animated property + // anyway), and the first frame element from there, we're only interested in + // its offset. + let keyframeComponent = detailsComponents[i].keyframeComponents[0]; + let frameEl = keyframeComponent.keyframesEl.querySelector(".frame"); + checkKeyframeOffset(containerEl, frameEl, state); + } +}); + +function checkAnimationTooltip(el, {iterationStart, duration}) { + info("Check an animation's iterationStart data in its tooltip"); + let title = el.querySelector(".name").getAttribute("title"); + + let iterationStartTime = iterationStart * duration / 1000; + let iterationStartTimeString = iterationStartTime.toLocaleString(undefined, { + maximumFractionDigits: 2, + minimumFractionDigits: 2 + }).replace(".", "\\."); + let iterationStartString = iterationStart.toString().replace(".", "\\."); + + let regex = new RegExp("Iteration start: " + iterationStartString + + " \\(" + iterationStartTimeString + "s\\)"); + ok(title.match(regex), "The tooltip shows the expected iteration start"); +} + +function checkProgressAtStartingTime(el, { iterationStart }) { + info("Check the progress of starting time"); + const pathEl = el.querySelector(".iteration-path"); + const pathSegList = pathEl.pathSegList; + const pathSeg = pathSegList.getItem(1); + const progress = pathSeg.y; + is(progress, iterationStart % 1, + `The progress at starting point should be ${ iterationStart % 1 }`); +} + +function checkKeyframeOffset(timeBlockEl, frameEl, {iterationStart}) { + info("Check that the first keyframe is offset correctly"); + + let start = getIterationStartFromLeft(frameEl); + is(start, iterationStart % 1, "The frame offset for iteration start"); +} + +function getIterationStartFromLeft(el) { + let left = 100 - parseFloat(/(\d+)%/.exec(el.style.left)[1]); + return left / 100; +} diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_pause_button_01.js b/devtools/client/animationinspector/test/browser_animation_timeline_pause_button_01.js new file mode 100644 index 000000000..a3a2b4c61 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_pause_button_01.js @@ -0,0 +1,34 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that the timeline toolbar contains a pause button and that this pause button can +// be clicked. Check that when it is, the button changes state and the scrubber stops and +// resumes. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + + let {panel} = yield openAnimationInspector(); + let btn = panel.playTimelineButtonEl; + + ok(btn, "The play/pause button exists"); + ok(!btn.classList.contains("paused"), "The play/pause button is in its playing state"); + + info("Click on the button to pause all timeline animations"); + yield clickTimelinePlayPauseButton(panel); + + ok(btn.classList.contains("paused"), "The play/pause button is in its paused state"); + yield assertScrubberMoving(panel, false); + + info("Click again on the button to play all timeline animations"); + yield clickTimelinePlayPauseButton(panel); + + ok(!btn.classList.contains("paused"), + "The play/pause button is in its playing state again"); + yield assertScrubberMoving(panel, true); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_pause_button_02.js b/devtools/client/animationinspector/test/browser_animation_timeline_pause_button_02.js new file mode 100644 index 000000000..1c440dd88 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_pause_button_02.js @@ -0,0 +1,48 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Checks that the play/pause button goes to the right state when the scrubber has reached +// the end of the timeline but there are infinite animations playing. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + + let {panel, inspector} = yield openAnimationInspector(); + let timeline = panel.animationsTimelineComponent; + let btn = panel.playTimelineButtonEl; + + info("Select an infinite animation and wait for the scrubber to reach the end"); + yield selectNodeAndWaitForAnimations(".multi", inspector); + yield waitForOutOfBoundScrubber(timeline); + + ok(!btn.classList.contains("paused"), + "The button is in its playing state still, animations are infinite."); + yield assertScrubberMoving(panel, true); + + info("Click on the button after the scrubber has moved out of bounds"); + yield clickTimelinePlayPauseButton(panel); + + ok(btn.classList.contains("paused"), + "The button can be paused after the scrubber has moved out of bounds"); + yield assertScrubberMoving(panel, false); +}); + +function waitForOutOfBoundScrubber({win, scrubberEl}) { + return new Promise(resolve => { + function check() { + let pos = scrubberEl.getBoxQuads()[0].bounds.right; + let width = win.document.documentElement.offsetWidth; + if (pos >= width) { + setTimeout(resolve, 50); + } else { + setTimeout(check, 50); + } + } + check(); + }); +} diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_pause_button_03.js b/devtools/client/animationinspector/test/browser_animation_timeline_pause_button_03.js new file mode 100644 index 000000000..5c6e324ed --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_pause_button_03.js @@ -0,0 +1,60 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Also checks that the button goes to the right state when the scrubber has +// reached the end of the timeline: continues to be in playing mode for infinite +// animations, goes to paused mode otherwise. +// And test that clicking the button once the scrubber has reached the end of +// the timeline does the right thing. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + + let {panel, controller, inspector} = yield openAnimationInspector(); + let btn = panel.playTimelineButtonEl; + + // For a finite animation, once the scrubber reaches the end of the timeline, the pause + // button should go back to paused mode. + info("Select a finite animation and wait for the animation to complete"); + yield selectNodeAndWaitForAnimations(".negative-delay", inspector); + + let onButtonPaused = waitForButtonPaused(btn); + let onTimelineUpdated = controller.once(controller.PLAYERS_UPDATED_EVENT); + // The page is reloaded to avoid missing the animation. + yield reloadTab(inspector); + yield onTimelineUpdated; + yield onButtonPaused; + + ok(btn.classList.contains("paused"), + "The button is in paused state once finite animations are done"); + yield assertScrubberMoving(panel, false); + + info("Click again on the button to play the animation from the start again"); + yield clickTimelinePlayPauseButton(panel); + + ok(!btn.classList.contains("paused"), + "Clicking the button once finite animations are done should restart them"); + yield assertScrubberMoving(panel, true); +}); + +function waitForButtonPaused(btn) { + return new Promise(resolve => { + let observer = new btn.ownerDocument.defaultView.MutationObserver(mutations => { + for (let mutation of mutations) { + if (mutation.type === "attributes" && + mutation.attributeName === "class" && + !mutation.oldValue.includes("paused") && + btn.classList.contains("paused")) { + observer.disconnect(); + resolve(); + } + } + }); + observer.observe(btn, { attributes: true, attributeOldValue: true }); + }); +} diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_rate_selector.js b/devtools/client/animationinspector/test/browser_animation_timeline_rate_selector.js new file mode 100644 index 000000000..37ac20de0 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_rate_selector.js @@ -0,0 +1,56 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that the timeline toolbar contains a playback rate selector UI and that +// it can be used to change the playback rate of animations in the timeline. +// Also check that it displays the rate of the current animations in case they +// all have the same rate, or that it displays the empty value in case they +// have mixed rates. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + + let {panel, controller, inspector, toolbox} = yield openAnimationInspector(); + + // In this test, we disable the highlighter on purpose because of the way + // events are simulated to select an option in the playbackRate <select>. + // Indeed, this may cause mousemove events to be triggered on the nodes that + // are underneath the <select>, and these are AnimationTargetNode instances. + // Simulating mouse events on them will cause the highlighter to emit requests + // and this might cause the test to fail if they happen after it has ended. + disableHighlighter(toolbox); + + let select = panel.rateSelectorEl.firstChild; + + ok(select, "The rate selector exists"); + + info("Change all of the current animations' rates to 0.5"); + yield changeTimelinePlaybackRate(panel, .5); + checkAllAnimationsRatesChanged(controller, select, .5); + + info("Select just one animated node and change its rate only"); + yield selectNodeAndWaitForAnimations(".animated", inspector); + + yield changeTimelinePlaybackRate(panel, 2); + checkAllAnimationsRatesChanged(controller, select, 2); + + info("Select the <body> again, it should now have mixed-rates animations"); + yield selectNodeAndWaitForAnimations("body", inspector); + + is(select.value, "", "The selected rate is empty"); + + info("Change the rate for these mixed-rate animations"); + yield changeTimelinePlaybackRate(panel, 1); + checkAllAnimationsRatesChanged(controller, select, 1); +}); + +function checkAllAnimationsRatesChanged({animationPlayers}, select, rate) { + ok(animationPlayers.every(({state}) => state.playbackRate === rate), + "All animations' rates have been set to " + rate); + is(select.value, rate, "The right value is displayed in the select"); +} diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_rewind_button.js b/devtools/client/animationinspector/test/browser_animation_timeline_rewind_button.js new file mode 100644 index 000000000..c4dcbd161 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_rewind_button.js @@ -0,0 +1,51 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that the timeline toolbar contains a rewind button and that it can be +// clicked. Check that when it is, the current animations displayed in the +// timeline get their playstates changed to paused, and their currentTimes +// reset to 0, and that the scrubber stops moving and is positioned to the +// start. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + + let {panel, controller} = yield openAnimationInspector(); + let players = controller.animationPlayers; + let btn = panel.rewindTimelineButtonEl; + + ok(btn, "The rewind button exists"); + + info("Click on the button to rewind all timeline animations"); + yield clickTimelineRewindButton(panel); + + info("Check that the scrubber has stopped moving"); + yield assertScrubberMoving(panel, false); + + ok(players.every(({state}) => state.currentTime === 0), + "All animations' currentTimes have been set to 0"); + ok(players.every(({state}) => state.playState === "paused"), + "All animations have been paused"); + + info("Play the animations again"); + yield clickTimelinePlayPauseButton(panel); + + info("And pause them after a short while"); + yield new Promise(r => setTimeout(r, 200)); + + info("Check that rewinding when animations are paused works too"); + yield clickTimelineRewindButton(panel); + + info("Check that the scrubber has stopped moving"); + yield assertScrubberMoving(panel, false); + + ok(players.every(({state}) => state.currentTime === 0), + "All animations' currentTimes have been set to 0"); + ok(players.every(({state}) => state.playState === "paused"), + "All animations have been paused"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_scrubber_exists.js b/devtools/client/animationinspector/test/browser_animation_timeline_scrubber_exists.js new file mode 100644 index 000000000..9fa22e007 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_scrubber_exists.js @@ -0,0 +1,20 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that the timeline does have a scrubber element. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {panel} = yield openAnimationInspector(); + + let timeline = panel.animationsTimelineComponent; + let scrubberEl = timeline.scrubberEl; + + ok(scrubberEl, "The scrubber element exists"); + ok(scrubberEl.classList.contains("scrubber"), "It has the right classname"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_scrubber_movable.js b/devtools/client/animationinspector/test/browser_animation_timeline_scrubber_movable.js new file mode 100644 index 000000000..a690dd78e --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_scrubber_movable.js @@ -0,0 +1,70 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that the scrubber in the timeline can be moved by clicking & dragging +// in the header area. +// Also check that doing so changes the timeline's play/pause button to paused +// state. +// Finally, also check that the scrubber can be moved using the scrubber handle. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + + let {panel} = yield openAnimationInspector(); + let timeline = panel.animationsTimelineComponent; + let {win, timeHeaderEl, scrubberEl, scrubberHandleEl} = timeline; + let playTimelineButtonEl = panel.playTimelineButtonEl; + + ok(!playTimelineButtonEl.classList.contains("paused"), + "The timeline play button is in its playing state by default"); + + info("Mousedown in the header to move the scrubber"); + yield synthesizeInHeaderAndWaitForChange(timeline, 50, 1, "mousedown"); + checkScrubberIsAt(scrubberEl, timeHeaderEl, 50); + + ok(playTimelineButtonEl.classList.contains("paused"), + "The timeline play button is in its paused state after mousedown"); + + info("Continue moving the mouse and verify that the scrubber tracks it"); + yield synthesizeInHeaderAndWaitForChange(timeline, 100, 1, "mousemove"); + checkScrubberIsAt(scrubberEl, timeHeaderEl, 100); + + ok(playTimelineButtonEl.classList.contains("paused"), + "The timeline play button is in its paused state after mousemove"); + + info("Release the mouse and move again and verify that the scrubber stays"); + EventUtils.synthesizeMouse(timeHeaderEl, 100, 1, {type: "mouseup"}, win); + EventUtils.synthesizeMouse(timeHeaderEl, 200, 1, {type: "mousemove"}, win); + checkScrubberIsAt(scrubberEl, timeHeaderEl, 100); + + info("Try to drag the scrubber handle and check that the scrubber moves"); + let onDataChanged = timeline.once("timeline-data-changed"); + EventUtils.synthesizeMouse(scrubberHandleEl, 1, 20, {type: "mousedown"}, win); + EventUtils.synthesizeMouse(timeHeaderEl, 0, 0, {type: "mousemove"}, win); + EventUtils.synthesizeMouse(timeHeaderEl, 0, 0, {type: "mouseup"}, win); + yield onDataChanged; + + checkScrubberIsAt(scrubberEl, timeHeaderEl, 0); +}); + +function* synthesizeInHeaderAndWaitForChange(timeline, x, y, type) { + let onDataChanged = timeline.once("timeline-data-changed"); + EventUtils.synthesizeMouse(timeline.timeHeaderEl, x, y, {type}, timeline.win); + yield onDataChanged; +} + +function getPositionPercentage(pos, headerEl) { + return pos * 100 / headerEl.offsetWidth; +} + +function checkScrubberIsAt(scrubberEl, timeHeaderEl, pos) { + let newPos = Math.round(parseFloat(scrubberEl.style.left)); + let expectedPos = Math.round(getPositionPercentage(pos, timeHeaderEl)); + is(newPos, expectedPos, + `The scrubber is at position ${pos} (${expectedPos}%)`); +} diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_scrubber_moves.js b/devtools/client/animationinspector/test/browser_animation_timeline_scrubber_moves.js new file mode 100644 index 000000000..494c581a4 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_scrubber_moves.js @@ -0,0 +1,28 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that the scrubber in the timeline moves when animations are playing. +// The animations in the test page last for a very long time, so the test just +// measures the position of the scrubber once, then waits for some time to pass +// and measures its position again. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {panel} = yield openAnimationInspector(); + + let timeline = panel.animationsTimelineComponent; + let scrubberEl = timeline.scrubberEl; + let startPos = scrubberEl.getBoundingClientRect().left; + + info("Wait for some time to check that the scrubber moves"); + yield new Promise(r => setTimeout(r, 2000)); + + let endPos = scrubberEl.getBoundingClientRect().left; + + ok(endPos > startPos, "The scrubber has moved"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_setCurrentTime.js b/devtools/client/animationinspector/test/browser_animation_timeline_setCurrentTime.js new file mode 100644 index 000000000..efc32c001 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_setCurrentTime.js @@ -0,0 +1,88 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Animation.currentTime ignores neagtive delay and positive/negative endDelay +// during fill-mode, even if they are set. +// For example, when the animation timing is +// { duration: 1000, iterations: 1, endDelay: -500, easing: linear }, +// the animation progress is 0.5 at 700ms because the progress stops as 0.5 at +// 500ms in original animation. However, if you set as +// animation.currentTime = 700 manually, the progress will be 0.7. +// So we modify setCurrentTime method since +// AnimationInspector should re-produce same as original animation. +// In these tests, +// we confirm the behavior of setCurrentTime by delay and endDelay. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_timing_combination_animation.html"); + const { panel, controller } = yield openAnimationInspector(); + + yield clickTimelinePlayPauseButton(panel); + + const timelineComponent = panel.animationsTimelineComponent; + const timeBlockComponents = timelineComponent.timeBlocks; + + // Test -5000ms. + let time = -5000; + yield controller.setCurrentTimeAll(time, true); + for (let i = 0; i < timeBlockComponents.length; i++) { + yield timeBlockComponents[i].animation.refreshState(); + const state = yield timeBlockComponents[i].animation.state; + info(`Check the state at ${ time }ms with ` + + `delay:${ state.delay } and endDelay:${ state.endDelay }`); + is(state.currentTime, 0, + `The currentTime should be 0 at setCurrentTime(${ time })`); + } + + // Test 10000ms. + time = 10000; + yield controller.setCurrentTimeAll(time, true); + for (let i = 0; i < timeBlockComponents.length; i++) { + yield timeBlockComponents[i].animation.refreshState(); + const state = yield timeBlockComponents[i].animation.state; + info(`Check the state at ${ time }ms with ` + + `delay:${ state.delay } and endDelay:${ state.endDelay }`); + const expected = state.delay < 0 ? 0 : time; + is(state.currentTime, expected, + `The currentTime should be ${ expected } at setCurrentTime(${ time }).` + + ` delay: ${ state.delay } and endDelay: ${ state.endDelay }`); + } + + // Test 60000ms. + time = 60000; + yield controller.setCurrentTimeAll(time, true); + for (let i = 0; i < timeBlockComponents.length; i++) { + yield timeBlockComponents[i].animation.refreshState(); + const state = yield timeBlockComponents[i].animation.state; + info(`Check the state at ${ time }ms with ` + + `delay:${ state.delay } and endDelay:${ state.endDelay }`); + const expected = state.delay < 0 ? time + state.delay : time; + is(state.currentTime, expected, + `The currentTime should be ${ expected } at setCurrentTime(${ time }).` + + ` delay: ${ state.delay } and endDelay: ${ state.endDelay }`); + } + + // Test 150000ms. + time = 150000; + yield controller.setCurrentTimeAll(time, true); + for (let i = 0; i < timeBlockComponents.length; i++) { + yield timeBlockComponents[i].animation.refreshState(); + const state = yield timeBlockComponents[i].animation.state; + info(`Check the state at ${ time }ms with ` + + `delay:${ state.delay } and endDelay:${ state.endDelay }`); + const currentTime = state.delay < 0 ? time + state.delay : time; + const endTime = + state.delay + state.iterationCount * state.duration + state.endDelay; + const expected = + state.endDelay < 0 && state.fill === "both" && currentTime > endTime + ? endTime : currentTime; + is(state.currentTime, expected, + `The currentTime should be ${ expected } at setCurrentTime(${ time }).` + + ` delay: ${ state.delay } and endDelay: ${ state.endDelay }`); + } +}); diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_shows_delay.js b/devtools/client/animationinspector/test/browser_animation_timeline_shows_delay.js new file mode 100644 index 000000000..8c9b0653d --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_shows_delay.js @@ -0,0 +1,96 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that animation delay is visualized in the timeline when the animation +// is delayed. +// Also check that negative delays do not overflow the UI, and are shown like +// positive delays. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {inspector, panel} = yield openAnimationInspector(); + + info("Selecting a delayed animated node"); + yield selectNodeAndWaitForAnimations(".delayed", inspector); + let timelineEl = panel.animationsTimelineComponent.rootWrapperEl; + checkDelayAndName(timelineEl, true); + let animationEl = timelineEl.querySelector(".animation"); + let state = panel.animationsTimelineComponent.timeBlocks[0].animation.state; + checkPath(animationEl, state); + + info("Selecting a no-delay animated node"); + yield selectNodeAndWaitForAnimations(".animated", inspector); + checkDelayAndName(timelineEl, false); + animationEl = timelineEl.querySelector(".animation"); + state = panel.animationsTimelineComponent.timeBlocks[0].animation.state; + checkPath(animationEl, state); + + info("Selecting a negative-delay animated node"); + yield selectNodeAndWaitForAnimations(".negative-delay", inspector); + checkDelayAndName(timelineEl, true); + animationEl = timelineEl.querySelector(".animation"); + state = panel.animationsTimelineComponent.timeBlocks[0].animation.state; + checkPath(animationEl, state); +}); + +function checkDelayAndName(timelineEl, hasDelay) { + let delay = timelineEl.querySelector(".delay"); + + is(!!delay, hasDelay, "The timeline " + + (hasDelay ? "contains" : "does not contain") + + " a delay element, as expected"); + + if (hasDelay) { + let targetNode = timelineEl.querySelector(".target"); + + // Check that the delay element does not cause the timeline to overflow. + let delayLeft = Math.round(delay.getBoundingClientRect().x); + let sidebarWidth = Math.round(targetNode.getBoundingClientRect().width); + ok(delayLeft >= sidebarWidth, + "The delay element isn't displayed over the sidebar"); + } +} + +function checkPath(animationEl, state) { + // Check existance of delay path. + const delayPathEl = animationEl.querySelector(".delay-path"); + if (!state.iterationCount && state.delay < 0) { + // Infinity + ok(!delayPathEl, "The delay path for Infinity should not exist"); + return; + } + if (state.delay === 0) { + ok(!delayPathEl, "The delay path for zero delay should not exist"); + return; + } + ok(delayPathEl, "The delay path should exist"); + + // Check delay path coordinates. + const pathSegList = delayPathEl.pathSegList; + const startingPathSeg = pathSegList.getItem(0); + const endingPathSeg = pathSegList.getItem(pathSegList.numberOfItems - 2); + if (state.delay < 0) { + ok(delayPathEl.classList.contains("negative"), + "The delay path should have 'negative' class"); + const startingX = state.delay; + const endingX = 0; + is(startingPathSeg.x, startingX, + `The x of starting point should be ${ startingX }`); + is(endingPathSeg.x, endingX, + `The x of ending point should be ${ endingX }`); + } else { + ok(!delayPathEl.classList.contains("negative"), + "The delay path should not have 'negative' class"); + const startingX = 0; + const endingX = state.delay; + is(startingPathSeg.x, startingX, + `The x of starting point should be ${ startingX }`); + is(endingPathSeg.x, endingX, + `The x of ending point should be ${ endingX }`); + } +} diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_shows_endDelay.js b/devtools/client/animationinspector/test/browser_animation_timeline_shows_endDelay.js new file mode 100644 index 000000000..0aa5c16c0 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_shows_endDelay.js @@ -0,0 +1,78 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that animation endDelay is visualized in the timeline when the +// animation is delayed. +// Also check that negative endDelays do not overflow the UI, and are shown +// like positive endDelays. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_end_delay.html"); + let {inspector, panel} = yield openAnimationInspector(); + + let selectors = ["#target1", "#target2", "#target3", "#target4"]; + for (let i = 0; i < selectors.length; i++) { + let selector = selectors[i]; + yield selectNode(selector, inspector); + let timelineEl = panel.animationsTimelineComponent.rootWrapperEl; + let animationEl = timelineEl.querySelector(".animation"); + checkEndDelayAndName(animationEl); + const state = + panel.animationsTimelineComponent.timeBlocks[0].animation.state; + checkPath(animationEl, state); + } +}); + +function checkEndDelayAndName(animationEl) { + let endDelay = animationEl.querySelector(".end-delay"); + let name = animationEl.querySelector(".name"); + let targetNode = animationEl.querySelector(".target"); + + // Check that the endDelay element does not cause the timeline to overflow. + let endDelayLeft = Math.round(endDelay.getBoundingClientRect().x); + let sidebarWidth = Math.round(targetNode.getBoundingClientRect().width); + ok(endDelayLeft >= sidebarWidth, + "The endDelay element isn't displayed over the sidebar"); + + // Check that the endDelay is not displayed on top of the name. + let endDelayRight = Math.round(endDelay.getBoundingClientRect().right); + let nameLeft = Math.round(name.getBoundingClientRect().left); + ok(endDelayRight >= nameLeft, + "The endDelay element does not span over the name element"); +} + +function checkPath(animationEl, state) { + // Check existance of enddelay path. + const endDelayPathEl = animationEl.querySelector(".enddelay-path"); + ok(endDelayPathEl, "The endDelay path should exist"); + + // Check enddelay path coordinates. + const pathSegList = endDelayPathEl.pathSegList; + const startingPathSeg = pathSegList.getItem(0); + const endingPathSeg = pathSegList.getItem(pathSegList.numberOfItems - 2); + if (state.endDelay < 0) { + ok(endDelayPathEl.classList.contains("negative"), + "The endDelay path should have 'negative' class"); + const endingX = state.delay + state.iterationCount * state.duration; + const startingX = endingX + state.endDelay; + is(startingPathSeg.x, startingX, + `The x of starting point should be ${ startingX }`); + is(endingPathSeg.x, endingX, + `The x of ending point should be ${ endingX }`); + } else { + ok(!endDelayPathEl.classList.contains("negative"), + "The endDelay path should not have 'negative' class"); + const startingX = + state.delay + state.iterationCount * state.duration; + const endingX = startingX + state.endDelay; + is(startingPathSeg.x, startingX, + `The x of starting point should be ${ startingX }`); + is(endingPathSeg.x, endingX, + `The x of ending point should be ${ endingX }`); + } +} diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_shows_iterations.js b/devtools/client/animationinspector/test/browser_animation_timeline_shows_iterations.js new file mode 100644 index 000000000..08e5a2620 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_shows_iterations.js @@ -0,0 +1,47 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that the timeline is displays as many iteration elements as there are +// iterations in an animation. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {inspector, panel} = yield openAnimationInspector(); + + info("Selecting the test node"); + yield selectNodeAndWaitForAnimations(".delayed", inspector); + + info("Getting the animation element from the panel"); + const timelineComponent = panel.animationsTimelineComponent; + const timelineEl = timelineComponent.rootWrapperEl; + let animation = timelineEl.querySelector(".time-block"); + // Get iteration count from summary graph path. + let iterationCount = getIterationCount(animation); + + is(iterationCount, 10, + "The animation timeline contains the right number of iterations"); + ok(!animation.querySelector(".infinity"), + "The summary graph does not have any elements " + + " that have infinity class"); + + info("Selecting another test node with an infinite animation"); + yield selectNodeAndWaitForAnimations(".animated", inspector); + + info("Getting the animation element from the panel again"); + animation = timelineEl.querySelector(".time-block"); + iterationCount = getIterationCount(animation); + + is(iterationCount, 1, + "The animation timeline contains one iteration"); + ok(animation.querySelector(".infinity"), + "The summary graph has an element that has infinity class"); +}); + +function getIterationCount(timeblockEl) { + return timeblockEl.querySelectorAll(".iteration-path").length; +} diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_shows_name_label.js b/devtools/client/animationinspector/test/browser_animation_timeline_shows_name_label.js new file mode 100644 index 000000000..e5778c943 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_shows_name_label.js @@ -0,0 +1,46 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check the text content and width of name label. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {inspector, panel} = yield openAnimationInspector(); + + info("Selecting 'simple-animation' animation which is running on compositor"); + yield selectNodeAndWaitForAnimations(".animated", inspector); + checkNameLabel(panel.animationsTimelineComponent.rootWrapperEl, "simple-animation"); + + info("Selecting 'no-compositor' animation which is not running on compositor"); + yield selectNodeAndWaitForAnimations(".no-compositor", inspector); + checkNameLabel(panel.animationsTimelineComponent.rootWrapperEl, "no-compositor"); +}); + +function checkNameLabel(rootWrapperEl, expectedLabelContent) { + const timeblockEl = rootWrapperEl.querySelector(".time-block"); + const labelEl = rootWrapperEl.querySelector(".name div"); + is(labelEl.textContent, expectedLabelContent, + `Text content of labelEl sould be ${ expectedLabelContent }`); + + // Expand timeblockEl to avoid max-width of the label. + timeblockEl.style.width = "10000px"; + const originalLabelWidth = labelEl.clientWidth; + ok(originalLabelWidth < timeblockEl.clientWidth / 2, + "Label width should be less than 50%"); + + // Set timeblockEl width to double of original label width. + timeblockEl.style.width = `${ originalLabelWidth * 2 }px`; + is(labelEl.clientWidth + labelEl.offsetLeft, originalLabelWidth, + `Label width + offsetLeft should be ${ originalLabelWidth }px`); + + // Shrink timeblockEl to enable max-width. + timeblockEl.style.width = `${ originalLabelWidth }px`; + is(labelEl.clientWidth + labelEl.offsetLeft, + Math.round(timeblockEl.clientWidth / 2), + "Label width + offsetLeft should be half of timeblockEl"); +} diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_shows_time_info.js b/devtools/client/animationinspector/test/browser_animation_timeline_shows_time_info.js new file mode 100644 index 000000000..f330e880e --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_shows_time_info.js @@ -0,0 +1,50 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that the timeline displays animations' duration, delay iteration +// counts and iteration start in tooltips. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {panel, controller} = yield openAnimationInspector(); + + info("Getting the animation element from the panel"); + let timelineEl = panel.animationsTimelineComponent.rootWrapperEl; + let timeBlockNameEls = timelineEl.querySelectorAll(".time-block .name"); + + // Verify that each time-block's name element has a tooltip that looks sort of + // ok. We don't need to test the actual content. + [...timeBlockNameEls].forEach((el, i) => { + ok(el.hasAttribute("title"), "The tooltip is defined for animation " + i); + + let title = el.getAttribute("title"); + if (controller.animationPlayers[i].state.delay) { + ok(title.match(/Delay: [\d.-]+s/), "The tooltip shows the delay"); + } + ok(title.match(/Duration: [\d.]+s/), "The tooltip shows the duration"); + if (controller.animationPlayers[i].state.endDelay) { + ok(title.match(/End delay: [\d.-]+s/), "The tooltip shows the endDelay"); + } + if (controller.animationPlayers[i].state.iterationCount !== 1) { + ok(title.match(/Repeats: /), "The tooltip shows the iterations"); + } else { + ok(!title.match(/Repeats: /), "The tooltip doesn't show the iterations"); + } + if (controller.animationPlayers[i].state.easing) { + ok(title.match(/Easing: /), "The tooltip shows the easing"); + } + if (controller.animationPlayers[i].state.fill) { + ok(title.match(/Fill: /), "The tooltip shows the fill"); + } + if (controller.animationPlayers[i].state.direction) { + ok(title.match(/Direction: /), "The tooltip shows the direction"); + } + ok(!title.match(/Iteration start:/), + "The tooltip doesn't show the iteration start"); + }); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_takes_rate_into_account.js b/devtools/client/animationinspector/test/browser_animation_timeline_takes_rate_into_account.js new file mode 100644 index 000000000..42309203a --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_takes_rate_into_account.js @@ -0,0 +1,81 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that if an animation has had its playbackRate changed via the DOM, then +// the timeline UI shows the right delay and duration. +// Indeed, the header in the timeline UI always shows the unaltered time, +// because there might be multiple animations displayed at the same time, some +// of which may have a different rate than others. Those that have had their +// rate changed have a delay = delay/rate and a duration = duration/rate. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_modify_playbackRate.html"); + + let {panel} = yield openAnimationInspector(); + + let timelineEl = panel.animationsTimelineComponent.rootWrapperEl; + + let timeBlocks = timelineEl.querySelectorAll(".time-block"); + is(timeBlocks.length, 2, "2 animations are displayed"); + + info("The first animation has its rate set to 1, let's measure it"); + + let el = timeBlocks[0]; + let duration = getDuration(el.querySelector("path")); + let delay = parseInt(el.querySelector(".delay").style.width, 10); + + info("The second animation has its rate set to 2, so should be shorter"); + + let el2 = timeBlocks[1]; + let duration2 = getDuration(el2.querySelector("path")); + let delay2 = parseInt(el2.querySelector(".delay").style.width, 10); + + // The width are calculated by the animation-inspector dynamically depending + // on the size of the panel, and therefore depends on the test machine/OS. + // Let's not try to be too precise here and compare numbers. + let durationDelta = (2 * duration2) - duration; + ok(durationDelta <= 1, "The duration width is correct"); + let delayDelta = (2 * delay2) - delay; + ok(delayDelta <= 1, "The delay width is correct"); +}); + +function getDuration(pathEl) { + const pathSegList = pathEl.pathSegList; + // Find the index of starting iterations. + let startingIterationIndex = 0; + const firstPathSeg = pathSegList.getItem(1); + for (let i = 2, n = pathSegList.numberOfItems - 2; i < n; i++) { + // Changing point of the progress acceleration is the time. + const pathSeg = pathSegList.getItem(i); + if (firstPathSeg.y != pathSeg.y) { + startingIterationIndex = i; + break; + } + } + // Find the index of ending iterations. + let endingIterationIndex = 0; + let previousPathSegment = pathSegList.getItem(startingIterationIndex); + for (let i = startingIterationIndex + 1, n = pathSegList.numberOfItems - 2; + i < n; i++) { + // Find forwards fill-mode. + const pathSeg = pathSegList.getItem(i); + if (previousPathSegment.y == pathSeg.y) { + endingIterationIndex = i; + break; + } + previousPathSegment = pathSeg; + } + if (endingIterationIndex) { + // Not forwards fill-mode + endingIterationIndex = pathSegList.numberOfItems - 2; + } + // Return the distance of starting and ending + const startingIterationPathSegment = + pathSegList.getItem(startingIterationIndex); + const endingIterationPathSegment = + pathSegList.getItem(startingIterationIndex); + return endingIterationPathSegment.x - startingIterationPathSegment.x; +} diff --git a/devtools/client/animationinspector/test/browser_animation_timeline_ui.js b/devtools/client/animationinspector/test/browser_animation_timeline_ui.js new file mode 100644 index 000000000..43c148482 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_timeline_ui.js @@ -0,0 +1,43 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Check that the timeline contains the right elements. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {panel} = yield openAnimationInspector(); + + let timeline = panel.animationsTimelineComponent; + let el = timeline.rootWrapperEl; + + ok(el.querySelector(".time-header"), + "The header element is in the DOM of the timeline"); + ok(el.querySelectorAll(".time-header .header-item").length, + "The header has some time graduations"); + + ok(el.querySelector(".animations"), + "The animations container is in the DOM of the timeline"); + is(el.querySelectorAll(".animations .animation").length, + timeline.animations.length, + "The number of animations displayed matches the number of animations"); + + for (let i = 0; i < timeline.animations.length; i++) { + let animation = timeline.animations[i]; + let animationEl = el.querySelectorAll(".animations .animation")[i]; + + ok(animationEl.querySelector(".target"), + "The animated node target element is in the DOM"); + ok(animationEl.querySelector(".time-block"), + "The timeline element is in the DOM"); + is(animationEl.querySelector(".name").textContent, + animation.state.name, + "The name on the timeline is correct"); + ok(animationEl.querySelector("svg path"), + "The timeline has svg and path element as summary graph"); + } +}); diff --git a/devtools/client/animationinspector/test/browser_animation_toggle_button_resets_on_navigate.js b/devtools/client/animationinspector/test/browser_animation_toggle_button_resets_on_navigate.js new file mode 100644 index 000000000..d9a92b905 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_toggle_button_resets_on_navigate.js @@ -0,0 +1,31 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Test that a page navigation resets the state of the global toggle button. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {inspector, panel} = yield openAnimationInspector(); + + info("Select the non-animated test node"); + yield selectNodeAndWaitForAnimations(".still", inspector); + + ok(!panel.toggleAllButtonEl.classList.contains("paused"), + "The toggle button is in its running state by default"); + + info("Toggle all animations, so that they pause"); + yield panel.toggleAll(); + ok(panel.toggleAllButtonEl.classList.contains("paused"), + "The toggle button now is in its paused state"); + + info("Reloading the page"); + yield reloadTab(inspector); + + ok(!panel.toggleAllButtonEl.classList.contains("paused"), + "The toggle button is back in its running state"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_toggle_button_toggles_animations.js b/devtools/client/animationinspector/test/browser_animation_toggle_button_toggles_animations.js new file mode 100644 index 000000000..4d55e0433 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_toggle_button_toggles_animations.js @@ -0,0 +1,32 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Test that the main toggle button actually toggles animations. +// This test doesn't need to be extra careful about checking that *all* +// animations have been paused (including inside iframes) because there's an +// actor test in /devtools/server/tests/browser/ that does this. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {panel} = yield openAnimationInspector(); + + info("Click the toggle button"); + yield panel.toggleAll(); + yield checkState("paused"); + + info("Click again the toggle button"); + yield panel.toggleAll(); + yield checkState("running"); +}); + +function* checkState(state) { + for (let selector of [".animated", ".multi", ".long"]) { + let playState = yield getAnimationPlayerState(selector); + is(playState, state, "The animation on node " + selector + " is " + state); + } +} diff --git a/devtools/client/animationinspector/test/browser_animation_toolbar_exists.js b/devtools/client/animationinspector/test/browser_animation_toolbar_exists.js new file mode 100644 index 000000000..aa8b69e02 --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_toolbar_exists.js @@ -0,0 +1,36 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Test that the animation panel has a top toolbar that contains the play/pause +// button and that is displayed at all times. +// Also test that this toolbar gets replaced by the timeline toolbar when there +// are animations to be displayed. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {inspector, window} = yield openAnimationInspector(); + let doc = window.document; + let toolbar = doc.querySelector("#global-toolbar"); + + ok(toolbar, "The panel contains the toolbar element with the new UI"); + ok(!isNodeVisible(toolbar), + "The toolbar is hidden while there are animations"); + + let timelineToolbar = doc.querySelector("#timeline-toolbar"); + ok(timelineToolbar, "The panel contains a timeline toolbar element"); + ok(isNodeVisible(timelineToolbar), + "The timeline toolbar is visible when there are animations"); + + info("Select a node that has no animations"); + yield selectNodeAndWaitForAnimations(".still", inspector); + + ok(isNodeVisible(toolbar), + "The toolbar is shown when there are no animations"); + ok(!isNodeVisible(timelineToolbar), + "The timeline toolbar is hidden when there are no animations"); +}); diff --git a/devtools/client/animationinspector/test/browser_animation_ui_updates_when_animation_data_changes.js b/devtools/client/animationinspector/test/browser_animation_ui_updates_when_animation_data_changes.js new file mode 100644 index 000000000..aa71fd9af --- /dev/null +++ b/devtools/client/animationinspector/test/browser_animation_ui_updates_when_animation_data_changes.js @@ -0,0 +1,53 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Verify that if the animation's duration, iterations or delay change in +// content, then the widget reflects the changes. + +add_task(function* () { + yield addTab(URL_ROOT + "doc_simple_animation.html"); + let {panel, controller, inspector} = yield openAnimationInspector(); + + info("Select the test node"); + yield selectNodeAndWaitForAnimations(".animated", inspector); + + let animation = controller.animationPlayers[0]; + yield setStyle(animation, panel, "animationDuration", "5.5s"); + yield setStyle(animation, panel, "animationIterationCount", "300"); + yield setStyle(animation, panel, "animationDelay", "45s"); + + let animationsEl = panel.animationsTimelineComponent.animationsEl; + let timeBlockEl = animationsEl.querySelector(".time-block"); + + // 45s delay + (300 * 5.5)s duration + let expectedTotalDuration = 1695 * 1000; + + // XXX: the nb and size of each iteration cannot be tested easily (displayed + // using a linear-gradient background and capped at 2px wide). They should + // be tested in bug 1173761. + let delayWidth = parseFloat(timeBlockEl.querySelector(".delay").style.width); + is(Math.round(delayWidth * expectedTotalDuration / 100), 45 * 1000, + "The timeline has the right delay"); +}); + +function* setStyle(animation, panel, name, value) { + info("Change the animation style via the content DOM. Setting " + + name + " to " + value); + + let onAnimationChanged = once(animation, "changed"); + yield executeInContent("devtools:test:setStyle", { + selector: ".animated", + propertyName: name, + propertyValue: value + }); + yield onAnimationChanged; + + // Also wait for the target node previews to be loaded if the panel got + // refreshed as a result of this animation mutation. + yield waitForAllAnimationTargets(panel); +} diff --git a/devtools/client/animationinspector/test/doc_body_animation.html b/devtools/client/animationinspector/test/doc_body_animation.html new file mode 100644 index 000000000..3813ea09c --- /dev/null +++ b/devtools/client/animationinspector/test/doc_body_animation.html @@ -0,0 +1,23 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <style> + body { + background-color: white; + color: black; + animation: change-background-color 3s infinite alternate; + } + + @keyframes change-background-color { + to { + background-color: black; + color: white; + } + } + </style> +</head> +<body> + <h1>Animated body element</h1> +</body> +</html> diff --git a/devtools/client/animationinspector/test/doc_end_delay.html b/devtools/client/animationinspector/test/doc_end_delay.html new file mode 100644 index 000000000..02018bc8a --- /dev/null +++ b/devtools/client/animationinspector/test/doc_end_delay.html @@ -0,0 +1,69 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <style> + .target { + width: 50px; + height: 50px; + background: blue; + } + </style> +</head> +<body> + <div id="target1" class="target"></div> + <div id="target2" class="target"></div> + <div id="target3" class="target"></div> + <div id="target4" class="target"></div> + <script> + /* globals KeyframeEffect, Animation */ + "use strict"; + + let animations = [{ + id: "target1", + frames: [{ opacity: 0, offset: 0 }, { opacity: 1, offset: 1 }], + timing: { + id: "endDelay_animation1", + duration: 1000000, + endDelay: 500000, + fill: "none" + } + }, { + id: "target2", + frames: [{ opacity: 0, offset: 0 }, { opacity: 1, offset: 1 }], + timing: { + id: "endDelay_animation2", + duration: 1000000, + endDelay: -500000, + fill: "none" + } + }, { + id: "target3", + frames: [{ opacity: 0, offset: 0 }, { opacity: 1, offset: 1 }], + timing: { + id: "endDelay_animation3", + duration: 1000000, + endDelay: -1500000, + fill: "forwards" + } + }, { + id: "target4", + frames: [{ opacity: 0, offset: 0 }, { opacity: 1, offset: 1 }], + timing: { + id: "endDelay_animation4", + duration: 100000, + delay: 100000, + endDelay: -1500000, + fill: "forwards" + } + }]; + + for (let {id, frames, timing} of animations) { + let effect = new KeyframeEffect(document.getElementById(id), + frames, timing); + let animation = new Animation(effect, document.timeline); + animation.play(); + } + </script> +</body> +</html> diff --git a/devtools/client/animationinspector/test/doc_frame_script.js b/devtools/client/animationinspector/test/doc_frame_script.js new file mode 100644 index 000000000..6846c9b29 --- /dev/null +++ b/devtools/client/animationinspector/test/doc_frame_script.js @@ -0,0 +1,122 @@ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* globals addMessageListener, sendAsyncMessage */ + +"use strict"; + +// A helper frame-script for brower/devtools/animationinspector tests. + +/** + * Toggle (play or pause) one of the animation players of a given node. + * @param {Object} data + * - {String} selector The CSS selector to get the node (can be a "super" + * selector). + * - {Number} animationIndex The index of the node's animationPlayers to play + * or pause + * - {Boolean} pause True to pause the animation, false to play. + */ +addMessageListener("Test:ToggleAnimationPlayer", function (msg) { + let {selector, animationIndex, pause} = msg.data; + let node = superQuerySelector(selector); + if (!node) { + return; + } + + let animation = node.getAnimations()[animationIndex]; + if (pause) { + animation.pause(); + } else { + animation.play(); + } + + sendAsyncMessage("Test:ToggleAnimationPlayer"); +}); + +/** + * Change the currentTime of one of the animation players of a given node. + * @param {Object} data + * - {String} selector The CSS selector to get the node (can be a "super" + * selector). + * - {Number} animationIndex The index of the node's animationPlayers to change. + * - {Number} currentTime The current time to set. + */ +addMessageListener("Test:SetAnimationPlayerCurrentTime", function (msg) { + let {selector, animationIndex, currentTime} = msg.data; + let node = superQuerySelector(selector); + if (!node) { + return; + } + + let animation = node.getAnimations()[animationIndex]; + animation.currentTime = currentTime; + + sendAsyncMessage("Test:SetAnimationPlayerCurrentTime"); +}); + +/** + * Change the playbackRate of one of the animation players of a given node. + * @param {Object} data + * - {String} selector The CSS selector to get the node (can be a "super" + * selector). + * - {Number} animationIndex The index of the node's animationPlayers to change. + * - {Number} playbackRate The rate to set. + */ +addMessageListener("Test:SetAnimationPlayerPlaybackRate", function (msg) { + let {selector, animationIndex, playbackRate} = msg.data; + let node = superQuerySelector(selector); + if (!node) { + return; + } + + let player = node.getAnimations()[animationIndex]; + player.playbackRate = playbackRate; + + sendAsyncMessage("Test:SetAnimationPlayerPlaybackRate"); +}); + +/** + * Get the current playState of an animation player on a given node. + * @param {Object} data + * - {String} selector The CSS selector to get the node (can be a "super" + * selector). + * - {Number} animationIndex The index of the node's animationPlayers to check + */ +addMessageListener("Test:GetAnimationPlayerState", function (msg) { + let {selector, animationIndex} = msg.data; + let node = superQuerySelector(selector); + if (!node) { + return; + } + + let animation = node.getAnimations()[animationIndex]; + animation.ready.then(() => { + sendAsyncMessage("Test:GetAnimationPlayerState", animation.playState); + }); +}); + +/** + * Like document.querySelector but can go into iframes too. + * ".container iframe || .sub-container div" will first try to find the node + * matched by ".container iframe" in the root document, then try to get the + * content document inside it, and then try to match ".sub-container div" inside + * this document. + * Any selector coming before the || separator *MUST* match a frame node. + * @param {String} superSelector. + * @return {DOMNode} The node, or null if not found. + */ +function superQuerySelector(superSelector, root = content.document) { + let frameIndex = superSelector.indexOf("||"); + if (frameIndex === -1) { + return root.querySelector(superSelector); + } + + let rootSelector = superSelector.substring(0, frameIndex).trim(); + let childSelector = superSelector.substring(frameIndex + 2).trim(); + root = root.querySelector(rootSelector); + if (!root || !root.contentWindow) { + return null; + } + + return superQuerySelector(childSelector, root.contentWindow.document); +} diff --git a/devtools/client/animationinspector/test/doc_keyframes.html b/devtools/client/animationinspector/test/doc_keyframes.html new file mode 100644 index 000000000..7671e09e3 --- /dev/null +++ b/devtools/client/animationinspector/test/doc_keyframes.html @@ -0,0 +1,55 @@ +<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Yay! Keyframes!</title>
+ <style>
+ div {
+ animation: wow 100s forwards;
+ }
+ @keyframes wow {
+ 0% {
+ width: 100px;
+ height: 100px;
+ border-radius: 0px;
+ background: #f06;
+ }
+ 10% {
+ border-radius: 2px;
+ }
+ 20% {
+ transform: rotate(13deg);
+ }
+ 30% {
+ background: gold;
+ }
+ 40% {
+ filter: blur(40px);
+ }
+ 50% {
+ transform: rotate(720deg) translateX(300px) skew(-13deg);
+ }
+ 60% {
+ width: 200px;
+ height: 200px;
+ }
+ 70% {
+ border-radius: 10px;
+ }
+ 80% {
+ background: #333;
+ }
+ 90% {
+ border-radius: 50%;
+ }
+ 100% {
+ width: 500px;
+ height: 500px;
+ }
+ }
+ </style>
+</head>
+<body>
+ <div></div>
+</body>
+</html>
diff --git a/devtools/client/animationinspector/test/doc_modify_playbackRate.html b/devtools/client/animationinspector/test/doc_modify_playbackRate.html new file mode 100644 index 000000000..7b83f1c38 --- /dev/null +++ b/devtools/client/animationinspector/test/doc_modify_playbackRate.html @@ -0,0 +1,32 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <style> + div { + width: 50px; + height: 50px; + background: blue; + animation: move 20s 20s linear; + animation-fill-mode: forwards; + } + + @keyframes move { + to { + margin-left: 200px; + } + } + </style> +</head> +<body> + <div></div> + <div class="rate"></div> + <script> + "use strict"; + + var el = document.querySelector(".rate"); + var ani = el.getAnimations()[0]; + ani.playbackRate = 2; + </script> +</body> +</html> diff --git a/devtools/client/animationinspector/test/doc_multiple_animation_types.html b/devtools/client/animationinspector/test/doc_multiple_animation_types.html new file mode 100644 index 000000000..318f14d0a --- /dev/null +++ b/devtools/client/animationinspector/test/doc_multiple_animation_types.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <style> + .ball { + width: 80px; + height: 80px; + border-radius: 50%; + } + + .script-animation { + background: #f06; + } + + .css-transition { + background: #006; + transition: background-color 20s; + } + + .css-animation { + background: #a06; + animation: flash 10s forwards; + } + + @keyframes flash { + 0% { + opacity: 1; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } + } + </style> +</head> +<body> + <div class="ball script-animation"></div> + <div class="ball css-animation"></div> + <div class="ball css-transition"></div> + + <script> + /* globals KeyframeEffect, Animation */ + "use strict"; + + setTimeout(function () { + document.querySelector(".css-transition").style.backgroundColor = "yellow"; + }, 0); + + let effect = new KeyframeEffect( + document.querySelector(".script-animation"), [ + {opacity: 1, offset: 0}, + {opacity: .1, offset: 1} + ], { duration: 10000, fill: "forwards" }); + let animation = new Animation(effect, document.timeline); + animation.play(); + </script> +</body> +</html> diff --git a/devtools/client/animationinspector/test/doc_negative_animation.html b/devtools/client/animationinspector/test/doc_negative_animation.html new file mode 100644 index 000000000..ea412025b --- /dev/null +++ b/devtools/client/animationinspector/test/doc_negative_animation.html @@ -0,0 +1,66 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <style> + html, body { + margin: 0; + height: 100%; + overflow: hidden; + } + + div { + position: absolute; + top: 0; + left: -500px; + height: 20px; + width: 500px; + color: red; + background: linear-gradient(to left, currentColor, currentColor 2px, transparent); + } + + .zero { + color: blue; + top: 20px; + } + + .positive { + color: green; + top: 40px; + } + + .negative.move { animation: 5s -1s move linear forwards; } + .zero.move { animation: 5s 0s move linear forwards; } + .positive.move { animation: 5s 1s move linear forwards; } + + @keyframes move { + to { + transform: translateX(500px); + } + } + </style> +</head> +<body> + <div class="negative"></div> + <div class="zero"></div> + <div class="positive"></div> + <script> + "use strict"; + + var negative = document.querySelector(".negative"); + var zero = document.querySelector(".zero"); + var positive = document.querySelector(".positive"); + + // The non-delayed animation starts now. + zero.classList.add("move"); + // The negative-delayed animation starts in 1 second. + setTimeout(function () { + negative.classList.add("move"); + }, 1000); + // The positive-delayed animation starts in 200 ms. + setTimeout(function () { + positive.classList.add("move"); + }, 200); + </script> +</body> +</html> diff --git a/devtools/client/animationinspector/test/doc_pseudo_elements.html b/devtools/client/animationinspector/test/doc_pseudo_elements.html new file mode 100644 index 000000000..587608b19 --- /dev/null +++ b/devtools/client/animationinspector/test/doc_pseudo_elements.html @@ -0,0 +1,61 @@ +<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <title>Animated pseudo elements</title>
+ <style>
+ html, body {
+ margin: 0;
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+ display: flex;
+ justify-content: center;
+ align-items: flex-end;
+ }
+
+ body {
+ animation: color 2s linear infinite;
+ background: #333;
+ }
+
+ @keyframes color {
+ to {
+ filter: hue-rotate(360deg);
+ }
+ }
+
+ body::before,
+ body::after {
+ content: "";
+ flex-grow: 1;
+ height: 100%;
+ animation: grow 1s linear infinite alternate;
+ }
+
+ body::before {
+ background: hsl(120, 80%, 80%);
+ }
+ body::after {
+ background: hsl(240, 80%, 80%);
+ animation-delay: -.5s;
+ }
+
+ @keyframes grow {
+ 0% {height: 100%; animation-timing-function: ease-in-out;}
+ 10% {height: 80%; animation-timing-function: ease-in-out;}
+ 20% {height: 60%; animation-timing-function: ease-in-out;}
+ 30% {height: 70%; animation-timing-function: ease-in-out;}
+ 40% {height: 50%; animation-timing-function: ease-in-out;}
+ 50% {height: 30%; animation-timing-function: ease-in-out;}
+ 60% {height: 80%; animation-timing-function: ease-in-out;}
+ 70% {height: 90%; animation-timing-function: ease-in-out;}
+ 80% {height: 70%; animation-timing-function: ease-in-out;}
+ 90% {height: 60%; animation-timing-function: ease-in-out;}
+ 100% {height: 100%; animation-timing-function: ease-in-out;}
+ }
+ </style>
+ </head>
+ <body>
+ </body>
+</html>
\ No newline at end of file diff --git a/devtools/client/animationinspector/test/doc_script_animation.html b/devtools/client/animationinspector/test/doc_script_animation.html new file mode 100644 index 000000000..b7839622e --- /dev/null +++ b/devtools/client/animationinspector/test/doc_script_animation.html @@ -0,0 +1,71 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <style> + #target1 { + width: 50px; + height: 50px; + background: red; + } + + #target2 { + width: 50px; + height: 50px; + background: green; + } + + #target3 { + width: 50px; + height: 50px; + background: blue; + } + </style> +</head> +<body> + <div id="target1"></div> + <div id="target2"></div> + <div id="target3"></div> + + <script> + /* globals KeyframeEffect, Animation */ + "use strict"; + + let animations = [{ + id: "target1", + frames: [{ opacity: 0, offset: 0 }, { opacity: 1, offset: 1 }], + timing: { + duration: 100, + iterations: 2, + iterationStart: 0.25, + fill: "both" + } + }, { + id: "target2", + frames: [{ opacity: 0, offset: 0 }, { opacity: 1, offset: 1 }], + timing: { + duration: 100, + iterations: 1, + iterationStart: 0.25, + fill: "both" + } + }, { + id: "target3", + frames: [{ opacity: 0, offset: 0 }, { opacity: 1, offset: 1 }], + timing: { + duration: 100, + iterations: 1.5, + iterationStart: 2.5, + fill: "both" + } + }]; + + for (let {id, frames, timing} of animations) { + let effect = new KeyframeEffect(document.getElementById(id), + frames, timing); + let animation = new Animation(effect, document.timeline); + animation.play(); + } + </script> +</body> +</html> diff --git a/devtools/client/animationinspector/test/doc_simple_animation.html b/devtools/client/animationinspector/test/doc_simple_animation.html new file mode 100644 index 000000000..fc65a5744 --- /dev/null +++ b/devtools/client/animationinspector/test/doc_simple_animation.html @@ -0,0 +1,147 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <style> + .ball { + width: 80px; + height: 80px; + border-radius: 50%; + background: #f06; + + position: absolute; + } + + .still { + top: 0; + left: 10px; + } + + .animated { + top: 100px; + left: 10px; + + animation: simple-animation 2s infinite alternate; + } + + .multi { + top: 200px; + left: 10px; + + animation: simple-animation 2s infinite alternate, + other-animation 5s infinite alternate; + } + + .delayed { + top: 300px; + left: 10px; + background: rebeccapurple; + + animation: simple-animation 3s 60s 10; + } + + .multi-finite { + top: 400px; + left: 10px; + background: yellow; + + animation: simple-animation 3s, + other-animation 4s; + } + + .short { + top: 500px; + left: 10px; + background: red; + + animation: simple-animation 2s; + } + + .long { + top: 600px; + left: 10px; + background: blue; + + animation: simple-animation 120s; + } + + .negative-delay { + top: 700px; + left: 10px; + background: gray; + + animation: simple-animation 15s -10s; + animation-fill-mode: forwards; + } + + .no-compositor { + top: 0; + right: 10px; + background: gold; + + animation: no-compositor 10s cubic-bezier(.57,-0.02,1,.31) forwards; + } + + .compositor-notall { + animation: compositor-notall 2s infinite; + } + + @keyframes simple-animation { + 100% { + transform: translateX(300px); + } + } + + @keyframes other-animation { + 100% { + background: blue; + } + } + + @keyframes no-compositor { + 100% { + margin-right: 600px; + } + } + + @keyframes compositor-notall { + from { + opacity: 0; + width: 0px; + transform: translate(0px); + } + to { + opacity: 1; + width: 100px; + transform: translate(100px); + } + } + </style> +</head> +<body> + <!-- Comment node --> + <div class="ball still"></div> + <div class="ball animated"></div> + <div class="ball multi"></div> + <div class="ball delayed"></div> + <div class="ball multi-finite"></div> + <div class="ball short"></div> + <div class="ball long"></div> + <div class="ball negative-delay"></div> + <div class="ball no-compositor"></div> + <div class="ball" id="endDelayed"></div> + <div class="ball compositor-notall"></div> + <script> + /* globals KeyframeEffect, Animation */ + "use strict"; + + var el = document.getElementById("endDelayed"); + let effect = new KeyframeEffect(el, [ + { opacity: 0, offset: 0 }, + { opacity: 1, offset: 1 } + ], { duration: 1000000, endDelay: 500000, fill: "none" }); + let animation = new Animation(effect, document.timeline); + animation.play(); + </script> +</body> +</html> diff --git a/devtools/client/animationinspector/test/doc_timing_combination_animation.html b/devtools/client/animationinspector/test/doc_timing_combination_animation.html new file mode 100644 index 000000000..8b39af015 --- /dev/null +++ b/devtools/client/animationinspector/test/doc_timing_combination_animation.html @@ -0,0 +1,35 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <style> + div { + display: inline-block; + width: 100px; + height: 100px; + background-color: lime; + } + </style> + </head> + <body> + <script> + "use strict"; + + const delayList = [0, 50000, -50000]; + const endDelayList = [0, 50000, -50000]; + + delayList.forEach(delay => { + endDelayList.forEach(endDelay => { + const el = document.createElement("div"); + document.body.appendChild(el); + el.animate({ opacity: [0, 1] }, + { duration: 200000, + iterations: 1, + fill: "both", + delay: delay, + endDelay: endDelay }); + }); + }); + </script> + </body> +</html> diff --git a/devtools/client/animationinspector/test/head.js b/devtools/client/animationinspector/test/head.js new file mode 100644 index 000000000..554a36430 --- /dev/null +++ b/devtools/client/animationinspector/test/head.js @@ -0,0 +1,426 @@ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint no-unused-vars: [2, {"vars": "local", "args": "none"}] */ + +"use strict"; + +/* import-globals-from ../../inspector/test/head.js */ +// Import the inspector's head.js first (which itself imports shared-head.js). +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/head.js", + this); + +const FRAME_SCRIPT_URL = CHROME_URL_ROOT + "doc_frame_script.js"; +const COMMON_FRAME_SCRIPT_URL = "chrome://devtools/content/shared/frame-script-utils.js"; +const TAB_NAME = "animationinspector"; +const ANIMATION_L10N = + new LocalizationHelper("devtools/client/locales/animationinspector.properties"); + +// Auto clean-up when a test ends +registerCleanupFunction(function* () { + yield closeAnimationInspector(); + + while (gBrowser.tabs.length > 1) { + gBrowser.removeCurrentTab(); + } +}); + +// Clean-up all prefs that might have been changed during a test run +// (safer here because if the test fails, then the pref is never reverted) +registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.debugger.log"); +}); + +// WebAnimations API is not enabled by default in all release channels yet, see +// Bug 1264101. +function enableWebAnimationsAPI() { + return new Promise(resolve => { + SpecialPowers.pushPrefEnv({"set": [ + ["dom.animations-api.core.enabled", true] + ]}, resolve); + }); +} + +/** + * Add a new test tab in the browser and load the given url. + * @param {String} url The url to be loaded in the new tab + * @return a promise that resolves to the tab object when the url is loaded + */ +var _addTab = addTab; +addTab = function (url) { + return enableWebAnimationsAPI().then(() => _addTab(url)).then(tab => { + let browser = tab.linkedBrowser; + info("Loading the helper frame script " + FRAME_SCRIPT_URL); + browser.messageManager.loadFrameScript(FRAME_SCRIPT_URL, false); + info("Loading the helper frame script " + COMMON_FRAME_SCRIPT_URL); + browser.messageManager.loadFrameScript(COMMON_FRAME_SCRIPT_URL, false); + return tab; + }); +}; + +/** + * Reload the current tab location. + * @param {InspectorPanel} inspector The instance of InspectorPanel currently + * loaded in the toolbox + */ +function* reloadTab(inspector) { + let onNewRoot = inspector.once("new-root"); + yield executeInContent("devtools:test:reload", {}, {}, false); + yield onNewRoot; + yield inspector.once("inspector-updated"); +} + +/* + * Set the inspector's current selection to a node or to the first match of the + * given css selector and wait for the animations to be displayed + * @param {String|NodeFront} + * data The node to select + * @param {InspectorPanel} inspector + * The instance of InspectorPanel currently + * loaded in the toolbox + * @param {String} reason + * Defaults to "test" which instructs the inspector not + * to highlight the node upon selection + * @return {Promise} Resolves when the inspector is updated with the new node + and animations of its subtree are properly displayed. + */ +var selectNodeAndWaitForAnimations = Task.async( + function* (data, inspector, reason = "test") { + yield selectNode(data, inspector, reason); + + // We want to make sure the rest of the test waits for the animations to + // be properly displayed (wait for all target DOM nodes to be previewed). + let {AnimationsPanel} = inspector.sidebar.getWindowForTab(TAB_NAME); + yield waitForAllAnimationTargets(AnimationsPanel); + } +); + +/** + * Check if there are the expected number of animations being displayed in the + * panel right now. + * @param {AnimationsPanel} panel + * @param {Number} nbAnimations The expected number of animations. + * @param {String} msg An optional string to be used as the assertion message. + */ +function assertAnimationsDisplayed(panel, nbAnimations, msg = "") { + msg = msg || `There are ${nbAnimations} animations in the panel`; + is(panel.animationsTimelineComponent + .animationsEl + .querySelectorAll(".animation").length, nbAnimations, msg); +} + +/** + * Takes an Inspector panel that was just created, and waits + * for a "inspector-updated" event as well as the animation inspector + * sidebar to be ready. Returns a promise once these are completed. + * + * @param {InspectorPanel} inspector + * @return {Promise} + */ +var waitForAnimationInspectorReady = Task.async(function* (inspector) { + let win = inspector.sidebar.getWindowForTab(TAB_NAME); + let updated = inspector.once("inspector-updated"); + + // In e10s, if we wait for underlying toolbox actors to + // load (by setting DevToolsUtils.testing to true), we miss the + // "animationinspector-ready" event on the sidebar, so check to see if the + // iframe is already loaded. + let tabReady = win.document.readyState === "complete" ? + promise.resolve() : + inspector.sidebar.once("animationinspector-ready"); + + return promise.all([updated, tabReady]); +}); + +/** + * Open the toolbox, with the inspector tool visible and the animationinspector + * sidebar selected. + * @return a promise that resolves when the inspector is ready. + */ +var openAnimationInspector = Task.async(function* () { + let {inspector, toolbox} = yield openInspectorSidebarTab(TAB_NAME); + + info("Waiting for the inspector and sidebar to be ready"); + yield waitForAnimationInspectorReady(inspector); + + let win = inspector.sidebar.getWindowForTab(TAB_NAME); + let {AnimationsController, AnimationsPanel} = win; + + info("Waiting for the animation controller and panel to be ready"); + if (AnimationsPanel.initialized) { + yield AnimationsPanel.initialized; + } else { + yield AnimationsPanel.once(AnimationsPanel.PANEL_INITIALIZED); + } + + // Make sure we wait for all animations to be loaded (especially their target + // nodes to be lazily displayed). This is safe to do even if there are no + // animations displayed. + yield waitForAllAnimationTargets(AnimationsPanel); + + return { + toolbox: toolbox, + inspector: inspector, + controller: AnimationsController, + panel: AnimationsPanel, + window: win + }; +}); + +/** + * Close the toolbox. + * @return a promise that resolves when the toolbox has closed. + */ +var closeAnimationInspector = Task.async(function* () { + let target = TargetFactory.forTab(gBrowser.selectedTab); + yield gDevTools.closeToolbox(target); +}); + +/** + * Wait for a content -> chrome message on the message manager (the window + * messagemanager is used). + * @param {String} name The message name + * @return {Promise} A promise that resolves to the response data when the + * message has been received + */ +function waitForContentMessage(name) { + info("Expecting message " + name + " from content"); + + let mm = gBrowser.selectedBrowser.messageManager; + + return new Promise(resolve => { + mm.addMessageListener(name, function onMessage(msg) { + mm.removeMessageListener(name, onMessage); + resolve(msg.data); + }); + }); +} + +/** + * Send an async message to the frame script (chrome -> content) and wait for a + * response message with the same name (content -> chrome). + * @param {String} name The message name. Should be one of the messages defined + * in doc_frame_script.js + * @param {Object} data Optional data to send along + * @param {Object} objects Optional CPOW objects to send along + * @param {Boolean} expectResponse If set to false, don't wait for a response + * with the same name from the content script. Defaults to true. + * @return {Promise} Resolves to the response data if a response is expected, + * immediately resolves otherwise + */ +function executeInContent(name, data = {}, objects = {}, + expectResponse = true) { + info("Sending message " + name + " to content"); + let mm = gBrowser.selectedBrowser.messageManager; + + mm.sendAsyncMessage(name, data, objects); + if (expectResponse) { + return waitForContentMessage(name); + } + + return promise.resolve(); +} + +/** + * Get the current playState of an animation player on a given node. + */ +var getAnimationPlayerState = Task.async(function* (selector, + animationIndex = 0) { + let playState = yield executeInContent("Test:GetAnimationPlayerState", + {selector, animationIndex}); + return playState; +}); + +/** + * Is the given node visible in the page (rendered in the frame tree). + * @param {DOMNode} + * @return {Boolean} + */ +function isNodeVisible(node) { + return !!node.getClientRects().length; +} + +/** + * Wait for all AnimationTargetNode instances to be fully loaded + * (fetched their related actor and rendered), and return them. + * @param {AnimationsPanel} panel + * @return {Array} all AnimationTargetNode instances + */ +var waitForAllAnimationTargets = Task.async(function* (panel) { + let targets = panel.animationsTimelineComponent.targetNodes; + yield promise.all(targets.map(t => { + if (!t.previewer.nodeFront) { + return t.once("target-retrieved"); + } + return false; + })); + return targets; +}); + +/** + * Check the scrubber element in the timeline is moving. + * @param {AnimationPanel} panel + * @param {Boolean} isMoving + */ +function* assertScrubberMoving(panel, isMoving) { + let timeline = panel.animationsTimelineComponent; + + if (isMoving) { + // If we expect the scrubber to move, just wait for a couple of + // timeline-data-changed events and compare times. + let {time: time1} = yield timeline.once("timeline-data-changed"); + let {time: time2} = yield timeline.once("timeline-data-changed"); + ok(time2 > time1, "The scrubber is moving"); + } else { + // If instead we expect the scrubber to remain at its position, just wait + // for some time and make sure timeline-data-changed isn't emitted. + let hasMoved = false; + timeline.once("timeline-data-changed", () => { + hasMoved = true; + }); + yield new Promise(r => setTimeout(r, 500)); + ok(!hasMoved, "The scrubber is not moving"); + } +} + +/** + * Click the play/pause button in the timeline toolbar and wait for animations + * to update. + * @param {AnimationsPanel} panel + */ +function* clickTimelinePlayPauseButton(panel) { + let onUiUpdated = panel.once(panel.UI_UPDATED_EVENT); + + let btn = panel.playTimelineButtonEl; + let win = btn.ownerDocument.defaultView; + EventUtils.sendMouseEvent({type: "click"}, btn, win); + + yield onUiUpdated; + yield waitForAllAnimationTargets(panel); +} + +/** + * Click the rewind button in the timeline toolbar and wait for animations to + * update. + * @param {AnimationsPanel} panel + */ +function* clickTimelineRewindButton(panel) { + let onUiUpdated = panel.once(panel.UI_UPDATED_EVENT); + + let btn = panel.rewindTimelineButtonEl; + let win = btn.ownerDocument.defaultView; + EventUtils.sendMouseEvent({type: "click"}, btn, win); + + yield onUiUpdated; + yield waitForAllAnimationTargets(panel); +} + +/** + * Select a rate inside the playback rate selector in the timeline toolbar and + * wait for animations to update. + * @param {AnimationsPanel} panel + * @param {Number} rate The new rate value to be selected + */ +function* changeTimelinePlaybackRate(panel, rate) { + let onUiUpdated = panel.once(panel.UI_UPDATED_EVENT); + + let select = panel.rateSelectorEl.firstChild; + let win = select.ownerDocument.defaultView; + + // Get the right option. + let option = [...select.options].filter(o => o.value === rate + "")[0]; + if (!option) { + ok(false, + "Could not find an option for rate " + rate + " in the rate selector. " + + "Values are: " + [...select.options].map(o => o.value)); + return; + } + + // Simulate the right events to select the option in the drop-down. + EventUtils.synthesizeMouseAtCenter(select, {type: "mousedown"}, win); + EventUtils.synthesizeMouseAtCenter(option, {type: "mouseup"}, win); + + yield onUiUpdated; + yield waitForAllAnimationTargets(panel); + + // Simulate a mousemove outside of the rate selector area to avoid subsequent + // tests from failing because of unwanted mouseover events. + EventUtils.synthesizeMouseAtCenter( + win.document.querySelector("#timeline-toolbar"), {type: "mousemove"}, win); +} + +/** + * Prevent the toolbox common highlighter from making backend requests. + * @param {Toolbox} toolbox + */ +function disableHighlighter(toolbox) { + toolbox._highlighter = { + showBoxModel: () => new Promise(r => r()), + hideBoxModel: () => new Promise(r => r()), + pick: () => new Promise(r => r()), + cancelPick: () => new Promise(r => r()), + destroy: () => {}, + traits: {} + }; +} + +/** + * Click on an animation in the timeline to select/unselect it. + * @param {AnimationsPanel} panel The panel instance. + * @param {Number} index The index of the animation to click on. + * @param {Boolean} shouldClose Set to true if clicking should close the + * animation. + * @return {Promise} resolves to the animation whose state has changed. + */ +function* clickOnAnimation(panel, index, shouldClose) { + let timeline = panel.animationsTimelineComponent; + + // Expect a selection event. + let onSelectionChanged = timeline.once(shouldClose + ? "animation-unselected" + : "animation-selected"); + + // If we're opening the animation, also wait for the keyframes-retrieved + // event. + let onReady = shouldClose + ? Promise.resolve() + : timeline.details[index].once("keyframes-retrieved"); + + info("Click on animation " + index + " in the timeline"); + let timeBlock = timeline.rootWrapperEl.querySelectorAll(".time-block")[index]; + EventUtils.sendMouseEvent({type: "click"}, timeBlock, + timeBlock.ownerDocument.defaultView); + + yield onReady; + return yield onSelectionChanged; +} + +/** + * Get an instance of the Keyframes component from the timeline. + * @param {AnimationsPanel} panel The panel instance. + * @param {Number} animationIndex The index of the animation in the timeline. + * @param {String} propertyName The name of the animated property. + * @return {Keyframes} The Keyframes component instance. + */ +function getKeyframeComponent(panel, animationIndex, propertyName) { + let timeline = panel.animationsTimelineComponent; + let detailsComponent = timeline.details[animationIndex]; + return detailsComponent.keyframeComponents + .find(c => c.propertyName === propertyName); +} + +/** + * Get a keyframe element from the timeline. + * @param {AnimationsPanel} panel The panel instance. + * @param {Number} animationIndex The index of the animation in the timeline. + * @param {String} propertyName The name of the animated property. + * @param {Index} keyframeIndex The index of the keyframe. + * @return {DOMNode} The keyframe element. + */ +function getKeyframeEl(panel, animationIndex, propertyName, keyframeIndex) { + let keyframeComponent = getKeyframeComponent(panel, animationIndex, + propertyName); + return keyframeComponent.keyframesEl + .querySelectorAll(".frame")[keyframeIndex]; +} diff --git a/devtools/client/animationinspector/test/unit/.eslintrc.js b/devtools/client/animationinspector/test/unit/.eslintrc.js new file mode 100644 index 000000000..59adf410a --- /dev/null +++ b/devtools/client/animationinspector/test/unit/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the common devtools xpcshell eslintrc config. + "extends": "../../../../.eslintrc.xpcshell.js" +}; diff --git a/devtools/client/animationinspector/test/unit/test_findOptimalTimeInterval.js b/devtools/client/animationinspector/test/unit/test_findOptimalTimeInterval.js new file mode 100644 index 000000000..64451bfdf --- /dev/null +++ b/devtools/client/animationinspector/test/unit/test_findOptimalTimeInterval.js @@ -0,0 +1,81 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint no-eval:0 */ + +"use strict"; + +var Cu = Components.utils; +const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const {findOptimalTimeInterval} = require("devtools/client/animationinspector/utils"); + +// This test array contains objects that are used to test the +// findOptimalTimeInterval function. Each object should have the following +// properties: +// - desc: an optional string that will be printed out +// - minTimeInterval: a number that represents the minimum time in ms +// that should be displayed in one interval +// - expectedInterval: a number that you expect the findOptimalTimeInterval +// function to return as a result. +// Optionally you can pass a string where `interval` is the calculated +// interval, this string will be eval'd and tested to be truthy. +const TEST_DATA = [{ + desc: "With no minTimeInterval, expect the interval to be 0", + minTimeInterval: null, + expectedInterval: 0 +}, { + desc: "With a minTimeInterval of 0 ms, expect the interval to be 0", + minTimeInterval: 0, + expectedInterval: 0 +}, { + desc: "With a minInterval of 1ms, expect the interval to be the 1ms too", + minTimeInterval: 1, + expectedInterval: 1 +}, { + desc: "With a very small minTimeInterval, expect the interval to be 1ms", + minTimeInterval: 1e-31, + expectedInterval: 1 +}, { + desc: "With a minInterval of 2.5ms, expect the interval to be 2.5ms too", + minTimeInterval: 2.5, + expectedInterval: 2.5 +}, { + desc: "With a minInterval of 5ms, expect the interval to be 5ms too", + minTimeInterval: 5, + expectedInterval: 5 +}, { + desc: "With a minInterval of 7ms, expect the interval to be the next " + + "multiple of 5", + minTimeInterval: 7, + expectedInterval: 10 +}, { + minTimeInterval: 20, + expectedInterval: 25 +}, { + minTimeInterval: 33, + expectedInterval: 50 +}, { + minTimeInterval: 987, + expectedInterval: 1000 +}, { + minTimeInterval: 1234, + expectedInterval: 2500 +}, { + minTimeInterval: 9800, + expectedInterval: 10000 +}]; + +function run_test() { + for (let {minTimeInterval, desc, expectedInterval} of TEST_DATA) { + do_print(`Testing minTimeInterval: ${minTimeInterval}. + Expecting ${expectedInterval}.`); + + let interval = findOptimalTimeInterval(minTimeInterval); + if (typeof expectedInterval == "string") { + ok(eval(expectedInterval), desc); + } else { + equal(interval, expectedInterval, desc); + } + } +} diff --git a/devtools/client/animationinspector/test/unit/test_formatStopwatchTime.js b/devtools/client/animationinspector/test/unit/test_formatStopwatchTime.js new file mode 100644 index 000000000..12584a2a4 --- /dev/null +++ b/devtools/client/animationinspector/test/unit/test_formatStopwatchTime.js @@ -0,0 +1,62 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var Cu = Components.utils; +const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const {formatStopwatchTime} = require("devtools/client/animationinspector/utils"); + +const TEST_DATA = [{ + desc: "Formatting 0", + time: 0, + expected: "00:00.000" +}, { + desc: "Formatting null", + time: null, + expected: "00:00.000" +}, { + desc: "Formatting undefined", + time: undefined, + expected: "00:00.000" +}, { + desc: "Formatting a small number of ms", + time: 13, + expected: "00:00.013" +}, { + desc: "Formatting a slightly larger number of ms", + time: 500, + expected: "00:00.500" +}, { + desc: "Formatting 1 second", + time: 1000, + expected: "00:01.000" +}, { + desc: "Formatting a number of seconds", + time: 1532, + expected: "00:01.532" +}, { + desc: "Formatting a big number of seconds", + time: 58450, + expected: "00:58.450" +}, { + desc: "Formatting 1 minute", + time: 60000, + expected: "01:00.000" +}, { + desc: "Formatting a number of minutes", + time: 263567, + expected: "04:23.567" +}, { + desc: "Formatting a large number of minutes", + time: 1000 * 60 * 60 * 3, + expected: "180:00.000" +}]; + +function run_test() { + for (let {desc, time, expected} of TEST_DATA) { + equal(formatStopwatchTime(time), expected, desc); + } +} diff --git a/devtools/client/animationinspector/test/unit/test_getCssPropertyName.js b/devtools/client/animationinspector/test/unit/test_getCssPropertyName.js new file mode 100644 index 000000000..21470d5fb --- /dev/null +++ b/devtools/client/animationinspector/test/unit/test_getCssPropertyName.js @@ -0,0 +1,27 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var Cu = Components.utils; +const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const {getCssPropertyName} = require("devtools/client/animationinspector/components/animation-details"); + +const TEST_DATA = [{ + jsName: "alllowercase", + cssName: "alllowercase" +}, { + jsName: "borderWidth", + cssName: "border-width" +}, { + jsName: "borderTopRightRadius", + cssName: "border-top-right-radius" +}]; + +function run_test() { + for (let {jsName, cssName} of TEST_DATA) { + equal(getCssPropertyName(jsName), cssName); + } +} diff --git a/devtools/client/animationinspector/test/unit/test_timeScale.js b/devtools/client/animationinspector/test/unit/test_timeScale.js new file mode 100644 index 000000000..9ee4b8a59 --- /dev/null +++ b/devtools/client/animationinspector/test/unit/test_timeScale.js @@ -0,0 +1,207 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var Cu = Components.utils; +const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const {TimeScale} = require("devtools/client/animationinspector/utils"); +const TEST_ANIMATIONS = [{ + desc: "Testing a few standard animations", + animations: [{ + previousStartTime: 500, + delay: 0, + duration: 1000, + iterationCount: 1, + playbackRate: 1 + }, { + previousStartTime: 400, + delay: 100, + duration: 10, + iterationCount: 100, + playbackRate: 1 + }, { + previousStartTime: 50, + delay: 1000, + duration: 100, + iterationCount: 20, + playbackRate: 1 + }], + expectedMinStart: 50, + expectedMaxEnd: 3050 +}, { + desc: "Testing a single negative-delay animation", + animations: [{ + previousStartTime: 100, + delay: -100, + duration: 100, + iterationCount: 1, + playbackRate: 1 + }], + expectedMinStart: 0, + expectedMaxEnd: 100 +}, { + desc: "Testing a single negative-delay animation with a different rate", + animations: [{ + previousStartTime: 3500, + delay: -1000, + duration: 10000, + iterationCount: 2, + playbackRate: 2 + }], + expectedMinStart: 3000, + expectedMaxEnd: 13000 +}]; + +const TEST_STARTTIME_TO_DISTANCE = [{ + time: 50, + expectedDistance: 0 +}, { + time: 50, + expectedDistance: 0 +}, { + time: 3050, + expectedDistance: 100 +}, { + time: 1550, + expectedDistance: 50 +}]; + +const TEST_DURATION_TO_DISTANCE = [{ + time: 3000, + expectedDistance: 100 +}, { + time: 0, + expectedDistance: 0 +}]; + +const TEST_DISTANCE_TO_TIME = [{ + distance: 100, + expectedTime: 3050 +}, { + distance: 0, + expectedTime: 50 +}, { + distance: 25, + expectedTime: 800 +}]; + +const TEST_DISTANCE_TO_RELATIVE_TIME = [{ + distance: 100, + expectedTime: 3000 +}, { + distance: 0, + expectedTime: 0 +}, { + distance: 25, + expectedTime: 750 +}]; + +const TEST_FORMAT_TIME_MS = [{ + time: 0, + expectedFormattedTime: "0ms" +}, { + time: 3540.341, + expectedFormattedTime: "3540ms" +}, { + time: 1.99, + expectedFormattedTime: "2ms" +}, { + time: 4000, + expectedFormattedTime: "4000ms" +}]; + +const TEST_FORMAT_TIME_S = [{ + time: 0, + expectedFormattedTime: "0.0s" +}, { + time: 3540.341, + expectedFormattedTime: "3.5s" +}, { + time: 1.99, + expectedFormattedTime: "0.0s" +}, { + time: 4000, + expectedFormattedTime: "4.0s" +}, { + time: 102540, + expectedFormattedTime: "102.5s" +}, { + time: 102940, + expectedFormattedTime: "102.9s" +}]; + +function run_test() { + do_print("Check the default min/max range values"); + equal(TimeScale.minStartTime, Infinity); + equal(TimeScale.maxEndTime, 0); + + for (let {desc, animations, expectedMinStart, expectedMaxEnd} of + TEST_ANIMATIONS) { + do_print("Test adding a few animations: " + desc); + for (let state of animations) { + TimeScale.addAnimation(state); + } + + do_print("Checking the time scale range"); + equal(TimeScale.minStartTime, expectedMinStart); + equal(TimeScale.maxEndTime, expectedMaxEnd); + + do_print("Test reseting the animations"); + TimeScale.reset(); + equal(TimeScale.minStartTime, Infinity); + equal(TimeScale.maxEndTime, 0); + } + + do_print("Add a set of animations again"); + for (let state of TEST_ANIMATIONS[0].animations) { + TimeScale.addAnimation(state); + } + + do_print("Test converting start times to distances"); + for (let {time, expectedDistance} of TEST_STARTTIME_TO_DISTANCE) { + let distance = TimeScale.startTimeToDistance(time); + equal(distance, expectedDistance); + } + + do_print("Test converting durations to distances"); + for (let {time, expectedDistance} of TEST_DURATION_TO_DISTANCE) { + let distance = TimeScale.durationToDistance(time); + equal(distance, expectedDistance); + } + + do_print("Test converting distances to times"); + for (let {distance, expectedTime} of TEST_DISTANCE_TO_TIME) { + let time = TimeScale.distanceToTime(distance); + equal(time, expectedTime); + } + + do_print("Test converting distances to relative times"); + for (let {distance, expectedTime} of TEST_DISTANCE_TO_RELATIVE_TIME) { + let time = TimeScale.distanceToRelativeTime(distance); + equal(time, expectedTime); + } + + do_print("Test formatting times (millis)"); + for (let {time, expectedFormattedTime} of TEST_FORMAT_TIME_MS) { + let formattedTime = TimeScale.formatTime(time); + equal(formattedTime, expectedFormattedTime); + } + + // Add 1 more animation to increase the range and test more time formatting + // cases. + TimeScale.addAnimation({ + startTime: 3000, + duration: 5000, + delay: 0, + iterationCount: 1 + }); + + do_print("Test formatting times (seconds)"); + for (let {time, expectedFormattedTime} of TEST_FORMAT_TIME_S) { + let formattedTime = TimeScale.formatTime(time); + equal(formattedTime, expectedFormattedTime); + } +} diff --git a/devtools/client/animationinspector/test/unit/test_timeScale_dimensions.js b/devtools/client/animationinspector/test/unit/test_timeScale_dimensions.js new file mode 100644 index 000000000..f6d80e60b --- /dev/null +++ b/devtools/client/animationinspector/test/unit/test_timeScale_dimensions.js @@ -0,0 +1,54 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const Cu = Components.utils; +const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const {TimeScale} = require("devtools/client/animationinspector/utils"); + +const TEST_ENDDELAY_X = [{ + desc: "Testing positive-endDelay animations", + animations: [{ + previousStartTime: 0, + duration: 500, + playbackRate: 1, + iterationCount: 3, + delay: 500, + endDelay: 500 + }], + expectedEndDelayX: 80 +}, { + desc: "Testing negative-endDelay animations", + animations: [{ + previousStartTime: 0, + duration: 500, + playbackRate: 1, + iterationCount: 9, + delay: 500, + endDelay: -500 + }], + expectedEndDelayX: 90 +}]; + +function run_test() { + do_print("Test calculating endDelayX"); + + // Be independent of possible prior tests + TimeScale.reset(); + + for (let {desc, animations, expectedEndDelayX} of TEST_ENDDELAY_X) { + do_print(`Adding animations: ${desc}`); + + for (let state of animations) { + TimeScale.addAnimation(state); + + let {endDelayX} = TimeScale.getAnimationDimensions({state}); + equal(endDelayX, expectedEndDelayX); + + TimeScale.reset(); + } + } +} diff --git a/devtools/client/animationinspector/test/unit/xpcshell.ini b/devtools/client/animationinspector/test/unit/xpcshell.ini new file mode 100644 index 000000000..c88e01cf9 --- /dev/null +++ b/devtools/client/animationinspector/test/unit/xpcshell.ini @@ -0,0 +1,12 @@ +[DEFAULT] +tags = devtools +head = +tail = +firefox-appdir = browser +skip-if = toolkit == 'android' + +[test_findOptimalTimeInterval.js] +[test_formatStopwatchTime.js] +[test_getCssPropertyName.js] +[test_timeScale.js] +[test_timeScale_dimensions.js] diff --git a/devtools/client/animationinspector/utils.js b/devtools/client/animationinspector/utils.js new file mode 100644 index 000000000..4b6891ac1 --- /dev/null +++ b/devtools/client/animationinspector/utils.js @@ -0,0 +1,275 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); + +const { LocalizationHelper } = require("devtools/shared/l10n"); +const L10N = + new LocalizationHelper("devtools/client/locales/animationinspector.properties"); + +// How many times, maximum, can we loop before we find the optimal time +// interval in the timeline graph. +const OPTIMAL_TIME_INTERVAL_MAX_ITERS = 100; +// Time graduations should be multiple of one of these number. +const OPTIMAL_TIME_INTERVAL_MULTIPLES = [1, 2.5, 5]; + +const MILLIS_TIME_FORMAT_MAX_DURATION = 4000; + +/** + * DOM node creation helper function. + * @param {Object} Options to customize the node to be created. + * - nodeType {String} Optional, defaults to "div", + * - attributes {Object} Optional attributes object like + * {attrName1:value1, attrName2: value2, ...} + * - parent {DOMNode} Mandatory node to append the newly created node to. + * - textContent {String} Optional text for the node. + * - namespace {String} Optional namespace + * @return {DOMNode} The newly created node. + */ +function createNode(options) { + if (!options.parent) { + throw new Error("Missing parent DOMNode to create new node"); + } + + let type = options.nodeType || "div"; + let node = + options.namespace + ? options.parent.ownerDocument.createElementNS(options.namespace, type) + : options.parent.ownerDocument.createElement(type); + + for (let name in options.attributes || {}) { + let value = options.attributes[name]; + node.setAttribute(name, value); + } + + if (options.textContent) { + node.textContent = options.textContent; + } + + options.parent.appendChild(node); + return node; +} + +exports.createNode = createNode; + +/** + * Find the optimal interval between time graduations in the animation timeline + * graph based on a minimum time interval + * @param {Number} minTimeInterval Minimum time in ms in one interval + * @return {Number} The optimal interval time in ms + */ +function findOptimalTimeInterval(minTimeInterval) { + let numIters = 0; + let multiplier = 1; + + if (!minTimeInterval) { + return 0; + } + + let interval; + while (true) { + for (let i = 0; i < OPTIMAL_TIME_INTERVAL_MULTIPLES.length; i++) { + interval = OPTIMAL_TIME_INTERVAL_MULTIPLES[i] * multiplier; + if (minTimeInterval <= interval) { + return interval; + } + } + if (++numIters > OPTIMAL_TIME_INTERVAL_MAX_ITERS) { + return interval; + } + multiplier *= 10; + } +} + +exports.findOptimalTimeInterval = findOptimalTimeInterval; + +/** + * Format a timestamp (in ms) as a mm:ss.mmm string. + * @param {Number} time + * @return {String} + */ +function formatStopwatchTime(time) { + // Format falsy values as 0 + if (!time) { + return "00:00.000"; + } + + let milliseconds = parseInt(time % 1000, 10); + let seconds = parseInt((time / 1000) % 60, 10); + let minutes = parseInt((time / (1000 * 60)), 10); + + let pad = (nb, max) => { + if (nb < max) { + return new Array((max + "").length - (nb + "").length + 1).join("0") + nb; + } + return nb; + }; + + minutes = pad(minutes, 10); + seconds = pad(seconds, 10); + milliseconds = pad(milliseconds, 100); + + return `${minutes}:${seconds}.${milliseconds}`; +} + +exports.formatStopwatchTime = formatStopwatchTime; + +/** + * The TimeScale helper object is used to know which size should something be + * displayed with in the animation panel, depending on the animations that are + * currently displayed. + * If there are 5 animations displayed, and the first one starts at 10000ms and + * the last one ends at 20000ms, then this helper can be used to convert any + * time in this range to a distance in pixels. + * + * For the helper to know how to convert, it needs to know all the animations. + * Whenever a new animation is added to the panel, addAnimation(state) should be + * called. reset() can be called to start over. + */ +var TimeScale = { + minStartTime: Infinity, + maxEndTime: 0, + + /** + * Add a new animation to time scale. + * @param {Object} state A PlayerFront.state object. + */ + addAnimation: function (state) { + let {previousStartTime, delay, duration, endDelay, + iterationCount, playbackRate} = state; + + endDelay = typeof endDelay === "undefined" ? 0 : endDelay; + let toRate = v => v / playbackRate; + let minZero = v => Math.max(v, 0); + let rateRelativeDuration = + toRate(duration * (!iterationCount ? 1 : iterationCount)); + // Negative-delayed animations have their startTimes set such that we would + // be displaying the delay outside the time window if we didn't take it into + // account here. + let relevantDelay = delay < 0 ? toRate(delay) : 0; + previousStartTime = previousStartTime || 0; + + let startTime = toRate(minZero(delay)) + + rateRelativeDuration + + endDelay; + this.minStartTime = Math.min( + this.minStartTime, + previousStartTime + + relevantDelay + + Math.min(startTime, 0) + ); + let length = toRate(delay) + + rateRelativeDuration + + toRate(minZero(endDelay)); + let endTime = previousStartTime + length; + this.maxEndTime = Math.max(this.maxEndTime, endTime); + }, + + /** + * Reset the current time scale. + */ + reset: function () { + this.minStartTime = Infinity; + this.maxEndTime = 0; + }, + + /** + * Convert a startTime to a distance in %, in the current time scale. + * @param {Number} time + * @return {Number} + */ + startTimeToDistance: function (time) { + time -= this.minStartTime; + return this.durationToDistance(time); + }, + + /** + * Convert a duration to a distance in %, in the current time scale. + * @param {Number} time + * @return {Number} + */ + durationToDistance: function (duration) { + return duration * 100 / this.getDuration(); + }, + + /** + * Convert a distance in % to a time, in the current time scale. + * @param {Number} distance + * @return {Number} + */ + distanceToTime: function (distance) { + return this.minStartTime + (this.getDuration() * distance / 100); + }, + + /** + * Convert a distance in % to a time, in the current time scale. + * The time will be relative to the current minimum start time. + * @param {Number} distance + * @return {Number} + */ + distanceToRelativeTime: function (distance) { + let time = this.distanceToTime(distance); + return time - this.minStartTime; + }, + + /** + * Depending on the time scale, format the given time as milliseconds or + * seconds. + * @param {Number} time + * @return {String} The formatted time string. + */ + formatTime: function (time) { + // Format in milliseconds if the total duration is short enough. + if (this.getDuration() <= MILLIS_TIME_FORMAT_MAX_DURATION) { + return L10N.getFormatStr("timeline.timeGraduationLabel", time.toFixed(0)); + } + + // Otherwise format in seconds. + return L10N.getFormatStr("player.timeLabel", (time / 1000).toFixed(1)); + }, + + getDuration: function () { + return this.maxEndTime - this.minStartTime; + }, + + /** + * Given an animation, get the various dimensions (in %) useful to draw the + * animation in the timeline. + */ + getAnimationDimensions: function ({state}) { + let start = state.previousStartTime || 0; + let duration = state.duration; + let rate = state.playbackRate; + let count = state.iterationCount; + let delay = state.delay || 0; + let endDelay = state.endDelay || 0; + + // The start position. + let x = this.startTimeToDistance(start + (delay / rate)); + // The width for a single iteration. + let w = this.durationToDistance(duration / rate); + // The width for all iterations. + let iterationW = w * (count || 1); + // The start position of the delay. + let delayX = delay < 0 ? x : this.startTimeToDistance(start); + // The width of the delay. + let delayW = this.durationToDistance(Math.abs(delay) / rate); + // The width of the delay if it is negative, 0 otherwise. + let negativeDelayW = delay < 0 ? delayW : 0; + // The width of the endDelay. + let endDelayW = this.durationToDistance(Math.abs(endDelay) / rate); + // The start position of the endDelay. + let endDelayX = endDelay < 0 ? x + iterationW - endDelayW + : x + iterationW; + + return {x, w, iterationW, delayX, delayW, negativeDelayW, + endDelayX, endDelayW}; + } +}; + +exports.TimeScale = TimeScale; |