summaryrefslogtreecommitdiffstats
path: root/devtools/client/animationinspector
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/animationinspector')
-rw-r--r--devtools/client/animationinspector/animation-controller.js390
-rw-r--r--devtools/client/animationinspector/animation-inspector.xhtml32
-rw-r--r--devtools/client/animationinspector/animation-panel.js347
-rw-r--r--devtools/client/animationinspector/components/animation-details.js222
-rw-r--r--devtools/client/animationinspector/components/animation-target-node.js80
-rw-r--r--devtools/client/animationinspector/components/animation-time-block.js719
-rw-r--r--devtools/client/animationinspector/components/animation-timeline.js502
-rw-r--r--devtools/client/animationinspector/components/keyframes.js81
-rw-r--r--devtools/client/animationinspector/components/moz.build12
-rw-r--r--devtools/client/animationinspector/components/rate-selector.js105
-rw-r--r--devtools/client/animationinspector/moz.build16
-rw-r--r--devtools/client/animationinspector/test/.eslintrc.js6
-rw-r--r--devtools/client/animationinspector/test/browser.ini71
-rw-r--r--devtools/client/animationinspector/test/browser_animation_animated_properties_displayed.js91
-rw-r--r--devtools/client/animationinspector/test/browser_animation_click_selects_animation.js44
-rw-r--r--devtools/client/animationinspector/test/browser_animation_controller_exposes_document_currentTime.js43
-rw-r--r--devtools/client/animationinspector/test/browser_animation_empty_on_invalid_nodes.js42
-rw-r--r--devtools/client/animationinspector/test/browser_animation_keyframe_click_to_set_time.js52
-rw-r--r--devtools/client/animationinspector/test/browser_animation_keyframe_markers.js74
-rw-r--r--devtools/client/animationinspector/test/browser_animation_mutations_with_same_names.js31
-rw-r--r--devtools/client/animationinspector/test/browser_animation_panel_exists.js23
-rw-r--r--devtools/client/animationinspector/test/browser_animation_participate_in_inspector_update.js46
-rw-r--r--devtools/client/animationinspector/test/browser_animation_playerFronts_are_refreshed.js36
-rw-r--r--devtools/client/animationinspector/test/browser_animation_playerWidgets_appear_on_panel_init.js41
-rw-r--r--devtools/client/animationinspector/test/browser_animation_playerWidgets_target_nodes.js33
-rw-r--r--devtools/client/animationinspector/test/browser_animation_pseudo_elements.js49
-rw-r--r--devtools/client/animationinspector/test/browser_animation_refresh_on_added_animation.js47
-rw-r--r--devtools/client/animationinspector/test/browser_animation_refresh_on_removed_animation.js50
-rw-r--r--devtools/client/animationinspector/test/browser_animation_refresh_when_active.js53
-rw-r--r--devtools/client/animationinspector/test/browser_animation_running_on_compositor.js57
-rw-r--r--devtools/client/animationinspector/test/browser_animation_same_nb_of_playerWidgets_and_playerFronts.js23
-rw-r--r--devtools/client/animationinspector/test/browser_animation_shows_player_on_valid_node.js21
-rw-r--r--devtools/client/animationinspector/test/browser_animation_spacebar_toggles_animations.js49
-rw-r--r--devtools/client/animationinspector/test/browser_animation_spacebar_toggles_node_animations.js45
-rw-r--r--devtools/client/animationinspector/test/browser_animation_target_highlight_select.js73
-rw-r--r--devtools/client/animationinspector/test/browser_animation_target_highlighter_lock.js54
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_currentTime.js48
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_header.js59
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_iterationStart.js71
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_pause_button_01.js34
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_pause_button_02.js48
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_pause_button_03.js60
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_rate_selector.js56
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_rewind_button.js51
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_scrubber_exists.js20
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_scrubber_movable.js70
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_scrubber_moves.js28
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_setCurrentTime.js88
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_shows_delay.js96
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_shows_endDelay.js78
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_shows_iterations.js47
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_shows_name_label.js46
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_shows_time_info.js50
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_takes_rate_into_account.js81
-rw-r--r--devtools/client/animationinspector/test/browser_animation_timeline_ui.js43
-rw-r--r--devtools/client/animationinspector/test/browser_animation_toggle_button_resets_on_navigate.js31
-rw-r--r--devtools/client/animationinspector/test/browser_animation_toggle_button_toggles_animations.js32
-rw-r--r--devtools/client/animationinspector/test/browser_animation_toolbar_exists.js36
-rw-r--r--devtools/client/animationinspector/test/browser_animation_ui_updates_when_animation_data_changes.js53
-rw-r--r--devtools/client/animationinspector/test/doc_body_animation.html23
-rw-r--r--devtools/client/animationinspector/test/doc_end_delay.html69
-rw-r--r--devtools/client/animationinspector/test/doc_frame_script.js122
-rw-r--r--devtools/client/animationinspector/test/doc_keyframes.html55
-rw-r--r--devtools/client/animationinspector/test/doc_modify_playbackRate.html32
-rw-r--r--devtools/client/animationinspector/test/doc_multiple_animation_types.html61
-rw-r--r--devtools/client/animationinspector/test/doc_negative_animation.html66
-rw-r--r--devtools/client/animationinspector/test/doc_pseudo_elements.html61
-rw-r--r--devtools/client/animationinspector/test/doc_script_animation.html71
-rw-r--r--devtools/client/animationinspector/test/doc_simple_animation.html147
-rw-r--r--devtools/client/animationinspector/test/doc_timing_combination_animation.html35
-rw-r--r--devtools/client/animationinspector/test/head.js426
-rw-r--r--devtools/client/animationinspector/test/unit/.eslintrc.js6
-rw-r--r--devtools/client/animationinspector/test/unit/test_findOptimalTimeInterval.js81
-rw-r--r--devtools/client/animationinspector/test/unit/test_formatStopwatchTime.js62
-rw-r--r--devtools/client/animationinspector/test/unit/test_getCssPropertyName.js27
-rw-r--r--devtools/client/animationinspector/test/unit/test_timeScale.js207
-rw-r--r--devtools/client/animationinspector/test/unit/test_timeScale_dimensions.js54
-rw-r--r--devtools/client/animationinspector/test/unit/xpcshell.ini12
-rw-r--r--devtools/client/animationinspector/utils.js275
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;