summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/animation
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /devtools/client/inspector/animation
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--devtools/client/inspector/animation/actions/animations.js86
-rw-r--r--devtools/client/inspector/animation/actions/index.js33
-rw-r--r--devtools/client/inspector/animation/actions/moz.build8
-rw-r--r--devtools/client/inspector/animation/animation.js802
-rw-r--r--devtools/client/inspector/animation/components/AnimatedPropertyItem.js64
-rw-r--r--devtools/client/inspector/animation/components/AnimatedPropertyList.js140
-rw-r--r--devtools/client/inspector/animation/components/AnimatedPropertyListContainer.js90
-rw-r--r--devtools/client/inspector/animation/components/AnimatedPropertyName.js37
-rw-r--r--devtools/client/inspector/animation/components/AnimationDetailContainer.js90
-rw-r--r--devtools/client/inspector/animation/components/AnimationDetailHeader.js52
-rw-r--r--devtools/client/inspector/animation/components/AnimationItem.js121
-rw-r--r--devtools/client/inspector/animation/components/AnimationList.js72
-rw-r--r--devtools/client/inspector/animation/components/AnimationListContainer.js224
-rw-r--r--devtools/client/inspector/animation/components/AnimationTarget.js182
-rw-r--r--devtools/client/inspector/animation/components/AnimationToolbar.js75
-rw-r--r--devtools/client/inspector/animation/components/App.js164
-rw-r--r--devtools/client/inspector/animation/components/CurrentTimeLabel.js76
-rw-r--r--devtools/client/inspector/animation/components/CurrentTimeScrubber.js131
-rw-r--r--devtools/client/inspector/animation/components/KeyframesProgressBar.js108
-rw-r--r--devtools/client/inspector/animation/components/NoAnimationPanel.js61
-rw-r--r--devtools/client/inspector/animation/components/PauseResumeButton.js104
-rw-r--r--devtools/client/inspector/animation/components/PlaybackRateSelector.js108
-rw-r--r--devtools/client/inspector/animation/components/ProgressInspectionPanel.js49
-rw-r--r--devtools/client/inspector/animation/components/RewindButton.js38
-rw-r--r--devtools/client/inspector/animation/components/TickLabels.js46
-rw-r--r--devtools/client/inspector/animation/components/TickLines.js40
-rw-r--r--devtools/client/inspector/animation/components/graph/AnimationName.js38
-rw-r--r--devtools/client/inspector/animation/components/graph/ComputedTimingPath.js104
-rw-r--r--devtools/client/inspector/animation/components/graph/DelaySign.js42
-rw-r--r--devtools/client/inspector/animation/components/graph/EffectTimingPath.js84
-rw-r--r--devtools/client/inspector/animation/components/graph/EndDelaySign.js44
-rw-r--r--devtools/client/inspector/animation/components/graph/NegativeDelayPath.js27
-rw-r--r--devtools/client/inspector/animation/components/graph/NegativeEndDelayPath.js27
-rw-r--r--devtools/client/inspector/animation/components/graph/NegativePath.js101
-rw-r--r--devtools/client/inspector/animation/components/graph/SummaryGraph.js205
-rw-r--r--devtools/client/inspector/animation/components/graph/SummaryGraphPath.js282
-rw-r--r--devtools/client/inspector/animation/components/graph/TimingPath.js450
-rw-r--r--devtools/client/inspector/animation/components/graph/moz.build17
-rw-r--r--devtools/client/inspector/animation/components/keyframes-graph/ColorPath.js209
-rw-r--r--devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js245
-rw-r--r--devtools/client/inspector/animation/components/keyframes-graph/DiscretePath.js67
-rw-r--r--devtools/client/inspector/animation/components/keyframes-graph/DistancePath.js34
-rw-r--r--devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerItem.js33
-rw-r--r--devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerList.js37
-rw-r--r--devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraph.js52
-rw-r--r--devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraphPath.js111
-rw-r--r--devtools/client/inspector/animation/components/keyframes-graph/moz.build14
-rw-r--r--devtools/client/inspector/animation/components/moz.build30
-rw-r--r--devtools/client/inspector/animation/current-time-timer.js75
-rw-r--r--devtools/client/inspector/animation/moz.build12
-rw-r--r--devtools/client/inspector/animation/reducers/animations.js117
-rw-r--r--devtools/client/inspector/animation/reducers/moz.build7
-rw-r--r--devtools/client/inspector/animation/test/browser.toml221
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_animated-property-list.js52
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_animated-property-list_unchanged-items.js58
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_animated-property-name.js127
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_animation-detail_close-button.js27
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_animation-detail_title.js44
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_animation-detail_visibility.js52
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_animation-list.js36
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_animation-list_one-animation-select.js30
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_animation-list_select.js38
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_animation-target.js61
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_animation-target_highlight.js118
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_animation-target_select.js64
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_animation-timeline-tick.js109
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_css-transition-with-playstate-idle.js81
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_current-time-label.js73
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_current-time-scrubber-rtl.js18
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_current-time-scrubber-with-negative-delay.js48
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_current-time-scrubber.js14
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_current-time-scrubber_each-different-creation-time-animations.js34
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_empty_on_invalid_nodes.js49
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_fission_switch-target.js99
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_indication-bar.js42
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_infinity-duration_current-time-scrubber.js40
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_infinity-duration_summary-graph.js147
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_infinity-duration_tick-label.js29
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-01.js164
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-02.js90
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-03.js190
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path_easing-hint.js380
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_keyframes-graph_keyframe-marker-rtl.js14
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_keyframes-graph_keyframe-marker.js13
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_keyframes-graph_special-colors.js40
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_keyframes-progress-bar.js103
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_keyframes-progress-bar_after-resuming.js64
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_logic_adjust-time-with-playback-rate.js50
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_logic_adjust-time.js50
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_logic_auto-stop.js94
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_logic_avoid-updating-during-hiding.js92
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_logic_created-time.js57
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_logic_mutations.js113
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_logic_mutations_add_remove_immediately.js29
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_logic_mutations_fast.js24
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_logic_mutations_properties.js103
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_logic_overflowed_delay_end-delay.js29
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_logic_scroll-amount.js70
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_pause-resume-button.js41
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_pause-resume-button_end-time.js79
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_pause-resume-button_respectively.js96
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_pause-resume-button_spacebar.js49
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_playback-rate-selector.js81
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_pseudo-element.js129
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_rewind-button.js33
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_short-duration.js43
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_summary-graph_animation-name.js63
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_summary-graph_compositor.js121
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_1.js208
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_2.js192
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_different-timescale.js43
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_summary-graph_delay-sign-rtl.js14
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_summary-graph_delay-sign.js13
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_summary-graph_effect-timing-path.js64
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_summary-graph_end-delay-sign-rtl.js15
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_summary-graph_end-delay-sign.js13
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_summary-graph_layout-by-seek.js150
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_summary-graph_negative-delay-path.js128
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_summary-graph_negative-end-delay-path.js56
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_summary-graph_tooltip.js294
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_timing_negative-playback-rate_current-time-scrubber.js47
-rw-r--r--devtools/client/inspector/animation/test/browser_animation_timing_negative-playback-rate_summary-graph.js235
-rw-r--r--devtools/client/inspector/animation/test/current-time-scrubber_head.js101
-rw-r--r--devtools/client/inspector/animation/test/doc_custom_playback_rate.html30
-rw-r--r--devtools/client/inspector/animation/test/doc_infinity_duration.html41
-rw-r--r--devtools/client/inspector/animation/test/doc_multi_easings.html121
-rw-r--r--devtools/client/inspector/animation/test/doc_multi_keyframes.html229
-rw-r--r--devtools/client/inspector/animation/test/doc_multi_timings.html169
-rw-r--r--devtools/client/inspector/animation/test/doc_mutations_add_remove_immediately.html22
-rw-r--r--devtools/client/inspector/animation/test/doc_mutations_fast.html53
-rw-r--r--devtools/client/inspector/animation/test/doc_negative_playback_rate.html38
-rw-r--r--devtools/client/inspector/animation/test/doc_overflowed_delay_end_delay.html75
-rw-r--r--devtools/client/inspector/animation/test/doc_pseudo.html91
-rw-r--r--devtools/client/inspector/animation/test/doc_short_duration.html26
-rw-r--r--devtools/client/inspector/animation/test/doc_simple_animation.html193
-rw-r--r--devtools/client/inspector/animation/test/doc_special_colors.html28
-rw-r--r--devtools/client/inspector/animation/test/head.js1028
-rw-r--r--devtools/client/inspector/animation/test/keyframes-graph_keyframe-marker_head.js237
-rw-r--r--devtools/client/inspector/animation/test/summary-graph_computed-timing-path_head.js103
-rw-r--r--devtools/client/inspector/animation/test/summary-graph_delay-sign_head.js106
-rw-r--r--devtools/client/inspector/animation/test/summary-graph_end-delay-sign_head.js100
-rw-r--r--devtools/client/inspector/animation/utils/graph-helper.js332
-rw-r--r--devtools/client/inspector/animation/utils/l10n.js46
-rw-r--r--devtools/client/inspector/animation/utils/moz.build10
-rw-r--r--devtools/client/inspector/animation/utils/timescale.js145
-rw-r--r--devtools/client/inspector/animation/utils/utils.js70
146 files changed, 14546 insertions, 0 deletions
diff --git a/devtools/client/inspector/animation/actions/animations.js b/devtools/client/inspector/animation/actions/animations.js
new file mode 100644
index 0000000000..3604630441
--- /dev/null
+++ b/devtools/client/inspector/animation/actions/animations.js
@@ -0,0 +1,86 @@
+/* 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 {
+ UPDATE_ANIMATIONS,
+ UPDATE_DETAIL_VISIBILITY,
+ UPDATE_ELEMENT_PICKER_ENABLED,
+ UPDATE_HIGHLIGHTED_NODE,
+ UPDATE_PLAYBACK_RATES,
+ UPDATE_SELECTED_ANIMATION,
+ UPDATE_SIDEBAR_SIZE,
+} = require("resource://devtools/client/inspector/animation/actions/index.js");
+
+module.exports = {
+ /**
+ * Update the list of animation in the animation inspector.
+ */
+ updateAnimations(animations) {
+ return {
+ type: UPDATE_ANIMATIONS,
+ animations,
+ };
+ },
+
+ /**
+ * Update visibility of detail pane.
+ */
+ updateDetailVisibility(detailVisibility) {
+ return {
+ type: UPDATE_DETAIL_VISIBILITY,
+ detailVisibility,
+ };
+ },
+
+ /**
+ * Update the state of element picker in animation inspector.
+ */
+ updateElementPickerEnabled(elementPickerEnabled) {
+ return {
+ type: UPDATE_ELEMENT_PICKER_ENABLED,
+ elementPickerEnabled,
+ };
+ },
+
+ /**
+ * Update the highlighted node.
+ */
+ updateHighlightedNode(nodeFront) {
+ return {
+ type: UPDATE_HIGHLIGHTED_NODE,
+ highlightedNode: nodeFront ? nodeFront.actorID : null,
+ };
+ },
+
+ /**
+ * Update the playback rates.
+ */
+ updatePlaybackRates() {
+ return {
+ type: UPDATE_PLAYBACK_RATES,
+ };
+ },
+
+ /**
+ * Update selected animation.
+ */
+ updateSelectedAnimation(selectedAnimation) {
+ return {
+ type: UPDATE_SELECTED_ANIMATION,
+ selectedAnimation,
+ };
+ },
+
+ /**
+ * Update the sidebar size.
+ */
+ updateSidebarSize(sidebarSize) {
+ return {
+ type: UPDATE_SIDEBAR_SIZE,
+ sidebarSize,
+ };
+ },
+};
diff --git a/devtools/client/inspector/animation/actions/index.js b/devtools/client/inspector/animation/actions/index.js
new file mode 100644
index 0000000000..572071604c
--- /dev/null
+++ b/devtools/client/inspector/animation/actions/index.js
@@ -0,0 +1,33 @@
+/* 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 { createEnum } = require("resource://devtools/client/shared/enum.js");
+
+createEnum(
+ [
+ // Update the list of animation.
+ "UPDATE_ANIMATIONS",
+
+ // Update visibility of detail pane.
+ "UPDATE_DETAIL_VISIBILITY",
+
+ // Update state of the picker enabled.
+ "UPDATE_ELEMENT_PICKER_ENABLED",
+
+ // Update highlighted node.
+ "UPDATE_HIGHLIGHTED_NODE",
+
+ // Update playback rate.
+ "UPDATE_PLAYBACK_RATES",
+
+ // Update selected animation.
+ "UPDATE_SELECTED_ANIMATION",
+
+ // Update sidebar size.
+ "UPDATE_SIDEBAR_SIZE",
+ ],
+ module.exports
+);
diff --git a/devtools/client/inspector/animation/actions/moz.build b/devtools/client/inspector/animation/actions/moz.build
new file mode 100644
index 0000000000..f43007dd6e
--- /dev/null
+++ b/devtools/client/inspector/animation/actions/moz.build
@@ -0,0 +1,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/.
+
+DevToolsModules(
+ "animations.js",
+ "index.js",
+)
diff --git a/devtools/client/inspector/animation/animation.js b/devtools/client/inspector/animation/animation.js
new file mode 100644
index 0000000000..c0f5d389d0
--- /dev/null
+++ b/devtools/client/inspector/animation/animation.js
@@ -0,0 +1,802 @@
+/* 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 {
+ createElement,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const {
+ Provider,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+const App = createFactory(
+ require("resource://devtools/client/inspector/animation/components/App.js")
+);
+const CurrentTimeTimer = require("resource://devtools/client/inspector/animation/current-time-timer.js");
+
+const animationsReducer = require("resource://devtools/client/inspector/animation/reducers/animations.js");
+const {
+ updateAnimations,
+ updateDetailVisibility,
+ updateElementPickerEnabled,
+ updateHighlightedNode,
+ updatePlaybackRates,
+ updateSelectedAnimation,
+ updateSidebarSize,
+} = require("resource://devtools/client/inspector/animation/actions/animations.js");
+const {
+ hasAnimationIterationCountInfinite,
+ hasRunningAnimation,
+} = require("resource://devtools/client/inspector/animation/utils/utils.js");
+
+class AnimationInspector {
+ constructor(inspector, win) {
+ this.inspector = inspector;
+ this.win = win;
+
+ this.inspector.store.injectReducer("animations", animationsReducer);
+
+ this.addAnimationsCurrentTimeListener =
+ this.addAnimationsCurrentTimeListener.bind(this);
+ this.getAnimatedPropertyMap = this.getAnimatedPropertyMap.bind(this);
+ this.getAnimationsCurrentTime = this.getAnimationsCurrentTime.bind(this);
+ this.getComputedStyle = this.getComputedStyle.bind(this);
+ this.getNodeFromActor = this.getNodeFromActor.bind(this);
+ this.removeAnimationsCurrentTimeListener =
+ this.removeAnimationsCurrentTimeListener.bind(this);
+ this.rewindAnimationsCurrentTime =
+ this.rewindAnimationsCurrentTime.bind(this);
+ this.selectAnimation = this.selectAnimation.bind(this);
+ this.setAnimationsCurrentTime = this.setAnimationsCurrentTime.bind(this);
+ this.setAnimationsPlaybackRate = this.setAnimationsPlaybackRate.bind(this);
+ this.setAnimationsPlayState = this.setAnimationsPlayState.bind(this);
+ this.setDetailVisibility = this.setDetailVisibility.bind(this);
+ this.setHighlightedNode = this.setHighlightedNode.bind(this);
+ this.setSelectedNode = this.setSelectedNode.bind(this);
+ this.simulateAnimation = this.simulateAnimation.bind(this);
+ this.simulateAnimationForKeyframesProgressBar =
+ this.simulateAnimationForKeyframesProgressBar.bind(this);
+ this.toggleElementPicker = this.toggleElementPicker.bind(this);
+ this.update = this.update.bind(this);
+ this.onAnimationStateChanged = this.onAnimationStateChanged.bind(this);
+ this.onAnimationsCurrentTimeUpdated =
+ this.onAnimationsCurrentTimeUpdated.bind(this);
+ this.onAnimationsMutation = this.onAnimationsMutation.bind(this);
+ this.onCurrentTimeTimerUpdated = this.onCurrentTimeTimerUpdated.bind(this);
+ this.onElementPickerStarted = this.onElementPickerStarted.bind(this);
+ this.onElementPickerStopped = this.onElementPickerStopped.bind(this);
+ this.onNavigate = this.onNavigate.bind(this);
+ this.onSidebarResized = this.onSidebarResized.bind(this);
+ this.onSidebarSelectionChanged = this.onSidebarSelectionChanged.bind(this);
+ this.onTargetAvailable = this.onTargetAvailable.bind(this);
+
+ EventEmitter.decorate(this);
+ this.emitForTests = this.emitForTests.bind(this);
+
+ this.initComponents();
+ this.initListeners();
+ }
+
+ initComponents() {
+ const {
+ addAnimationsCurrentTimeListener,
+ emitForTests: emitEventForTest,
+ getAnimatedPropertyMap,
+ getAnimationsCurrentTime,
+ getComputedStyle,
+ getNodeFromActor,
+ isAnimationsRunning,
+ removeAnimationsCurrentTimeListener,
+ rewindAnimationsCurrentTime,
+ selectAnimation,
+ setAnimationsCurrentTime,
+ setAnimationsPlaybackRate,
+ setAnimationsPlayState,
+ setDetailVisibility,
+ setHighlightedNode,
+ setSelectedNode,
+ simulateAnimation,
+ simulateAnimationForKeyframesProgressBar,
+ toggleElementPicker,
+ } = this;
+
+ const direction = this.win.document.dir;
+
+ this.animationsCurrentTimeListeners = [];
+ this.isCurrentTimeSet = false;
+
+ const provider = createElement(
+ Provider,
+ {
+ id: "animationinspector",
+ key: "animationinspector",
+ store: this.inspector.store,
+ },
+ App({
+ addAnimationsCurrentTimeListener,
+ direction,
+ emitEventForTest,
+ getAnimatedPropertyMap,
+ getAnimationsCurrentTime,
+ getComputedStyle,
+ getNodeFromActor,
+ isAnimationsRunning,
+ removeAnimationsCurrentTimeListener,
+ rewindAnimationsCurrentTime,
+ selectAnimation,
+ setAnimationsCurrentTime,
+ setAnimationsPlaybackRate,
+ setAnimationsPlayState,
+ setDetailVisibility,
+ setHighlightedNode,
+ setSelectedNode,
+ simulateAnimation,
+ simulateAnimationForKeyframesProgressBar,
+ toggleElementPicker,
+ })
+ );
+ this.provider = provider;
+ }
+
+ async initListeners() {
+ await this.inspector.commands.targetCommand.watchTargets({
+ types: [this.inspector.commands.targetCommand.TYPES.FRAME],
+ onAvailable: this.onTargetAvailable,
+ });
+
+ this.inspector.on("new-root", this.onNavigate);
+ this.inspector.selection.on("new-node-front", this.update);
+ this.inspector.sidebar.on("select", this.onSidebarSelectionChanged);
+ this.inspector.toolbox.on("select", this.onSidebarSelectionChanged);
+ this.inspector.toolbox.on(
+ "inspector-sidebar-resized",
+ this.onSidebarResized
+ );
+ this.inspector.toolbox.nodePicker.on(
+ "picker-started",
+ this.onElementPickerStarted
+ );
+ this.inspector.toolbox.nodePicker.on(
+ "picker-stopped",
+ this.onElementPickerStopped
+ );
+ }
+
+ destroy() {
+ this.setAnimationStateChangedListenerEnabled(false);
+ this.inspector.off("new-root", this.onNavigate);
+ this.inspector.selection.off("new-node-front", this.update);
+ this.inspector.sidebar.off("select", this.onSidebarSelectionChanged);
+ this.inspector.toolbox.off(
+ "inspector-sidebar-resized",
+ this.onSidebarResized
+ );
+ this.inspector.toolbox.nodePicker.off(
+ "picker-started",
+ this.onElementPickerStarted
+ );
+ this.inspector.toolbox.nodePicker.off(
+ "picker-stopped",
+ this.onElementPickerStopped
+ );
+ this.inspector.toolbox.off("select", this.onSidebarSelectionChanged);
+
+ if (this.animationsFront) {
+ this.animationsFront.off("mutations", this.onAnimationsMutation);
+ }
+
+ if (this.simulatedAnimation) {
+ this.simulatedAnimation.cancel();
+ this.simulatedAnimation = null;
+ }
+
+ if (this.simulatedElement) {
+ this.simulatedElement.remove();
+ this.simulatedElement = null;
+ }
+
+ if (this.simulatedAnimationForKeyframesProgressBar) {
+ this.simulatedAnimationForKeyframesProgressBar.cancel();
+ this.simulatedAnimationForKeyframesProgressBar = null;
+ }
+
+ this.stopAnimationsCurrentTimeTimer();
+
+ this.inspector = null;
+ this.win = null;
+ }
+
+ get state() {
+ return this.inspector.store.getState().animations;
+ }
+
+ addAnimationsCurrentTimeListener(listener) {
+ this.animationsCurrentTimeListeners.push(listener);
+ }
+
+ /**
+ * This function calls AnimationsFront.setCurrentTimes with considering the createdTime.
+ *
+ * @param {Number} currentTime
+ */
+ async doSetCurrentTimes(currentTime) {
+ const { animations, timeScale } = this.state;
+ currentTime = currentTime + timeScale.minStartTime;
+ await this.animationsFront.setCurrentTimes(animations, currentTime, true, {
+ relativeToCreatedTime: true,
+ });
+ }
+
+ /**
+ * Return a map of animated property from given animation actor.
+ *
+ * @param {Object} animation
+ * @return {Map} A map of animated property
+ * key: {String} Animated property name
+ * value: {Array} Array of keyframe object
+ * Also, the keyframe object is consisted as following.
+ * {
+ * value: {String} style,
+ * offset: {Number} offset of keyframe,
+ * easing: {String} easing from this keyframe to next keyframe,
+ * distance: {Number} use as y coordinate in graph,
+ * }
+ */
+ getAnimatedPropertyMap(animation) {
+ const properties = animation.state.properties;
+ const animatedPropertyMap = new Map();
+
+ for (const { name, values } of properties) {
+ const keyframes = values.map(
+ ({ value, offset, easing, distance = 0 }) => {
+ offset = parseFloat(offset.toFixed(3));
+ return { value, offset, easing, distance };
+ }
+ );
+
+ animatedPropertyMap.set(name, keyframes);
+ }
+
+ return animatedPropertyMap;
+ }
+
+ getAnimationsCurrentTime() {
+ return this.currentTime;
+ }
+
+ /**
+ * Return the computed style of the specified property after setting the given styles
+ * to the simulated element.
+ *
+ * @param {String} property
+ * CSS property name (e.g. text-align).
+ * @param {Object} styles
+ * Map of CSS property name and value.
+ * @return {String}
+ * Computed style of property.
+ */
+ getComputedStyle(property, styles) {
+ this.simulatedElement.style.cssText = "";
+
+ for (const propertyName in styles) {
+ this.simulatedElement.style.setProperty(
+ propertyName,
+ styles[propertyName]
+ );
+ }
+
+ return this.win
+ .getComputedStyle(this.simulatedElement)
+ .getPropertyValue(property);
+ }
+
+ getNodeFromActor(actorID) {
+ if (!this.inspector) {
+ return Promise.reject("Animation inspector already destroyed");
+ }
+
+ return this.inspector.walker.getNodeFromActor(actorID, ["node"]);
+ }
+
+ isPanelVisible() {
+ return (
+ this.inspector &&
+ this.inspector.toolbox &&
+ this.inspector.sidebar &&
+ this.inspector.toolbox.currentToolId === "inspector" &&
+ this.inspector.sidebar.getCurrentTabID() === "animationinspector"
+ );
+ }
+
+ onAnimationStateChanged() {
+ // Simply update the animations since the state has already been updated.
+ this.fireUpdateAction([...this.state.animations]);
+ }
+
+ /**
+ * This method should call when the current time is changed.
+ * Then, dispatches the current time to listeners that are registered
+ * by addAnimationsCurrentTimeListener.
+ *
+ * @param {Number} currentTime
+ */
+ onAnimationsCurrentTimeUpdated(currentTime) {
+ this.currentTime = currentTime;
+
+ for (const listener of this.animationsCurrentTimeListeners) {
+ listener(currentTime);
+ }
+ }
+
+ /**
+ * This method is called when the current time proceed by CurrentTimeTimer.
+ *
+ * @param {Number} currentTime
+ * @param {Bool} shouldStop
+ */
+ onCurrentTimeTimerUpdated(currentTime, shouldStop) {
+ if (shouldStop) {
+ this.setAnimationsCurrentTime(currentTime, true);
+ } else {
+ this.onAnimationsCurrentTimeUpdated(currentTime);
+ }
+ }
+
+ async onAnimationsMutation(changes) {
+ let animations = [...this.state.animations];
+ const addedAnimations = [];
+
+ for (const { type, player: animation } of changes) {
+ if (type === "added") {
+ if (!animation.state.type) {
+ // This animation was added but removed immediately.
+ continue;
+ }
+
+ addedAnimations.push(animation);
+ animation.on("changed", this.onAnimationStateChanged);
+ } else if (type === "removed") {
+ const index = animations.indexOf(animation);
+
+ if (index < 0) {
+ // This animation was added but removed immediately.
+ continue;
+ }
+
+ animations.splice(index, 1);
+ animation.off("changed", this.onAnimationStateChanged);
+ }
+ }
+
+ // Update existing other animations as well since the currentTime would be proceeded
+ // sice the scrubber position is related the currentTime.
+ // Also, don't update the state of removed animations since React components
+ // may refer to the same instance still.
+ try {
+ animations = await this.refreshAnimationsState(animations);
+ } catch (_) {
+ console.error(`Updating Animations failed`);
+ return;
+ }
+
+ this.fireUpdateAction(animations.concat(addedAnimations));
+ }
+
+ onElementPickerStarted() {
+ this.inspector.store.dispatch(updateElementPickerEnabled(true));
+ }
+
+ onElementPickerStopped() {
+ this.inspector.store.dispatch(updateElementPickerEnabled(false));
+ }
+
+ onNavigate() {
+ if (!this.isPanelVisible()) {
+ return;
+ }
+
+ this.inspector.store.dispatch(updatePlaybackRates());
+ }
+
+ async onSidebarSelectionChanged() {
+ const isPanelVisibled = this.isPanelVisible();
+
+ if (this.wasPanelVisibled === isPanelVisibled) {
+ // onSidebarSelectionChanged is called some times even same state
+ // from sidebar and toolbar.
+ return;
+ }
+
+ this.wasPanelVisibled = isPanelVisibled;
+
+ if (this.isPanelVisible()) {
+ await this.update();
+ this.onSidebarResized(null, this.inspector.getSidebarSize());
+ } else {
+ this.stopAnimationsCurrentTimeTimer();
+ this.setAnimationStateChangedListenerEnabled(false);
+ }
+ }
+
+ onSidebarResized(size) {
+ if (!this.isPanelVisible()) {
+ return;
+ }
+
+ this.inspector.store.dispatch(updateSidebarSize(size));
+ }
+
+ async onTargetAvailable({ targetFront }) {
+ if (targetFront.isTopLevel) {
+ this.animationsFront = await targetFront.getFront("animations");
+ this.animationsFront.setWalkerActor(this.inspector.walker);
+ this.animationsFront.on("mutations", this.onAnimationsMutation);
+
+ await this.update();
+ }
+ }
+
+ removeAnimationsCurrentTimeListener(listener) {
+ this.animationsCurrentTimeListeners =
+ this.animationsCurrentTimeListeners.filter(l => l !== listener);
+ }
+
+ async rewindAnimationsCurrentTime() {
+ const { timeScale } = this.state;
+ await this.setAnimationsCurrentTime(timeScale.zeroPositionTime, true);
+ }
+
+ selectAnimation(animation) {
+ this.inspector.store.dispatch(updateSelectedAnimation(animation));
+ }
+
+ async setSelectedNode(nodeFront) {
+ if (this.inspector.selection.nodeFront === nodeFront) {
+ return;
+ }
+
+ await this.inspector
+ .getCommonComponentProps()
+ .setSelectedNode(nodeFront, { reason: "animation-panel" });
+ }
+
+ async setAnimationsCurrentTime(currentTime, shouldRefresh) {
+ this.stopAnimationsCurrentTimeTimer();
+ this.onAnimationsCurrentTimeUpdated(currentTime);
+
+ if (!shouldRefresh && this.isCurrentTimeSet) {
+ return;
+ }
+
+ let animations = this.state.animations;
+ this.isCurrentTimeSet = true;
+
+ try {
+ await this.doSetCurrentTimes(currentTime);
+ animations = await this.refreshAnimationsState(animations);
+ } catch (e) {
+ // Expected if we've already been destroyed or other node have been selected
+ // in the meantime.
+ console.error(e);
+ return;
+ }
+
+ this.isCurrentTimeSet = false;
+
+ if (shouldRefresh) {
+ this.fireUpdateAction(animations);
+ }
+ }
+
+ async setAnimationsPlaybackRate(playbackRate) {
+ if (!this.inspector) {
+ return; // Already destroyed or another node selected.
+ }
+
+ let animations = this.state.animations;
+ // "changed" event on each animation will fire respectively when the playback
+ // rate changed. Since for each occurrence of event, change of UI is urged.
+ // To avoid this, disable the listeners once in order to not capture the event.
+ this.setAnimationStateChangedListenerEnabled(false);
+ try {
+ await this.animationsFront.setPlaybackRates(animations, playbackRate);
+ animations = await this.refreshAnimationsState(animations);
+ } catch (e) {
+ // Expected if we've already been destroyed or another node has been
+ // selected in the meantime.
+ console.error(e);
+ return;
+ } finally {
+ this.setAnimationStateChangedListenerEnabled(true);
+ }
+
+ if (animations) {
+ await this.fireUpdateAction(animations);
+ }
+ }
+
+ async setAnimationsPlayState(doPlay) {
+ if (!this.inspector) {
+ return; // Already destroyed or another node selected.
+ }
+
+ let { animations, timeScale } = this.state;
+
+ try {
+ if (
+ doPlay &&
+ animations.every(
+ animation =>
+ timeScale.getEndTime(animation) <= animation.state.currentTime
+ )
+ ) {
+ await this.doSetCurrentTimes(timeScale.zeroPositionTime);
+ }
+
+ if (doPlay) {
+ await this.animationsFront.playSome(animations);
+ } else {
+ await this.animationsFront.pauseSome(animations);
+ }
+
+ animations = await this.refreshAnimationsState(animations);
+ } catch (e) {
+ // Expected if we've already been destroyed or other node have been selected
+ // in the meantime.
+ console.error(e);
+ return;
+ }
+
+ await this.fireUpdateAction(animations);
+ }
+
+ /**
+ * Enable/disable the animation state change listener.
+ * If set true, observe "changed" event on current animations.
+ * Otherwise, quit observing the "changed" event.
+ *
+ * @param {Bool} isEnabled
+ */
+ setAnimationStateChangedListenerEnabled(isEnabled) {
+ if (!this.inspector) {
+ return; // Already destroyed.
+ }
+ if (isEnabled) {
+ for (const animation of this.state.animations) {
+ animation.on("changed", this.onAnimationStateChanged);
+ }
+ } else {
+ for (const animation of this.state.animations) {
+ animation.off("changed", this.onAnimationStateChanged);
+ }
+ }
+ }
+
+ setDetailVisibility(isVisible) {
+ this.inspector.store.dispatch(updateDetailVisibility(isVisible));
+ }
+
+ /**
+ * Persistently highlight the given node identified with a unique selector.
+ * If no node is provided, hide any persistent highlighter.
+ *
+ * @param {NodeFront} nodeFront
+ */
+ async setHighlightedNode(nodeFront) {
+ await this.inspector.highlighters.hideHighlighterType(
+ this.inspector.highlighters.TYPES.SELECTOR
+ );
+
+ if (nodeFront) {
+ const selector = await nodeFront.getUniqueSelector();
+ if (!selector) {
+ console.warn(
+ `Couldn't get unique selector for NodeFront: ${nodeFront.actorID}`
+ );
+ return;
+ }
+
+ /**
+ * NOTE: Using a Selector Highlighter here because only one Box Model Highlighter
+ * can be visible at a time. The Box Model Highlighter is shown when hovering nodes
+ * which would cause this persistent highlighter to be hidden unexpectedly.
+ * This limitation of one highlighter type a time should be solved by switching
+ * to a highlighter by role approach (Bug 1663443).
+ */
+ await this.inspector.highlighters.showHighlighterTypeForNode(
+ this.inspector.highlighters.TYPES.SELECTOR,
+ nodeFront,
+ {
+ hideInfoBar: true,
+ hideGuides: true,
+ selector,
+ }
+ );
+ }
+
+ this.inspector.store.dispatch(updateHighlightedNode(nodeFront));
+ }
+
+ /**
+ * Returns simulatable animation by given parameters.
+ * The returned animation is implementing Animation interface of Web Animation API.
+ * https://drafts.csswg.org/web-animations/#the-animation-interface
+ *
+ * @param {Array} keyframes
+ * e.g. [{ opacity: 0 }, { opacity: 1 }]
+ * @param {Object} effectTiming
+ * e.g. { duration: 1000, fill: "both" }
+ * @param {Boolean} isElementNeeded
+ * true: create animation with an element.
+ * If want to know computed value of the element, turn on.
+ * false: create animation without an element,
+ * If need to know only timing progress.
+ * @return {Animation}
+ * https://drafts.csswg.org/web-animations/#the-animation-interface
+ */
+ simulateAnimation(keyframes, effectTiming, isElementNeeded) {
+ // Don't simulate animation if the animation inspector is already destroyed.
+ if (!this.win) {
+ return null;
+ }
+
+ let targetEl = null;
+
+ if (isElementNeeded) {
+ if (!this.simulatedElement) {
+ this.simulatedElement = this.win.document.createElement("div");
+ this.win.document.documentElement.appendChild(this.simulatedElement);
+ } else {
+ // Reset styles.
+ this.simulatedElement.style.cssText = "";
+ }
+
+ targetEl = this.simulatedElement;
+ }
+
+ if (!this.simulatedAnimation) {
+ this.simulatedAnimation = new this.win.Animation();
+ }
+
+ this.simulatedAnimation.effect = new this.win.KeyframeEffect(
+ targetEl,
+ keyframes,
+ effectTiming
+ );
+
+ return this.simulatedAnimation;
+ }
+
+ /**
+ * Returns a simulatable efect timing animation for the keyframes progress bar.
+ * The returned animation is implementing Animation interface of Web Animation API.
+ * https://drafts.csswg.org/web-animations/#the-animation-interface
+ *
+ * @param {Object} effectTiming
+ * e.g. { duration: 1000, fill: "both" }
+ * @return {Animation}
+ * https://drafts.csswg.org/web-animations/#the-animation-interface
+ */
+ simulateAnimationForKeyframesProgressBar(effectTiming) {
+ if (!this.simulatedAnimationForKeyframesProgressBar) {
+ this.simulatedAnimationForKeyframesProgressBar = new this.win.Animation();
+ }
+
+ this.simulatedAnimationForKeyframesProgressBar.effect =
+ new this.win.KeyframeEffect(null, null, effectTiming);
+
+ return this.simulatedAnimationForKeyframesProgressBar;
+ }
+
+ stopAnimationsCurrentTimeTimer() {
+ if (this.currentTimeTimer) {
+ this.currentTimeTimer.destroy();
+ this.currentTimeTimer = null;
+ }
+ }
+
+ startAnimationsCurrentTimeTimer() {
+ const timeScale = this.state.timeScale;
+ const shouldStopAfterEndTime = !hasAnimationIterationCountInfinite(
+ this.state.animations
+ );
+
+ const currentTimeTimer = new CurrentTimeTimer(
+ timeScale,
+ shouldStopAfterEndTime,
+ this.win,
+ this.onCurrentTimeTimerUpdated
+ );
+ currentTimeTimer.start();
+ this.currentTimeTimer = currentTimeTimer;
+ }
+
+ toggleElementPicker() {
+ this.inspector.toolbox.nodePicker.togglePicker();
+ }
+
+ async update() {
+ if (!this.isPanelVisible()) {
+ return;
+ }
+
+ const done = this.inspector.updating("animationinspector");
+
+ const selection = this.inspector.selection;
+ const animations =
+ selection.isConnected() && selection.isElementNode()
+ ? await this.animationsFront.getAnimationPlayersForNode(
+ selection.nodeFront
+ )
+ : [];
+ this.fireUpdateAction(animations);
+ this.setAnimationStateChangedListenerEnabled(true);
+
+ done();
+ }
+
+ async refreshAnimationsState(animations) {
+ let error = null;
+
+ const promises = animations.map(animation => {
+ return new Promise(resolve => {
+ animation
+ .refreshState()
+ .catch(e => {
+ error = e;
+ })
+ .finally(() => {
+ resolve();
+ });
+ });
+ });
+ await Promise.all(promises);
+
+ if (error) {
+ throw new Error(error);
+ }
+
+ // Even when removal animation on inspected document, refreshAnimationsState
+ // might be called before onAnimationsMutation due to the async timing.
+ // Return the animations as result of refreshAnimationsState after getting rid of
+ // the animations since they should not display.
+ return animations.filter(anim => !!anim.state.type);
+ }
+
+ fireUpdateAction(animations) {
+ // Animation inspector already destroyed
+ if (!this.inspector) {
+ return;
+ }
+
+ this.stopAnimationsCurrentTimeTimer();
+
+ // Although it is not possible to set a delay or end delay of infinity using
+ // the animation API, if the value passed exceeds the limit of our internal
+ // representation of times, it will be treated as infinity. Rather than
+ // adding special case code to represent this very rare case, we simply omit
+ // such animations from the graph.
+ animations = animations.filter(
+ anim =>
+ Math.abs(anim.state.delay) !== Infinity &&
+ Math.abs(anim.state.endDelay) !== Infinity
+ );
+
+ this.inspector.store.dispatch(updateAnimations(animations));
+
+ if (hasRunningAnimation(animations)) {
+ this.startAnimationsCurrentTimeTimer();
+ } else {
+ // Even no running animations, update the current time once
+ // so as to show the state.
+ this.onCurrentTimeTimerUpdated(this.state.timeScale.getCurrentTime());
+ }
+ }
+}
+
+module.exports = AnimationInspector;
diff --git a/devtools/client/inspector/animation/components/AnimatedPropertyItem.js b/devtools/client/inspector/animation/components/AnimatedPropertyItem.js
new file mode 100644
index 0000000000..4c4ff37254
--- /dev/null
+++ b/devtools/client/inspector/animation/components/AnimatedPropertyItem.js
@@ -0,0 +1,64 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const AnimatedPropertyName = createFactory(
+ require("resource://devtools/client/inspector/animation/components/AnimatedPropertyName.js")
+);
+const KeyframesGraph = createFactory(
+ require("resource://devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraph.js")
+);
+
+class AnimatedPropertyItem extends PureComponent {
+ static get propTypes() {
+ return {
+ getComputedStyle: PropTypes.func.isRequired,
+ isUnchanged: PropTypes.bool.isRequired,
+ keyframes: PropTypes.array.isRequired,
+ name: PropTypes.string.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ state: PropTypes.object.isRequired,
+ type: PropTypes.string.isRequired,
+ };
+ }
+
+ render() {
+ const {
+ getComputedStyle,
+ isUnchanged,
+ keyframes,
+ name,
+ simulateAnimation,
+ state,
+ type,
+ } = this.props;
+
+ return dom.li(
+ {
+ className: "animated-property-item" + (isUnchanged ? " unchanged" : ""),
+ },
+ AnimatedPropertyName({
+ name,
+ state,
+ }),
+ KeyframesGraph({
+ getComputedStyle,
+ keyframes,
+ name,
+ simulateAnimation,
+ type,
+ })
+ );
+ }
+}
+
+module.exports = AnimatedPropertyItem;
diff --git a/devtools/client/inspector/animation/components/AnimatedPropertyList.js b/devtools/client/inspector/animation/components/AnimatedPropertyList.js
new file mode 100644
index 0000000000..54dff15187
--- /dev/null
+++ b/devtools/client/inspector/animation/components/AnimatedPropertyList.js
@@ -0,0 +1,140 @@
+/* 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 {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const AnimatedPropertyItem = createFactory(
+ require("resource://devtools/client/inspector/animation/components/AnimatedPropertyItem.js")
+);
+
+class AnimatedPropertyList extends Component {
+ static get propTypes() {
+ return {
+ animation: PropTypes.object.isRequired,
+ emitEventForTest: PropTypes.func.isRequired,
+ getAnimatedPropertyMap: PropTypes.func.isRequired,
+ getComputedStyle: PropTypes.func.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ // Array of object which has the property name, the keyframes, its aniamtion type
+ // and unchanged flag.
+ animatedProperties: null,
+ // To avoid rendering while the state is updating
+ // since we call an async function in updateState.
+ isStateUpdating: false,
+ };
+ }
+
+ componentDidMount() {
+ // No need to set isStateUpdating state since paint sequence is finish here.
+ this.updateState(this.props.animation);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ this.setState({ isStateUpdating: true });
+ this.updateState(nextProps.animation);
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return !nextState.isStateUpdating;
+ }
+
+ getPropertyState(property) {
+ const { animation } = this.props;
+
+ for (const propState of animation.state.propertyState) {
+ if (propState.property === property) {
+ return propState;
+ }
+ }
+
+ return null;
+ }
+
+ async updateState(animation) {
+ const { getAnimatedPropertyMap, emitEventForTest } = this.props;
+
+ let propertyMap = null;
+ let propertyNames = null;
+ let types = null;
+
+ try {
+ propertyMap = getAnimatedPropertyMap(animation);
+ propertyNames = [...propertyMap.keys()];
+ types = await animation.getAnimationTypes(propertyNames);
+ } catch (e) {
+ // Expected if we've already been destroyed or other node have been selected
+ // in the meantime.
+ console.error(e);
+ return;
+ }
+
+ const animatedProperties = propertyNames.map(name => {
+ const keyframes = propertyMap.get(name);
+ const type = types[name];
+ const isUnchanged = keyframes.every(
+ keyframe => keyframe.value === keyframes[0].value
+ );
+ return { isUnchanged, keyframes, name, type };
+ });
+
+ animatedProperties.sort((a, b) => {
+ if (a.isUnchanged === b.isUnchanged) {
+ return a.name > b.name ? 1 : -1;
+ }
+
+ return a.isUnchanged ? 1 : -1;
+ });
+
+ this.setState({
+ animatedProperties,
+ isStateUpdating: false,
+ });
+
+ emitEventForTest("animation-keyframes-rendered");
+ }
+
+ render() {
+ const { getComputedStyle, simulateAnimation } = this.props;
+ const { animatedProperties } = this.state;
+
+ if (!animatedProperties) {
+ return null;
+ }
+
+ return dom.ul(
+ {
+ className: "animated-property-list",
+ },
+ animatedProperties.map(({ isUnchanged, keyframes, name, type }) => {
+ const state = this.getPropertyState(name);
+ return AnimatedPropertyItem({
+ getComputedStyle,
+ isUnchanged,
+ keyframes,
+ name,
+ simulateAnimation,
+ state,
+ type,
+ });
+ })
+ );
+ }
+}
+
+module.exports = AnimatedPropertyList;
diff --git a/devtools/client/inspector/animation/components/AnimatedPropertyListContainer.js b/devtools/client/inspector/animation/components/AnimatedPropertyListContainer.js
new file mode 100644
index 0000000000..965099b37e
--- /dev/null
+++ b/devtools/client/inspector/animation/components/AnimatedPropertyListContainer.js
@@ -0,0 +1,90 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const AnimatedPropertyList = createFactory(
+ require("resource://devtools/client/inspector/animation/components/AnimatedPropertyList.js")
+);
+const KeyframesProgressBar = createFactory(
+ require("resource://devtools/client/inspector/animation/components/KeyframesProgressBar.js")
+);
+const ProgressInspectionPanel = createFactory(
+ require("resource://devtools/client/inspector/animation/components/ProgressInspectionPanel.js")
+);
+
+const {
+ getFormatStr,
+} = require("resource://devtools/client/inspector/animation/utils/l10n.js");
+
+class AnimatedPropertyListContainer extends PureComponent {
+ static get propTypes() {
+ return {
+ addAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ animation: PropTypes.object.isRequired,
+ emitEventForTest: PropTypes.func.isRequired,
+ getAnimatedPropertyMap: PropTypes.func.isRequired,
+ getAnimationsCurrentTime: PropTypes.func.isRequired,
+ getComputedStyle: PropTypes.func.isRequired,
+ removeAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ simulateAnimationForKeyframesProgressBar: PropTypes.func.isRequired,
+ timeScale: PropTypes.object.isRequired,
+ };
+ }
+
+ render() {
+ const {
+ addAnimationsCurrentTimeListener,
+ animation,
+ emitEventForTest,
+ getAnimatedPropertyMap,
+ getAnimationsCurrentTime,
+ getComputedStyle,
+ removeAnimationsCurrentTimeListener,
+ simulateAnimation,
+ simulateAnimationForKeyframesProgressBar,
+ timeScale,
+ } = this.props;
+
+ return dom.div(
+ {
+ className: `animated-property-list-container ${animation.state.type}`,
+ },
+ ProgressInspectionPanel({
+ indicator: KeyframesProgressBar({
+ addAnimationsCurrentTimeListener,
+ animation,
+ getAnimationsCurrentTime,
+ removeAnimationsCurrentTimeListener,
+ simulateAnimationForKeyframesProgressBar,
+ timeScale,
+ }),
+ list: AnimatedPropertyList({
+ animation,
+ emitEventForTest,
+ getAnimatedPropertyMap,
+ getComputedStyle,
+ simulateAnimation,
+ }),
+ ticks: [0, 50, 100].map(position => {
+ const label = getFormatStr(
+ "detail.propertiesHeader.percentage",
+ position
+ );
+ return { position, label };
+ }),
+ })
+ );
+ }
+}
+
+module.exports = AnimatedPropertyListContainer;
diff --git a/devtools/client/inspector/animation/components/AnimatedPropertyName.js b/devtools/client/inspector/animation/components/AnimatedPropertyName.js
new file mode 100644
index 0000000000..e7a777ee12
--- /dev/null
+++ b/devtools/client/inspector/animation/components/AnimatedPropertyName.js
@@ -0,0 +1,37 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+class AnimatedPropertyName extends PureComponent {
+ static get propTypes() {
+ return {
+ name: PropTypes.string.isRequired,
+ state: PropTypes.oneOfType([null, PropTypes.object]).isRequired,
+ };
+ }
+
+ render() {
+ const { name, state } = this.props;
+
+ return dom.div(
+ {
+ className:
+ "animated-property-name" +
+ (state?.runningOnCompositor ? " compositor" : "") +
+ (state?.warning ? " warning" : ""),
+ title: state ? state.warning : "",
+ },
+ dom.span({}, name)
+ );
+ }
+}
+
+module.exports = AnimatedPropertyName;
diff --git a/devtools/client/inspector/animation/components/AnimationDetailContainer.js b/devtools/client/inspector/animation/components/AnimationDetailContainer.js
new file mode 100644
index 0000000000..9a7b4bfd1a
--- /dev/null
+++ b/devtools/client/inspector/animation/components/AnimationDetailContainer.js
@@ -0,0 +1,90 @@
+/* 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 {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+const {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const AnimationDetailHeader = createFactory(
+ require("resource://devtools/client/inspector/animation/components/AnimationDetailHeader.js")
+);
+const AnimatedPropertyListContainer = createFactory(
+ require("resource://devtools/client/inspector/animation/components/AnimatedPropertyListContainer.js")
+);
+
+class AnimationDetailContainer extends PureComponent {
+ static get propTypes() {
+ return {
+ addAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ animation: PropTypes.object.isRequired,
+ emitEventForTest: PropTypes.func.isRequired,
+ getAnimatedPropertyMap: PropTypes.func.isRequired,
+ getAnimationsCurrentTime: PropTypes.func.isRequired,
+ getComputedStyle: PropTypes.func.isRequired,
+ removeAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ setDetailVisibility: PropTypes.func.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ simulateAnimationForKeyframesProgressBar: PropTypes.func.isRequired,
+ timeScale: PropTypes.object.isRequired,
+ };
+ }
+
+ render() {
+ const {
+ addAnimationsCurrentTimeListener,
+ animation,
+ emitEventForTest,
+ getAnimatedPropertyMap,
+ getAnimationsCurrentTime,
+ getComputedStyle,
+ removeAnimationsCurrentTimeListener,
+ setDetailVisibility,
+ simulateAnimation,
+ simulateAnimationForKeyframesProgressBar,
+ timeScale,
+ } = this.props;
+
+ return dom.div(
+ {
+ className: "animation-detail-container",
+ },
+ animation
+ ? AnimationDetailHeader({
+ animation,
+ setDetailVisibility,
+ })
+ : null,
+ animation
+ ? AnimatedPropertyListContainer({
+ addAnimationsCurrentTimeListener,
+ animation,
+ emitEventForTest,
+ getAnimatedPropertyMap,
+ getAnimationsCurrentTime,
+ getComputedStyle,
+ removeAnimationsCurrentTimeListener,
+ simulateAnimation,
+ simulateAnimationForKeyframesProgressBar,
+ timeScale,
+ })
+ : null
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ animation: state.animations.selectedAnimation,
+ };
+};
+
+module.exports = connect(mapStateToProps)(AnimationDetailContainer);
diff --git a/devtools/client/inspector/animation/components/AnimationDetailHeader.js b/devtools/client/inspector/animation/components/AnimationDetailHeader.js
new file mode 100644
index 0000000000..5f3da0acba
--- /dev/null
+++ b/devtools/client/inspector/animation/components/AnimationDetailHeader.js
@@ -0,0 +1,52 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const {
+ getFormattedTitle,
+} = require("resource://devtools/client/inspector/animation/utils/l10n.js");
+
+class AnimationDetailHeader extends PureComponent {
+ static get propTypes() {
+ return {
+ animation: PropTypes.object.isRequired,
+ setDetailVisibility: PropTypes.func.isRequired,
+ };
+ }
+
+ onClick(event) {
+ event.stopPropagation();
+ const { setDetailVisibility } = this.props;
+ setDetailVisibility(false);
+ }
+
+ render() {
+ const { animation } = this.props;
+
+ return dom.div(
+ {
+ className: "animation-detail-header devtools-toolbar",
+ },
+ dom.div(
+ {
+ className: "animation-detail-title",
+ },
+ getFormattedTitle(animation.state)
+ ),
+ dom.button({
+ className: "animation-detail-close-button devtools-button",
+ onClick: this.onClick.bind(this),
+ })
+ );
+ }
+}
+
+module.exports = AnimationDetailHeader;
diff --git a/devtools/client/inspector/animation/components/AnimationItem.js b/devtools/client/inspector/animation/components/AnimationItem.js
new file mode 100644
index 0000000000..45bb09bac6
--- /dev/null
+++ b/devtools/client/inspector/animation/components/AnimationItem.js
@@ -0,0 +1,121 @@
+/* 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 {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const AnimationTarget = createFactory(
+ require("resource://devtools/client/inspector/animation/components/AnimationTarget.js")
+);
+const SummaryGraph = createFactory(
+ require("resource://devtools/client/inspector/animation/components/graph/SummaryGraph.js")
+);
+
+class AnimationItem extends Component {
+ static get propTypes() {
+ return {
+ animation: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ getAnimatedPropertyMap: PropTypes.func.isRequired,
+ getNodeFromActor: PropTypes.func.isRequired,
+ isDisplayable: PropTypes.bool.isRequired,
+ selectAnimation: PropTypes.func.isRequired,
+ selectedAnimation: PropTypes.object.isRequired,
+ setHighlightedNode: PropTypes.func.isRequired,
+ setSelectedNode: PropTypes.func.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ timeScale: PropTypes.object.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isSelected: this.isSelected(props),
+ };
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ this.setState({
+ isSelected: this.isSelected(nextProps),
+ });
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return (
+ this.props.isDisplayable !== nextProps.isDisplayable ||
+ this.state.isSelected !== nextState.isSelected ||
+ this.props.animation !== nextProps.animation ||
+ this.props.timeScale !== nextProps.timeScale
+ );
+ }
+
+ isSelected(props) {
+ return (
+ props.selectedAnimation &&
+ props.animation.actorID === props.selectedAnimation.actorID
+ );
+ }
+
+ render() {
+ const {
+ animation,
+ dispatch,
+ getAnimatedPropertyMap,
+ getNodeFromActor,
+ isDisplayable,
+ selectAnimation,
+ setHighlightedNode,
+ setSelectedNode,
+ simulateAnimation,
+ timeScale,
+ } = this.props;
+ const { isSelected } = this.state;
+
+ return dom.li(
+ {
+ className:
+ `animation-item ${animation.state.type} ` +
+ (isSelected ? "selected" : ""),
+ },
+ isDisplayable
+ ? [
+ AnimationTarget({
+ animation,
+ dispatch,
+ getNodeFromActor,
+ setHighlightedNode,
+ setSelectedNode,
+ }),
+ SummaryGraph({
+ animation,
+ getAnimatedPropertyMap,
+ selectAnimation,
+ simulateAnimation,
+ timeScale,
+ }),
+ ]
+ : null
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ selectedAnimation: state.animations.selectedAnimation,
+ };
+};
+
+module.exports = connect(mapStateToProps)(AnimationItem);
diff --git a/devtools/client/inspector/animation/components/AnimationList.js b/devtools/client/inspector/animation/components/AnimationList.js
new file mode 100644
index 0000000000..7d0f1caf3f
--- /dev/null
+++ b/devtools/client/inspector/animation/components/AnimationList.js
@@ -0,0 +1,72 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const AnimationItem = createFactory(
+ require("resource://devtools/client/inspector/animation/components/AnimationItem.js")
+);
+
+class AnimationList extends PureComponent {
+ static get propTypes() {
+ return {
+ animations: PropTypes.arrayOf(PropTypes.object).isRequired,
+ dispatch: PropTypes.func.isRequired,
+ displayableRange: PropTypes.object.isRequired,
+ getAnimatedPropertyMap: PropTypes.func.isRequired,
+ getNodeFromActor: PropTypes.func.isRequired,
+ selectAnimation: PropTypes.func.isRequired,
+ setHighlightedNode: PropTypes.func.isRequired,
+ setSelectedNode: PropTypes.func.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ timeScale: PropTypes.object.isRequired,
+ };
+ }
+
+ render() {
+ const {
+ animations,
+ dispatch,
+ displayableRange,
+ getAnimatedPropertyMap,
+ getNodeFromActor,
+ selectAnimation,
+ setHighlightedNode,
+ setSelectedNode,
+ simulateAnimation,
+ timeScale,
+ } = this.props;
+
+ const { startIndex, endIndex } = displayableRange;
+
+ return dom.ul(
+ {
+ className: "animation-list",
+ },
+ animations.map((animation, index) =>
+ AnimationItem({
+ animation,
+ dispatch,
+ getAnimatedPropertyMap,
+ getNodeFromActor,
+ isDisplayable: startIndex <= index && index <= endIndex,
+ selectAnimation,
+ setHighlightedNode,
+ setSelectedNode,
+ simulateAnimation,
+ timeScale,
+ })
+ )
+ );
+ }
+}
+
+module.exports = AnimationList;
diff --git a/devtools/client/inspector/animation/components/AnimationListContainer.js b/devtools/client/inspector/animation/components/AnimationListContainer.js
new file mode 100644
index 0000000000..d97b368f70
--- /dev/null
+++ b/devtools/client/inspector/animation/components/AnimationListContainer.js
@@ -0,0 +1,224 @@
+/* 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 {
+ createFactory,
+ createRef,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const AnimationList = createFactory(
+ require("resource://devtools/client/inspector/animation/components/AnimationList.js")
+);
+const CurrentTimeScrubber = createFactory(
+ require("resource://devtools/client/inspector/animation/components/CurrentTimeScrubber.js")
+);
+const ProgressInspectionPanel = createFactory(
+ require("resource://devtools/client/inspector/animation/components/ProgressInspectionPanel.js")
+);
+
+const {
+ findOptimalTimeInterval,
+} = require("resource://devtools/client/inspector/animation/utils/utils.js");
+const {
+ getStr,
+} = require("resource://devtools/client/inspector/animation/utils/l10n.js");
+const { throttle } = require("resource://devtools/shared/throttle.js");
+
+// The minimum spacing between 2 time graduation headers in the timeline (px).
+const TIME_GRADUATION_MIN_SPACING = 40;
+
+class AnimationListContainer extends PureComponent {
+ static get propTypes() {
+ return {
+ addAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ animations: PropTypes.arrayOf(PropTypes.object).isRequired,
+ direction: PropTypes.string.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ getAnimatedPropertyMap: PropTypes.func.isRequired,
+ getNodeFromActor: PropTypes.func.isRequired,
+ removeAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ selectAnimation: PropTypes.func.isRequired,
+ setAnimationsCurrentTime: PropTypes.func.isRequired,
+ setHighlightedNode: PropTypes.func.isRequired,
+ setSelectedNode: PropTypes.func.isRequired,
+ sidebarWidth: PropTypes.number.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ timeScale: PropTypes.object.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this._ref = createRef();
+
+ this.updateDisplayableRange = throttle(
+ this.updateDisplayableRange,
+ 100,
+ this
+ );
+
+ this.state = {
+ // tick labels and lines on the progress inspection panel
+ ticks: [],
+ // Displayable range.
+ displayableRange: { startIndex: 0, endIndex: 0 },
+ };
+ }
+
+ componentDidMount() {
+ this.updateTicks(this.props);
+
+ const current = this._ref.current;
+ this._inspectionPanelEl = current.querySelector(
+ ".progress-inspection-panel"
+ );
+ this._inspectionPanelEl.addEventListener("scroll", () => {
+ this.updateDisplayableRange();
+ });
+
+ this._animationListEl = current.querySelector(".animation-list");
+ const resizeObserver = new current.ownerGlobal.ResizeObserver(() => {
+ this.updateDisplayableRange();
+ });
+ resizeObserver.observe(this._animationListEl);
+
+ const animationItemEl = current.querySelector(".animation-item");
+ this._itemHeight = animationItemEl.offsetHeight;
+
+ this.updateDisplayableRange();
+ }
+
+ componentDidUpdate(prevProps) {
+ const { timeScale, sidebarWidth } = this.props;
+
+ if (
+ timeScale.getDuration() !== prevProps.timeScale.getDuration() ||
+ timeScale.zeroPositionTime !== prevProps.timeScale.zeroPositionTime ||
+ sidebarWidth !== prevProps.sidebarWidth
+ ) {
+ this.updateTicks(this.props);
+ }
+ }
+
+ /**
+ * Since it takes too much time if we render all of animation graphs,
+ * we restrict to render the items that are not in displaying area.
+ * This function calculates the displayable item range.
+ */
+ updateDisplayableRange() {
+ const count =
+ Math.floor(this._animationListEl.offsetHeight / this._itemHeight) + 1;
+ const index = Math.floor(
+ this._inspectionPanelEl.scrollTop / this._itemHeight
+ );
+ this.setState({
+ displayableRange: { startIndex: index, endIndex: index + count },
+ });
+ }
+
+ updateTicks(props) {
+ const { animations, timeScale } = props;
+ const tickLinesEl = this._ref.current.querySelector(".tick-lines");
+ const width = tickLinesEl.offsetWidth;
+ const animationDuration = timeScale.getDuration();
+ const minTimeInterval =
+ (TIME_GRADUATION_MIN_SPACING * animationDuration) / width;
+ const intervalLength = findOptimalTimeInterval(minTimeInterval);
+ const intervalWidth = (intervalLength * width) / animationDuration;
+ const tickCount = parseInt(width / intervalWidth, 10);
+ const isAllDurationInfinity = animations.every(
+ animation => animation.state.duration === Infinity
+ );
+ const zeroBasePosition =
+ width * (timeScale.zeroPositionTime / animationDuration);
+ const shiftWidth = zeroBasePosition % intervalWidth;
+ const needToShift = zeroBasePosition !== 0 && shiftWidth !== 0;
+
+ const ticks = [];
+ // Need to display first graduation since position will be shifted.
+ if (needToShift) {
+ const label = timeScale.formatTime(timeScale.distanceToRelativeTime(0));
+ ticks.push({ position: 0, label, width: shiftWidth });
+ }
+
+ for (let i = 0; i <= tickCount; i++) {
+ const position = ((i * intervalWidth + shiftWidth) * 100) / width;
+ const distance = timeScale.distanceToRelativeTime(position);
+ const label =
+ isAllDurationInfinity && i === tickCount
+ ? getStr("player.infiniteTimeLabel")
+ : timeScale.formatTime(distance);
+ ticks.push({ position, label, width: intervalWidth });
+ }
+
+ this.setState({ ticks });
+ }
+
+ render() {
+ const {
+ addAnimationsCurrentTimeListener,
+ animations,
+ direction,
+ dispatch,
+ getAnimatedPropertyMap,
+ getNodeFromActor,
+ removeAnimationsCurrentTimeListener,
+ selectAnimation,
+ setAnimationsCurrentTime,
+ setHighlightedNode,
+ setSelectedNode,
+ simulateAnimation,
+ timeScale,
+ } = this.props;
+ const { displayableRange, ticks } = this.state;
+
+ return dom.div(
+ {
+ className: "animation-list-container",
+ ref: this._ref,
+ },
+ ProgressInspectionPanel({
+ indicator: CurrentTimeScrubber({
+ addAnimationsCurrentTimeListener,
+ direction,
+ removeAnimationsCurrentTimeListener,
+ setAnimationsCurrentTime,
+ timeScale,
+ }),
+ list: AnimationList({
+ animations,
+ dispatch,
+ displayableRange,
+ getAnimatedPropertyMap,
+ getNodeFromActor,
+ selectAnimation,
+ setHighlightedNode,
+ setSelectedNode,
+ simulateAnimation,
+ timeScale,
+ }),
+ ticks,
+ })
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ sidebarWidth: state.animations.sidebarSize
+ ? state.animations.sidebarSize.width
+ : 0,
+ };
+};
+
+module.exports = connect(mapStateToProps)(AnimationListContainer);
diff --git a/devtools/client/inspector/animation/components/AnimationTarget.js b/devtools/client/inspector/animation/components/AnimationTarget.js
new file mode 100644
index 0000000000..2fa392b3f6
--- /dev/null
+++ b/devtools/client/inspector/animation/components/AnimationTarget.js
@@ -0,0 +1,182 @@
+/* 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 {
+ Component,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const {
+ translateNodeFrontToGrip,
+} = require("resource://devtools/client/inspector/shared/utils.js");
+
+const {
+ REPS,
+ MODE,
+} = require("resource://devtools/client/shared/components/reps/index.js");
+const { Rep } = REPS;
+const ElementNode = REPS.ElementNode;
+
+const {
+ getInspectorStr,
+} = require("resource://devtools/client/inspector/animation/utils/l10n.js");
+
+const {
+ highlightNode,
+ unhighlightNode,
+} = require("resource://devtools/client/inspector/boxmodel/actions/box-model-highlighter.js");
+
+class AnimationTarget extends Component {
+ static get propTypes() {
+ return {
+ animation: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ getNodeFromActor: PropTypes.func.isRequired,
+ highlightedNode: PropTypes.string.isRequired,
+ setHighlightedNode: PropTypes.func.isRequired,
+ setSelectedNode: PropTypes.func.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ nodeFront: null,
+ };
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillMount() {
+ this.updateNodeFront(this.props.animation);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ if (this.props.animation.actorID !== nextProps.animation.actorID) {
+ this.updateNodeFront(nextProps.animation);
+ }
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return (
+ this.state.nodeFront !== nextState.nodeFront ||
+ this.props.highlightedNode !== nextState.highlightedNode
+ );
+ }
+
+ async updateNodeFront(animation) {
+ const { getNodeFromActor } = this.props;
+
+ // Try and get it from the playerFront directly.
+ let nodeFront = animation.animationTargetNodeFront;
+
+ // Next, get it from the walkerActor if it wasn't found.
+ if (!nodeFront) {
+ try {
+ nodeFront = await getNodeFromActor(animation.actorID);
+ } 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.
+ console.error(e);
+ this.setState({ nodeFront: null });
+ return;
+ }
+ }
+
+ this.setState({ nodeFront });
+ }
+
+ async ensureNodeFront() {
+ if (!this.state.nodeFront.actorID) {
+ // In case of no actorID, the node front had been destroyed.
+ // This will occur when the pseudo element was re-generated.
+ await this.updateNodeFront(this.props.animation);
+ }
+ }
+
+ async highlight() {
+ await this.ensureNodeFront();
+
+ if (this.state.nodeFront) {
+ this.props.dispatch(
+ highlightNode(this.state.nodeFront, {
+ hideInfoBar: true,
+ hideGuides: true,
+ })
+ );
+ }
+ }
+
+ async select() {
+ await this.ensureNodeFront();
+
+ if (this.state.nodeFront) {
+ this.props.setSelectedNode(this.state.nodeFront);
+ }
+ }
+
+ render() {
+ const { dispatch, highlightedNode, setHighlightedNode } = this.props;
+ const { nodeFront } = this.state;
+
+ if (!nodeFront) {
+ return dom.div({
+ className: "animation-target",
+ });
+ }
+
+ const isHighlighted = nodeFront.actorID === highlightedNode;
+
+ return dom.div(
+ {
+ className: "animation-target" + (isHighlighted ? " highlighting" : ""),
+ },
+ Rep({
+ defaultRep: ElementNode,
+ mode: MODE.TINY,
+ inspectIconTitle: getInspectorStr(
+ "inspector.nodePreview.highlightNodeLabel"
+ ),
+ inspectIconClassName: "highlight-node",
+ object: translateNodeFrontToGrip(nodeFront),
+ onDOMNodeClick: () => this.select(),
+ onDOMNodeMouseOut: () => {
+ if (!isHighlighted) {
+ dispatch(unhighlightNode());
+ }
+ },
+ onDOMNodeMouseOver: () => {
+ if (!isHighlighted) {
+ this.highlight();
+ }
+ },
+ onInspectIconClick: (_, e) => {
+ e.stopPropagation();
+
+ if (!isHighlighted) {
+ // At first, hide highlighter which was created by onDOMNodeMouseOver.
+ dispatch(unhighlightNode());
+ }
+
+ setHighlightedNode(isHighlighted ? null : nodeFront);
+ },
+ })
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ highlightedNode: state.animations.highlightedNode,
+ };
+};
+
+module.exports = connect(mapStateToProps)(AnimationTarget);
diff --git a/devtools/client/inspector/animation/components/AnimationToolbar.js b/devtools/client/inspector/animation/components/AnimationToolbar.js
new file mode 100644
index 0000000000..9b6b7aadc8
--- /dev/null
+++ b/devtools/client/inspector/animation/components/AnimationToolbar.js
@@ -0,0 +1,75 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const CurrentTimeLabel = createFactory(
+ require("resource://devtools/client/inspector/animation/components/CurrentTimeLabel.js")
+);
+const PauseResumeButton = createFactory(
+ require("resource://devtools/client/inspector/animation/components/PauseResumeButton.js")
+);
+const PlaybackRateSelector = createFactory(
+ require("resource://devtools/client/inspector/animation/components/PlaybackRateSelector.js")
+);
+const RewindButton = createFactory(
+ require("resource://devtools/client/inspector/animation/components/RewindButton.js")
+);
+
+class AnimationToolbar extends PureComponent {
+ static get propTypes() {
+ return {
+ addAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ animations: PropTypes.arrayOf(PropTypes.object).isRequired,
+ removeAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ rewindAnimationsCurrentTime: PropTypes.func.isRequired,
+ setAnimationsPlaybackRate: PropTypes.func.isRequired,
+ setAnimationsPlayState: PropTypes.func.isRequired,
+ timeScale: PropTypes.object.isRequired,
+ };
+ }
+
+ render() {
+ const {
+ addAnimationsCurrentTimeListener,
+ animations,
+ removeAnimationsCurrentTimeListener,
+ rewindAnimationsCurrentTime,
+ setAnimationsPlaybackRate,
+ setAnimationsPlayState,
+ timeScale,
+ } = this.props;
+
+ return dom.div(
+ {
+ className: "animation-toolbar devtools-toolbar",
+ },
+ RewindButton({
+ rewindAnimationsCurrentTime,
+ }),
+ PauseResumeButton({
+ animations,
+ setAnimationsPlayState,
+ }),
+ PlaybackRateSelector({
+ animations,
+ setAnimationsPlaybackRate,
+ }),
+ CurrentTimeLabel({
+ addAnimationsCurrentTimeListener,
+ removeAnimationsCurrentTimeListener,
+ timeScale,
+ })
+ );
+ }
+}
+
+module.exports = AnimationToolbar;
diff --git a/devtools/client/inspector/animation/components/App.js b/devtools/client/inspector/animation/components/App.js
new file mode 100644
index 0000000000..75a000286c
--- /dev/null
+++ b/devtools/client/inspector/animation/components/App.js
@@ -0,0 +1,164 @@
+/* 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 {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+
+const AnimationDetailContainer = createFactory(
+ require("resource://devtools/client/inspector/animation/components/AnimationDetailContainer.js")
+);
+const AnimationListContainer = createFactory(
+ require("resource://devtools/client/inspector/animation/components/AnimationListContainer.js")
+);
+const AnimationToolbar = createFactory(
+ require("resource://devtools/client/inspector/animation/components/AnimationToolbar.js")
+);
+const NoAnimationPanel = createFactory(
+ require("resource://devtools/client/inspector/animation/components/NoAnimationPanel.js")
+);
+const SplitBox = createFactory(
+ require("resource://devtools/client/shared/components/splitter/SplitBox.js")
+);
+
+class App extends Component {
+ static get propTypes() {
+ return {
+ addAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ animations: PropTypes.arrayOf(PropTypes.object).isRequired,
+ detailVisibility: PropTypes.bool.isRequired,
+ direction: PropTypes.string.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ emitEventForTest: PropTypes.func.isRequired,
+ getAnimatedPropertyMap: PropTypes.func.isRequired,
+ getAnimationsCurrentTime: PropTypes.func.isRequired,
+ getComputedStyle: PropTypes.func.isRequired,
+ getNodeFromActor: PropTypes.func.isRequired,
+ removeAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ rewindAnimationsCurrentTime: PropTypes.func.isRequired,
+ selectAnimation: PropTypes.func.isRequired,
+ setAnimationsCurrentTime: PropTypes.func.isRequired,
+ setAnimationsPlaybackRate: PropTypes.func.isRequired,
+ setAnimationsPlayState: PropTypes.func.isRequired,
+ setDetailVisibility: PropTypes.func.isRequired,
+ setHighlightedNode: PropTypes.func.isRequired,
+ setSelectedNode: PropTypes.func.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ simulateAnimationForKeyframesProgressBar: PropTypes.func.isRequired,
+ timeScale: PropTypes.object.isRequired,
+ toggleElementPicker: PropTypes.func.isRequired,
+ toggleLockingHighlight: PropTypes.func.isRequired,
+ };
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return (
+ this.props.animations.length !== 0 || nextProps.animations.length !== 0
+ );
+ }
+
+ render() {
+ const {
+ addAnimationsCurrentTimeListener,
+ animations,
+ detailVisibility,
+ direction,
+ dispatch,
+ emitEventForTest,
+ getAnimatedPropertyMap,
+ getAnimationsCurrentTime,
+ getComputedStyle,
+ getNodeFromActor,
+ removeAnimationsCurrentTimeListener,
+ rewindAnimationsCurrentTime,
+ selectAnimation,
+ setAnimationsCurrentTime,
+ setAnimationsPlaybackRate,
+ setAnimationsPlayState,
+ setDetailVisibility,
+ setHighlightedNode,
+ setSelectedNode,
+ simulateAnimation,
+ simulateAnimationForKeyframesProgressBar,
+ timeScale,
+ toggleElementPicker,
+ } = this.props;
+
+ return dom.div(
+ {
+ id: "animation-container",
+ className: detailVisibility ? "animation-detail-visible" : "",
+ tabIndex: -1,
+ },
+ animations.length
+ ? [
+ AnimationToolbar({
+ addAnimationsCurrentTimeListener,
+ animations,
+ removeAnimationsCurrentTimeListener,
+ rewindAnimationsCurrentTime,
+ setAnimationsPlaybackRate,
+ setAnimationsPlayState,
+ timeScale,
+ }),
+ SplitBox({
+ className: "animation-container-splitter",
+ endPanel: AnimationDetailContainer({
+ addAnimationsCurrentTimeListener,
+ emitEventForTest,
+ getAnimatedPropertyMap,
+ getAnimationsCurrentTime,
+ getComputedStyle,
+ removeAnimationsCurrentTimeListener,
+ setDetailVisibility,
+ simulateAnimation,
+ simulateAnimationForKeyframesProgressBar,
+ timeScale,
+ }),
+ endPanelControl: true,
+ initialHeight: "50%",
+ splitterSize: 1,
+ minSize: "30px",
+ startPanel: AnimationListContainer({
+ addAnimationsCurrentTimeListener,
+ animations,
+ direction,
+ dispatch,
+ getAnimatedPropertyMap,
+ getNodeFromActor,
+ removeAnimationsCurrentTimeListener,
+ selectAnimation,
+ setAnimationsCurrentTime,
+ setHighlightedNode,
+ setSelectedNode,
+ simulateAnimation,
+ timeScale,
+ }),
+ vert: false,
+ }),
+ ]
+ : NoAnimationPanel({
+ toggleElementPicker,
+ })
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ animations: state.animations.animations,
+ detailVisibility: state.animations.detailVisibility,
+ timeScale: state.animations.timeScale,
+ };
+};
+
+module.exports = connect(mapStateToProps)(App);
diff --git a/devtools/client/inspector/animation/components/CurrentTimeLabel.js b/devtools/client/inspector/animation/components/CurrentTimeLabel.js
new file mode 100644
index 0000000000..67b2498e8b
--- /dev/null
+++ b/devtools/client/inspector/animation/components/CurrentTimeLabel.js
@@ -0,0 +1,76 @@
+/* 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 {
+ createRef,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+class CurrentTimeLabel extends PureComponent {
+ static get propTypes() {
+ return {
+ addAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ removeAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ timeScale: PropTypes.object.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this._ref = createRef();
+
+ const { addAnimationsCurrentTimeListener } = props;
+ this.onCurrentTimeUpdated = this.onCurrentTimeUpdated.bind(this);
+
+ addAnimationsCurrentTimeListener(this.onCurrentTimeUpdated);
+ }
+
+ componentWillUnmount() {
+ const { removeAnimationsCurrentTimeListener } = this.props;
+ removeAnimationsCurrentTimeListener(this.onCurrentTimeUpdated);
+ }
+
+ onCurrentTimeUpdated(currentTime) {
+ const { timeScale } = this.props;
+ const text = formatStopwatchTime(currentTime - timeScale.zeroPositionTime);
+ // onCurrentTimeUpdated is bound to requestAnimationFrame.
+ // As to update the component too frequently has performance issue if React controlled,
+ // update raw component directly. See Bug 1699039.
+ this._ref.current.textContent = text;
+ }
+
+ render() {
+ return dom.label({ className: "current-time-label", ref: this._ref });
+ }
+}
+
+/**
+ * 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";
+ }
+
+ const sign = time < 0 ? "-" : "";
+ let milliseconds = parseInt(Math.abs(time % 1000), 10);
+ let seconds = parseInt(Math.abs(time / 1000) % 60, 10);
+ let minutes = parseInt(Math.abs(time / (1000 * 60)), 10);
+
+ minutes = minutes.toString().padStart(2, "0");
+ seconds = seconds.toString().padStart(2, "0");
+ milliseconds = milliseconds.toString().padStart(3, "0");
+ return `${sign}${minutes}:${seconds}.${milliseconds}`;
+}
+
+module.exports = CurrentTimeLabel;
diff --git a/devtools/client/inspector/animation/components/CurrentTimeScrubber.js b/devtools/client/inspector/animation/components/CurrentTimeScrubber.js
new file mode 100644
index 0000000000..8b87e32eff
--- /dev/null
+++ b/devtools/client/inspector/animation/components/CurrentTimeScrubber.js
@@ -0,0 +1,131 @@
+/* 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 DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+const {
+ createRef,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+class CurrentTimeScrubber extends PureComponent {
+ static get propTypes() {
+ return {
+ addAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ direction: PropTypes.string.isRequired,
+ removeAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ setAnimationsCurrentTime: PropTypes.func.isRequired,
+ timeScale: PropTypes.object.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this._ref = createRef();
+
+ const { addAnimationsCurrentTimeListener } = props;
+ this.onCurrentTimeUpdated = this.onCurrentTimeUpdated.bind(this);
+ this.onMouseDown = this.onMouseDown.bind(this);
+ this.onMouseMove = this.onMouseMove.bind(this);
+ this.onMouseUp = this.onMouseUp.bind(this);
+
+ addAnimationsCurrentTimeListener(this.onCurrentTimeUpdated);
+ }
+
+ componentDidMount() {
+ const current = this._ref.current;
+ current.addEventListener("mousedown", this.onMouseDown);
+ this._scrubber = current.querySelector(".current-time-scrubber");
+ }
+
+ componentWillUnmount() {
+ const { removeAnimationsCurrentTimeListener } = this.props;
+ removeAnimationsCurrentTimeListener(this.onCurrentTimeUpdated);
+ }
+
+ onCurrentTimeUpdated(currentTime) {
+ const { timeScale } = this.props;
+
+ const position = currentTime / timeScale.getDuration();
+ // onCurrentTimeUpdated is bound to requestAnimationFrame.
+ // As to update the component too frequently has performance issue if React controlled,
+ // update raw component directly. See Bug 1699039.
+ this._scrubber.style.marginInlineStart = `${position * 100}%`;
+ }
+
+ onMouseDown(event) {
+ event.stopPropagation();
+ const current = this._ref.current;
+ this.controllerArea = current.getBoundingClientRect();
+ this.listenerTarget = DevToolsUtils.getTopWindow(current.ownerGlobal);
+ this.listenerTarget.addEventListener("mousemove", this.onMouseMove);
+ this.listenerTarget.addEventListener("mouseup", this.onMouseUp);
+ this.decorationTarget = current.closest(".animation-list-container");
+ this.decorationTarget.classList.add("active-scrubber");
+
+ this.updateAnimationsCurrentTime(event.pageX, true);
+ }
+
+ onMouseMove(event) {
+ event.stopPropagation();
+ this.isMouseMoved = true;
+ this.updateAnimationsCurrentTime(event.pageX);
+ }
+
+ onMouseUp(event) {
+ event.stopPropagation();
+
+ if (this.isMouseMoved) {
+ this.updateAnimationsCurrentTime(event.pageX, true);
+ this.isMouseMoved = null;
+ }
+
+ this.uninstallListeners();
+ }
+
+ uninstallListeners() {
+ this.listenerTarget.removeEventListener("mousemove", this.onMouseMove);
+ this.listenerTarget.removeEventListener("mouseup", this.onMouseUp);
+ this.listenerTarget = null;
+ this.decorationTarget.classList.remove("active-scrubber");
+ this.decorationTarget = null;
+ this.controllerArea = null;
+ }
+
+ updateAnimationsCurrentTime(pageX, needRefresh) {
+ const { direction, setAnimationsCurrentTime, timeScale } = this.props;
+
+ let progressRate =
+ (pageX - this.controllerArea.x) / this.controllerArea.width;
+
+ if (progressRate < 0.0) {
+ progressRate = 0.0;
+ } else if (progressRate > 1.0) {
+ progressRate = 1.0;
+ }
+
+ const time =
+ direction === "ltr"
+ ? progressRate * timeScale.getDuration()
+ : (1 - progressRate) * timeScale.getDuration();
+
+ setAnimationsCurrentTime(time, needRefresh);
+ }
+
+ render() {
+ return dom.div(
+ {
+ className: "current-time-scrubber-area",
+ ref: this._ref,
+ },
+ dom.div({ className: "indication-bar current-time-scrubber" })
+ );
+ }
+}
+
+module.exports = CurrentTimeScrubber;
diff --git a/devtools/client/inspector/animation/components/KeyframesProgressBar.js b/devtools/client/inspector/animation/components/KeyframesProgressBar.js
new file mode 100644
index 0000000000..b4e9e526de
--- /dev/null
+++ b/devtools/client/inspector/animation/components/KeyframesProgressBar.js
@@ -0,0 +1,108 @@
+/* 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 {
+ createRef,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+class KeyframesProgressBar extends PureComponent {
+ static get propTypes() {
+ return {
+ addAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ animation: PropTypes.object.isRequired,
+ getAnimationsCurrentTime: PropTypes.func.isRequired,
+ removeAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+ simulateAnimationForKeyframesProgressBar: PropTypes.func.isRequired,
+ timeScale: PropTypes.object.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this._progressBarRef = createRef();
+
+ this.onCurrentTimeUpdated = this.onCurrentTimeUpdated.bind(this);
+ }
+
+ componentDidMount() {
+ const { addAnimationsCurrentTimeListener } = this.props;
+
+ this.setupAnimation(this.props);
+ addAnimationsCurrentTimeListener(this.onCurrentTimeUpdated);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { animation, getAnimationsCurrentTime, timeScale } = nextProps;
+
+ this.setupAnimation(nextProps);
+ this.updateOffset(getAnimationsCurrentTime(), animation, timeScale);
+ }
+
+ componentWillUnmount() {
+ const { removeAnimationsCurrentTimeListener } = this.props;
+
+ removeAnimationsCurrentTimeListener(this.onCurrentTimeUpdated);
+ this.simulatedAnimation = null;
+ }
+
+ onCurrentTimeUpdated(currentTime) {
+ const { animation, timeScale } = this.props;
+ this.updateOffset(currentTime, animation, timeScale);
+ }
+
+ updateOffset(currentTime, animation, timeScale) {
+ const { createdTime, playbackRate } = animation.state;
+
+ const time =
+ (timeScale.minStartTime + currentTime - createdTime) * playbackRate;
+
+ if (isNaN(time)) {
+ // Setting an invalid currentTime will throw so bail out if time is not a number for
+ // any reason.
+ return;
+ }
+
+ this.simulatedAnimation.currentTime = time;
+ const position =
+ this.simulatedAnimation.effect.getComputedTiming().progress;
+
+ // onCurrentTimeUpdated is bound to requestAnimationFrame.
+ // As to update the component too frequently has performance issue if React controlled,
+ // update raw component directly. See Bug 1699039.
+ this._progressBarRef.current.style.marginInlineStart = `${position * 100}%`;
+ }
+
+ setupAnimation(props) {
+ const { animation, simulateAnimationForKeyframesProgressBar } = props;
+
+ if (this.simulatedAnimation) {
+ this.simulatedAnimation.cancel();
+ }
+
+ const timing = Object.assign({}, animation.state, {
+ iterations: animation.state.iterationCount || Infinity,
+ });
+
+ this.simulatedAnimation = simulateAnimationForKeyframesProgressBar(timing);
+ }
+
+ render() {
+ return dom.div(
+ { className: "keyframes-progress-bar-area" },
+ dom.div({
+ className: "indication-bar keyframes-progress-bar",
+ ref: this._progressBarRef,
+ })
+ );
+ }
+}
+
+module.exports = KeyframesProgressBar;
diff --git a/devtools/client/inspector/animation/components/NoAnimationPanel.js b/devtools/client/inspector/animation/components/NoAnimationPanel.js
new file mode 100644
index 0000000000..ea034e413d
--- /dev/null
+++ b/devtools/client/inspector/animation/components/NoAnimationPanel.js
@@ -0,0 +1,61 @@
+/* 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 {
+ Component,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+
+const L10N = new LocalizationHelper(
+ "devtools/client/locales/animationinspector.properties"
+);
+
+class NoAnimationPanel extends Component {
+ static get propTypes() {
+ return {
+ elementPickerEnabled: PropTypes.bool.isRequired,
+ toggleElementPicker: PropTypes.func.isRequired,
+ };
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return this.props.elementPickerEnabled != nextProps.elementPickerEnabled;
+ }
+
+ render() {
+ const { elementPickerEnabled, toggleElementPicker } = this.props;
+
+ return dom.div(
+ {
+ className: "animation-error-message devtools-sidepanel-no-result",
+ },
+ dom.p(null, L10N.getStr("panel.noAnimation")),
+ dom.button({
+ className:
+ "animation-element-picker devtools-button" +
+ (elementPickerEnabled ? " checked" : ""),
+ "data-standalone": true,
+ onClick: event => {
+ event.stopPropagation();
+ toggleElementPicker();
+ },
+ })
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ elementPickerEnabled: state.animations.elementPickerEnabled,
+ };
+};
+
+module.exports = connect(mapStateToProps)(NoAnimationPanel);
diff --git a/devtools/client/inspector/animation/components/PauseResumeButton.js b/devtools/client/inspector/animation/components/PauseResumeButton.js
new file mode 100644
index 0000000000..bcbb597a39
--- /dev/null
+++ b/devtools/client/inspector/animation/components/PauseResumeButton.js
@@ -0,0 +1,104 @@
+/* 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 {
+ createRef,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
+
+const {
+ getStr,
+} = require("resource://devtools/client/inspector/animation/utils/l10n.js");
+const {
+ hasRunningAnimation,
+} = require("resource://devtools/client/inspector/animation/utils/utils.js");
+
+class PauseResumeButton extends PureComponent {
+ static get propTypes() {
+ return {
+ animations: PropTypes.arrayOf(PropTypes.object).isRequired,
+ setAnimationsPlayState: PropTypes.func.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.onKeyDown = this.onKeyDown.bind(this);
+ this.pauseResumeButtonRef = createRef();
+
+ this.state = {
+ isRunning: false,
+ };
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillMount() {
+ this.updateState(this.props);
+ }
+
+ componentDidMount() {
+ const targetEl = this.getKeyEventTarget();
+ targetEl.addEventListener("keydown", this.onKeyDown);
+ }
+
+ componentDidUpdate() {
+ this.updateState();
+ }
+
+ componentWillUnount() {
+ const targetEl = this.getKeyEventTarget();
+ targetEl.removeEventListener("keydown", this.onKeyDown);
+ }
+
+ getKeyEventTarget() {
+ return this.pauseResumeButtonRef.current.closest("#animation-container");
+ }
+
+ onToggleAnimationsPlayState(event) {
+ event.stopPropagation();
+ const { setAnimationsPlayState } = this.props;
+ const { isRunning } = this.state;
+
+ setAnimationsPlayState(!isRunning);
+ }
+
+ onKeyDown(event) {
+ // Prevent to the duplicated call from the key listener and click listener.
+ if (
+ event.keyCode === KeyCodes.DOM_VK_SPACE &&
+ event.target !== this.pauseResumeButtonRef.current
+ ) {
+ this.onToggleAnimationsPlayState(event);
+ }
+ }
+
+ updateState() {
+ const { animations } = this.props;
+ const isRunning = hasRunningAnimation(animations);
+ this.setState({ isRunning });
+ }
+
+ render() {
+ const { isRunning } = this.state;
+
+ return dom.button({
+ className:
+ "pause-resume-button devtools-button" + (isRunning ? "" : " paused"),
+ onClick: this.onToggleAnimationsPlayState.bind(this),
+ title: isRunning
+ ? getStr("timeline.resumedButtonTooltip")
+ : getStr("timeline.pausedButtonTooltip"),
+ ref: this.pauseResumeButtonRef,
+ });
+ }
+}
+
+module.exports = PauseResumeButton;
diff --git a/devtools/client/inspector/animation/components/PlaybackRateSelector.js b/devtools/client/inspector/animation/components/PlaybackRateSelector.js
new file mode 100644
index 0000000000..2d0de53a0c
--- /dev/null
+++ b/devtools/client/inspector/animation/components/PlaybackRateSelector.js
@@ -0,0 +1,108 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+
+const {
+ getFormatStr,
+} = require("resource://devtools/client/inspector/animation/utils/l10n.js");
+
+const PLAYBACK_RATES = [0.1, 0.25, 0.5, 1, 2, 5, 10];
+
+class PlaybackRateSelector extends PureComponent {
+ static get propTypes() {
+ return {
+ animations: PropTypes.arrayOf(PropTypes.object).isRequired,
+ playbackRates: PropTypes.arrayOf(PropTypes.number).isRequired,
+ setAnimationsPlaybackRate: PropTypes.func.isRequired,
+ };
+ }
+
+ static getDerivedStateFromProps(props, state) {
+ const { animations, playbackRates } = props;
+
+ const currentPlaybackRates = sortAndUnique(
+ animations.map(a => a.state.playbackRate)
+ );
+ const options = sortAndUnique([
+ ...PLAYBACK_RATES,
+ ...playbackRates,
+ ...currentPlaybackRates,
+ ]);
+
+ if (currentPlaybackRates.length === 1) {
+ return {
+ options,
+ selected: currentPlaybackRates[0],
+ };
+ }
+
+ // When the animations displayed have mixed playback rates, we can't
+ // select any of the predefined ones.
+ return {
+ options: ["", ...options],
+ selected: "",
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ options: [],
+ selected: 1,
+ };
+ }
+
+ onChange(e) {
+ const { setAnimationsPlaybackRate } = this.props;
+
+ if (!e.target.value) {
+ return;
+ }
+
+ setAnimationsPlaybackRate(e.target.value);
+ }
+
+ render() {
+ const { options, selected } = this.state;
+
+ return dom.select(
+ {
+ className: "playback-rate-selector devtools-button",
+ onChange: this.onChange.bind(this),
+ },
+ options.map(rate => {
+ return dom.option(
+ {
+ selected: rate === selected ? "true" : null,
+ value: rate,
+ },
+ rate ? getFormatStr("player.playbackRateLabel", rate) : "-"
+ );
+ })
+ );
+ }
+}
+
+function sortAndUnique(array) {
+ return [...new Set(array)].sort((a, b) => a > b);
+}
+
+const mapStateToProps = state => {
+ return {
+ playbackRates: state.animations.playbackRates,
+ };
+};
+
+module.exports = connect(mapStateToProps)(PlaybackRateSelector);
diff --git a/devtools/client/inspector/animation/components/ProgressInspectionPanel.js b/devtools/client/inspector/animation/components/ProgressInspectionPanel.js
new file mode 100644
index 0000000000..71c82dd17b
--- /dev/null
+++ b/devtools/client/inspector/animation/components/ProgressInspectionPanel.js
@@ -0,0 +1,49 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+const TickLabels = createFactory(
+ require("resource://devtools/client/inspector/animation/components/TickLabels.js")
+);
+const TickLines = createFactory(
+ require("resource://devtools/client/inspector/animation/components/TickLines.js")
+);
+
+/**
+ * This component is a panel for the progress of animations or keyframes, supports
+ * displaying the ticks, take the areas of indicator and the list.
+ */
+class ProgressInspectionPanel extends PureComponent {
+ static get propTypes() {
+ return {
+ indicator: PropTypes.any.isRequired,
+ list: PropTypes.any.isRequired,
+ ticks: PropTypes.arrayOf(PropTypes.object).isRequired,
+ };
+ }
+
+ render() {
+ const { indicator, list, ticks } = this.props;
+
+ return dom.div(
+ {
+ className: "progress-inspection-panel",
+ },
+ dom.div({ className: "background" }, TickLines({ ticks })),
+ dom.div({ className: "indicator" }, indicator),
+ dom.div({ className: "header devtools-toolbar" }, TickLabels({ ticks })),
+ list
+ );
+ }
+}
+
+module.exports = ProgressInspectionPanel;
diff --git a/devtools/client/inspector/animation/components/RewindButton.js b/devtools/client/inspector/animation/components/RewindButton.js
new file mode 100644
index 0000000000..f069b42368
--- /dev/null
+++ b/devtools/client/inspector/animation/components/RewindButton.js
@@ -0,0 +1,38 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const {
+ getStr,
+} = require("resource://devtools/client/inspector/animation/utils/l10n.js");
+
+class RewindButton extends PureComponent {
+ static get propTypes() {
+ return {
+ rewindAnimationsCurrentTime: PropTypes.func.isRequired,
+ };
+ }
+
+ render() {
+ const { rewindAnimationsCurrentTime } = this.props;
+
+ return dom.button({
+ className: "rewind-button devtools-button",
+ onClick: event => {
+ event.stopPropagation();
+ rewindAnimationsCurrentTime();
+ },
+ title: getStr("timeline.rewindButtonTooltip"),
+ });
+ }
+}
+
+module.exports = RewindButton;
diff --git a/devtools/client/inspector/animation/components/TickLabels.js b/devtools/client/inspector/animation/components/TickLabels.js
new file mode 100644
index 0000000000..019c7b177b
--- /dev/null
+++ b/devtools/client/inspector/animation/components/TickLabels.js
@@ -0,0 +1,46 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+/**
+ * This component is intended to show tick labels on the header.
+ */
+class TickLabels extends PureComponent {
+ static get propTypes() {
+ return {
+ ticks: PropTypes.array.isRequired,
+ };
+ }
+
+ render() {
+ const { ticks } = this.props;
+
+ return dom.div(
+ {
+ className: "tick-labels",
+ },
+ ticks.map(tick =>
+ dom.div(
+ {
+ className: "tick-label",
+ style: {
+ marginInlineStart: `${tick.position}%`,
+ maxWidth: `${tick.width}px`,
+ },
+ },
+ tick.label
+ )
+ )
+ );
+ }
+}
+
+module.exports = TickLabels;
diff --git a/devtools/client/inspector/animation/components/TickLines.js b/devtools/client/inspector/animation/components/TickLines.js
new file mode 100644
index 0000000000..d3cbdead98
--- /dev/null
+++ b/devtools/client/inspector/animation/components/TickLines.js
@@ -0,0 +1,40 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+/**
+ * This component is intended to show the tick line as the background.
+ */
+class TickLines extends PureComponent {
+ static get propTypes() {
+ return {
+ ticks: PropTypes.array.isRequired,
+ };
+ }
+
+ render() {
+ const { ticks } = this.props;
+
+ return dom.div(
+ {
+ className: "tick-lines",
+ },
+ ticks.map(tick =>
+ dom.div({
+ className: "tick-line",
+ style: { marginInlineStart: `${tick.position}%` },
+ })
+ )
+ );
+ }
+}
+
+module.exports = TickLines;
diff --git a/devtools/client/inspector/animation/components/graph/AnimationName.js b/devtools/client/inspector/animation/components/graph/AnimationName.js
new file mode 100644
index 0000000000..1ef2ebd829
--- /dev/null
+++ b/devtools/client/inspector/animation/components/graph/AnimationName.js
@@ -0,0 +1,38 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+class AnimationName extends PureComponent {
+ static get propTypes() {
+ return {
+ animation: PropTypes.object.isRequired,
+ };
+ }
+
+ render() {
+ const { animation } = this.props;
+
+ return dom.svg(
+ {
+ className: "animation-name",
+ },
+ dom.text(
+ {
+ y: "50%",
+ x: "100%",
+ },
+ animation.state.name
+ )
+ );
+ }
+}
+
+module.exports = AnimationName;
diff --git a/devtools/client/inspector/animation/components/graph/ComputedTimingPath.js b/devtools/client/inspector/animation/components/graph/ComputedTimingPath.js
new file mode 100644
index 0000000000..fed25e161e
--- /dev/null
+++ b/devtools/client/inspector/animation/components/graph/ComputedTimingPath.js
@@ -0,0 +1,104 @@
+/* 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 PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+const {
+ createSummaryGraphPathStringFunction,
+ SummaryGraphHelper,
+} = require("resource://devtools/client/inspector/animation/utils/graph-helper.js");
+const TimingPath = require("resource://devtools/client/inspector/animation/components/graph/TimingPath.js");
+
+class ComputedTimingPath extends TimingPath {
+ static get propTypes() {
+ return {
+ animation: PropTypes.object.isRequired,
+ durationPerPixel: PropTypes.number.isRequired,
+ keyframes: PropTypes.object.isRequired,
+ offset: PropTypes.number.isRequired,
+ opacity: PropTypes.number.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ totalDuration: PropTypes.number.isRequired,
+ };
+ }
+
+ render() {
+ const {
+ animation,
+ durationPerPixel,
+ keyframes,
+ offset,
+ opacity,
+ simulateAnimation,
+ totalDuration,
+ } = this.props;
+
+ const { state } = animation;
+ const effectTiming = Object.assign({}, state, {
+ iterations: state.iterationCount ? state.iterationCount : Infinity,
+ });
+
+ // Create new keyframes for opacity as computed style.
+ // The reason why we use computed value instead of computed timing progress is to
+ // include the easing in keyframes as well. Although the computed timing progress
+ // is not affected by the easing in keyframes at all, computed value reflects that.
+ const frames = keyframes.map(keyframe => {
+ return {
+ opacity: keyframe.offset,
+ offset: keyframe.offset,
+ easing: keyframe.easing,
+ };
+ });
+
+ const simulatedAnimation = simulateAnimation(frames, effectTiming, true);
+
+ if (!simulatedAnimation) {
+ return null;
+ }
+
+ const simulatedElement = simulatedAnimation.effect.target;
+ const win = simulatedElement.ownerGlobal;
+ const endTime = simulatedAnimation.effect.getComputedTiming().endTime;
+
+ // Set the underlying opacity to zero so that if we sample the animation's output
+ // during the delay phase and it is not filling backwards, we get zero.
+ simulatedElement.style.opacity = 0;
+
+ const getValueFunc = time => {
+ if (time < 0) {
+ return 0;
+ }
+
+ simulatedAnimation.currentTime = time < endTime ? time : endTime;
+ return win.getComputedStyle(simulatedElement).opacity;
+ };
+
+ const toPathStringFunc = createSummaryGraphPathStringFunction(
+ endTime,
+ state.playbackRate
+ );
+ const helper = new SummaryGraphHelper(
+ state,
+ keyframes,
+ totalDuration,
+ durationPerPixel,
+ getValueFunc,
+ toPathStringFunc
+ );
+
+ return dom.g(
+ {
+ className: "animation-computed-timing-path",
+ style: { opacity },
+ transform: `translate(${offset})`,
+ },
+ super.renderGraph(state, helper)
+ );
+ }
+}
+
+module.exports = ComputedTimingPath;
diff --git a/devtools/client/inspector/animation/components/graph/DelaySign.js b/devtools/client/inspector/animation/components/graph/DelaySign.js
new file mode 100644
index 0000000000..4e817a9d61
--- /dev/null
+++ b/devtools/client/inspector/animation/components/graph/DelaySign.js
@@ -0,0 +1,42 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+class DelaySign extends PureComponent {
+ static get propTypes() {
+ return {
+ animation: PropTypes.object.isRequired,
+ timeScale: PropTypes.object.isRequired,
+ };
+ }
+
+ render() {
+ const { animation, timeScale } = this.props;
+ const { delay, isDelayFilled, startTime } = animation.state.absoluteValues;
+
+ const toPercentage = v => (v / timeScale.getDuration()) * 100;
+ const offset = toPercentage(startTime - timeScale.minStartTime);
+ const width = toPercentage(Math.abs(delay));
+
+ return dom.div({
+ className:
+ "animation-delay-sign" +
+ (delay < 0 ? " negative" : "") +
+ (isDelayFilled ? " fill" : ""),
+ style: {
+ width: `${width}%`,
+ marginInlineStart: `${offset}%`,
+ },
+ });
+ }
+}
+
+module.exports = DelaySign;
diff --git a/devtools/client/inspector/animation/components/graph/EffectTimingPath.js b/devtools/client/inspector/animation/components/graph/EffectTimingPath.js
new file mode 100644
index 0000000000..4a984bc61c
--- /dev/null
+++ b/devtools/client/inspector/animation/components/graph/EffectTimingPath.js
@@ -0,0 +1,84 @@
+/* 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 PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+const {
+ createSummaryGraphPathStringFunction,
+ SummaryGraphHelper,
+} = require("resource://devtools/client/inspector/animation/utils/graph-helper.js");
+const TimingPath = require("resource://devtools/client/inspector/animation/components/graph/TimingPath.js");
+
+class EffectTimingPath extends TimingPath {
+ static get propTypes() {
+ return {
+ animation: PropTypes.object.isRequired,
+ durationPerPixel: PropTypes.number.isRequired,
+ offset: PropTypes.number.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ totalDuration: PropTypes.number.isRequired,
+ };
+ }
+
+ render() {
+ const {
+ animation,
+ durationPerPixel,
+ offset,
+ simulateAnimation,
+ totalDuration,
+ } = this.props;
+
+ const { state } = animation;
+ const effectTiming = Object.assign({}, state, {
+ iterations: state.iterationCount ? state.iterationCount : Infinity,
+ });
+
+ const simulatedAnimation = simulateAnimation(null, effectTiming, false);
+
+ if (!simulatedAnimation) {
+ return null;
+ }
+
+ const endTime = simulatedAnimation.effect.getComputedTiming().endTime;
+
+ const getValueFunc = time => {
+ if (time < 0) {
+ return 0;
+ }
+
+ simulatedAnimation.currentTime = time < endTime ? time : endTime;
+ return Math.max(
+ simulatedAnimation.effect.getComputedTiming().progress,
+ 0
+ );
+ };
+
+ const toPathStringFunc = createSummaryGraphPathStringFunction(
+ endTime,
+ state.playbackRate
+ );
+ const helper = new SummaryGraphHelper(
+ state,
+ null,
+ totalDuration,
+ durationPerPixel,
+ getValueFunc,
+ toPathStringFunc
+ );
+
+ return dom.g(
+ {
+ className: "animation-effect-timing-path",
+ transform: `translate(${offset})`,
+ },
+ super.renderGraph(state, helper)
+ );
+ }
+}
+
+module.exports = EffectTimingPath;
diff --git a/devtools/client/inspector/animation/components/graph/EndDelaySign.js b/devtools/client/inspector/animation/components/graph/EndDelaySign.js
new file mode 100644
index 0000000000..f843123cbf
--- /dev/null
+++ b/devtools/client/inspector/animation/components/graph/EndDelaySign.js
@@ -0,0 +1,44 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+class EndDelaySign extends PureComponent {
+ static get propTypes() {
+ return {
+ animation: PropTypes.object.isRequired,
+ timeScale: PropTypes.object.isRequired,
+ };
+ }
+
+ render() {
+ const { animation, timeScale } = this.props;
+ const { endDelay, endTime, isEndDelayFilled } =
+ animation.state.absoluteValues;
+
+ const toPercentage = v => (v / timeScale.getDuration()) * 100;
+ const absEndDelay = Math.abs(endDelay);
+ const offset = toPercentage(endTime - absEndDelay - timeScale.minStartTime);
+ const width = toPercentage(absEndDelay);
+
+ return dom.div({
+ className:
+ "animation-end-delay-sign" +
+ (endDelay < 0 ? " negative" : "") +
+ (isEndDelayFilled ? " fill" : ""),
+ style: {
+ width: `${width}%`,
+ marginInlineStart: `${offset}%`,
+ },
+ });
+ }
+}
+
+module.exports = EndDelaySign;
diff --git a/devtools/client/inspector/animation/components/graph/NegativeDelayPath.js b/devtools/client/inspector/animation/components/graph/NegativeDelayPath.js
new file mode 100644
index 0000000000..0cd87dd320
--- /dev/null
+++ b/devtools/client/inspector/animation/components/graph/NegativeDelayPath.js
@@ -0,0 +1,27 @@
+/* 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 dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+const NegativePath = require("resource://devtools/client/inspector/animation/components/graph/NegativePath.js");
+
+class NegativeDelayPath extends NegativePath {
+ getClassName() {
+ return "animation-negative-delay-path";
+ }
+
+ renderGraph(state, helper) {
+ const startTime = state.delay;
+ const endTime = 0;
+ const segments = helper.createPathSegments(startTime, endTime);
+
+ return dom.path({
+ d: helper.toPathString(segments),
+ });
+ }
+}
+
+module.exports = NegativeDelayPath;
diff --git a/devtools/client/inspector/animation/components/graph/NegativeEndDelayPath.js b/devtools/client/inspector/animation/components/graph/NegativeEndDelayPath.js
new file mode 100644
index 0000000000..88f5f65037
--- /dev/null
+++ b/devtools/client/inspector/animation/components/graph/NegativeEndDelayPath.js
@@ -0,0 +1,27 @@
+/* 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 dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+const NegativePath = require("resource://devtools/client/inspector/animation/components/graph/NegativePath.js");
+
+class NegativeEndDelayPath extends NegativePath {
+ getClassName() {
+ return "animation-negative-end-delay-path";
+ }
+
+ renderGraph(state, helper) {
+ const endTime = state.delay + state.iterationCount * state.duration;
+ const startTime = endTime + state.endDelay;
+ const segments = helper.createPathSegments(startTime, endTime);
+
+ return dom.path({
+ d: helper.toPathString(segments),
+ });
+ }
+}
+
+module.exports = NegativeEndDelayPath;
diff --git a/devtools/client/inspector/animation/components/graph/NegativePath.js b/devtools/client/inspector/animation/components/graph/NegativePath.js
new file mode 100644
index 0000000000..4f1d0af9e6
--- /dev/null
+++ b/devtools/client/inspector/animation/components/graph/NegativePath.js
@@ -0,0 +1,101 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+const {
+ createSummaryGraphPathStringFunction,
+ SummaryGraphHelper,
+} = require("resource://devtools/client/inspector/animation/utils/graph-helper.js");
+
+class NegativePath extends PureComponent {
+ static get propTypes() {
+ return {
+ animation: PropTypes.object.isRequired,
+ className: PropTypes.string.isRequired,
+ durationPerPixel: PropTypes.number.isRequired,
+ keyframes: PropTypes.object.isRequired,
+ offset: PropTypes.number.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ totalDuration: PropTypes.number.isRequired,
+ };
+ }
+
+ render() {
+ const {
+ animation,
+ durationPerPixel,
+ keyframes,
+ offset,
+ simulateAnimation,
+ totalDuration,
+ } = this.props;
+
+ const { state } = animation;
+ const effectTiming = Object.assign({}, state, {
+ fill: "both",
+ iterations: state.iterationCount ? state.iterationCount : Infinity,
+ });
+
+ // Create new keyframes for opacity as computed style.
+ // The reason why we use computed value instead of computed timing progress is to
+ // include the easing in keyframes as well. Although the computed timing progress
+ // is not affected by the easing in keyframes at all, computed value reflects that.
+ const frames = keyframes.map(keyframe => {
+ return {
+ opacity: keyframe.offset,
+ offset: keyframe.offset,
+ easing: keyframe.easing,
+ };
+ });
+
+ const simulatedAnimation = simulateAnimation(frames, effectTiming, true);
+
+ if (!simulatedAnimation) {
+ return null;
+ }
+
+ const simulatedElement = simulatedAnimation.effect.target;
+ const win = simulatedElement.ownerGlobal;
+ const endTime = simulatedAnimation.effect.getComputedTiming().endTime;
+
+ // Set the underlying opacity to zero so that if we sample the animation's output
+ // during the delay phase and it is not filling backwards, we get zero.
+ simulatedElement.style.opacity = 0;
+
+ const getValueFunc = time => {
+ simulatedAnimation.currentTime = time;
+ return win.getComputedStyle(simulatedElement).opacity;
+ };
+
+ const toPathStringFunc = createSummaryGraphPathStringFunction(
+ endTime,
+ state.playbackRate
+ );
+ const helper = new SummaryGraphHelper(
+ state,
+ keyframes,
+ totalDuration,
+ durationPerPixel,
+ getValueFunc,
+ toPathStringFunc
+ );
+
+ return dom.g(
+ {
+ className: this.getClassName(),
+ transform: `translate(${offset})`,
+ },
+ this.renderGraph(state, helper)
+ );
+ }
+}
+
+module.exports = NegativePath;
diff --git a/devtools/client/inspector/animation/components/graph/SummaryGraph.js b/devtools/client/inspector/animation/components/graph/SummaryGraph.js
new file mode 100644
index 0000000000..bac54ea26f
--- /dev/null
+++ b/devtools/client/inspector/animation/components/graph/SummaryGraph.js
@@ -0,0 +1,205 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const AnimationName = createFactory(
+ require("resource://devtools/client/inspector/animation/components/graph/AnimationName.js")
+);
+const DelaySign = createFactory(
+ require("resource://devtools/client/inspector/animation/components/graph/DelaySign.js")
+);
+const EndDelaySign = createFactory(
+ require("resource://devtools/client/inspector/animation/components/graph/EndDelaySign.js")
+);
+const SummaryGraphPath = createFactory(
+ require("resource://devtools/client/inspector/animation/components/graph/SummaryGraphPath.js")
+);
+
+const {
+ getFormattedTitle,
+ getFormatStr,
+ getStr,
+ numberWithDecimals,
+} = require("resource://devtools/client/inspector/animation/utils/l10n.js");
+
+class SummaryGraph extends PureComponent {
+ static get propTypes() {
+ return {
+ animation: PropTypes.object.isRequired,
+ getAnimatedPropertyMap: PropTypes.func.isRequired,
+ selectAnimation: PropTypes.func.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ timeScale: PropTypes.object.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.onClick = this.onClick.bind(this);
+ }
+
+ onClick(event) {
+ event.stopPropagation();
+ this.props.selectAnimation(this.props.animation);
+ }
+
+ getTitleText(state) {
+ const getTime = time =>
+ getFormatStr("player.timeLabel", numberWithDecimals(time / 1000, 2));
+ const getTimeOrInfinity = time =>
+ time === Infinity ? getStr("player.infiniteDurationText") : getTime(time);
+
+ let text = "";
+
+ // Adding the name.
+ text += getFormattedTitle(state);
+ text += "\n";
+
+ // Adding the delay.
+ if (state.delay) {
+ text += getStr("player.animationDelayLabel") + " ";
+ text += getTime(state.delay);
+ text += "\n";
+ }
+
+ // Adding the duration.
+ text += getStr("player.animationDurationLabel") + " ";
+ text += getTimeOrInfinity(state.duration);
+ text += "\n";
+
+ // Adding the endDelay.
+ if (state.endDelay) {
+ text += getStr("player.animationEndDelayLabel") + " ";
+ text += getTime(state.endDelay);
+ text += "\n";
+ }
+
+ // Adding the iteration count (the infinite symbol, or an integer).
+ if (state.iterationCount !== 1) {
+ text += getStr("player.animationIterationCountLabel") + " ";
+ text +=
+ state.iterationCount || getStr("player.infiniteIterationCountText");
+ text += "\n";
+ }
+
+ // Adding the iteration start.
+ if (state.iterationStart !== 0) {
+ text += getFormatStr(
+ "player.animationIterationStartLabel2",
+ state.iterationStart,
+ getTimeOrInfinity(state.iterationStart * state.duration)
+ );
+ text += "\n";
+ }
+
+ // Adding the easing if it is not "linear".
+ if (state.easing && state.easing !== "linear") {
+ text += getStr("player.animationOverallEasingLabel") + " ";
+ text += state.easing;
+ text += "\n";
+ }
+
+ // Adding the fill mode.
+ if (state.fill && state.fill !== "none") {
+ text += getStr("player.animationFillLabel") + " ";
+ text += state.fill;
+ text += "\n";
+ }
+
+ // Adding the direction mode if it is not "normal".
+ if (state.direction && state.direction !== "normal") {
+ text += getStr("player.animationDirectionLabel") + " ";
+ text += state.direction;
+ text += "\n";
+ }
+
+ // Adding the playback rate if it's different than 1.
+ if (state.playbackRate !== 1) {
+ text += getStr("player.animationRateLabel") + " ";
+ text += state.playbackRate;
+ text += "\n";
+ }
+
+ // Adding the animation-timing-function
+ // if it is not "ease" which is default value for CSS Animations.
+ if (
+ state.animationTimingFunction &&
+ state.animationTimingFunction !== "ease"
+ ) {
+ text += getStr("player.animationTimingFunctionLabel") + " ";
+ text += state.animationTimingFunction;
+ 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 += getStr("player.allPropertiesOnCompositorTooltip");
+ } else if (
+ state.propertyState.some(propState => propState.runningOnCompositor)
+ ) {
+ text += getStr("player.somePropertiesOnCompositorTooltip");
+ }
+ } else if (state.isRunningOnCompositor) {
+ text += getStr("player.runningOnCompositorTooltip");
+ }
+
+ return text;
+ }
+
+ render() {
+ const { animation, getAnimatedPropertyMap, simulateAnimation, timeScale } =
+ this.props;
+
+ const { iterationCount } = animation.state;
+ const { delay, endDelay } = animation.state.absoluteValues;
+
+ return dom.div(
+ {
+ className:
+ "animation-summary-graph" +
+ (animation.state.isRunningOnCompositor ? " compositor" : ""),
+ onClick: this.onClick,
+ title: this.getTitleText(animation.state),
+ },
+ SummaryGraphPath({
+ animation,
+ getAnimatedPropertyMap,
+ simulateAnimation,
+ timeScale,
+ }),
+ delay
+ ? DelaySign({
+ animation,
+ timeScale,
+ })
+ : null,
+ iterationCount && endDelay
+ ? EndDelaySign({
+ animation,
+ timeScale,
+ })
+ : null,
+ animation.state.name
+ ? AnimationName({
+ animation,
+ })
+ : null
+ );
+ }
+}
+
+module.exports = SummaryGraph;
diff --git a/devtools/client/inspector/animation/components/graph/SummaryGraphPath.js b/devtools/client/inspector/animation/components/graph/SummaryGraphPath.js
new file mode 100644
index 0000000000..0183c7413e
--- /dev/null
+++ b/devtools/client/inspector/animation/components/graph/SummaryGraphPath.js
@@ -0,0 +1,282 @@
+/* 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 {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js");
+
+const ComputedTimingPath = createFactory(
+ require("resource://devtools/client/inspector/animation/components/graph/ComputedTimingPath.js")
+);
+const EffectTimingPath = createFactory(
+ require("resource://devtools/client/inspector/animation/components/graph/EffectTimingPath.js")
+);
+const NegativeDelayPath = createFactory(
+ require("resource://devtools/client/inspector/animation/components/graph/NegativeDelayPath.js")
+);
+const NegativeEndDelayPath = createFactory(
+ require("resource://devtools/client/inspector/animation/components/graph/NegativeEndDelayPath.js")
+);
+const {
+ DEFAULT_GRAPH_HEIGHT,
+} = require("resource://devtools/client/inspector/animation/utils/graph-helper.js");
+
+// Minimum opacity for semitransparent fill color for keyframes's easing graph.
+const MIN_KEYFRAMES_EASING_OPACITY = 0.5;
+
+class SummaryGraphPath extends Component {
+ static get propTypes() {
+ return {
+ animation: PropTypes.object.isRequired,
+ getAnimatedPropertyMap: PropTypes.object.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ timeScale: PropTypes.object.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ // Duration which can display in one pixel.
+ durationPerPixel: 0,
+ // To avoid rendering while the state is updating
+ // since we call an async function in updateState.
+ isStateUpdating: false,
+ // List of keyframe which consists by only offset and easing.
+ keyframesList: [],
+ };
+ }
+
+ componentDidMount() {
+ // No need to set isStateUpdating state since paint sequence is finish here.
+ this.updateState(this.props);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ this.setState({ isStateUpdating: true });
+ this.updateState(nextProps);
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return !nextState.isStateUpdating;
+ }
+
+ /**
+ * Return animatable keyframes list which has only offset and easing.
+ * Also, this method remove duplicate keyframes.
+ * For example, if the given animatedPropertyMap is,
+ * [
+ * {
+ * key: "color",
+ * values: [
+ * {
+ * offset: 0,
+ * easing: "ease",
+ * value: "rgb(255, 0, 0)",
+ * },
+ * {
+ * offset: 1,
+ * value: "rgb(0, 255, 0)",
+ * },
+ * ],
+ * },
+ * {
+ * key: "opacity",
+ * values: [
+ * {
+ * offset: 0,
+ * easing: "ease",
+ * value: 0,
+ * },
+ * {
+ * offset: 1,
+ * value: 1,
+ * },
+ * ],
+ * },
+ * ]
+ *
+ * then this method returns,
+ * [
+ * [
+ * {
+ * offset: 0,
+ * easing: "ease",
+ * },
+ * {
+ * offset: 1,
+ * },
+ * ],
+ * ]
+ *
+ * @param {Map} animated property map
+ * which can get form getAnimatedPropertyMap in animation.js
+ * @return {Array} list of keyframes which has only easing and offset.
+ */
+ getOffsetAndEasingOnlyKeyframes(animatedPropertyMap) {
+ return [...animatedPropertyMap.values()]
+ .filter((keyframes1, i, self) => {
+ return (
+ i !==
+ self.findIndex((keyframes2, j) => {
+ return this.isOffsetAndEasingKeyframesEqual(keyframes1, keyframes2)
+ ? j
+ : -1;
+ })
+ );
+ })
+ .map(keyframes => {
+ return keyframes.map(keyframe => {
+ return { easing: keyframe.easing, offset: keyframe.offset };
+ });
+ });
+ }
+
+ /**
+ * Return true if given keyframes have same length, offset and easing.
+ *
+ * @param {Array} keyframes1
+ * @param {Array} keyframes2
+ * @return {Boolean} true: equals
+ */
+ isOffsetAndEasingKeyframesEqual(keyframes1, keyframes2) {
+ if (keyframes1.length !== keyframes2.length) {
+ return false;
+ }
+
+ for (let i = 0; i < keyframes1.length; i++) {
+ const keyframe1 = keyframes1[i];
+ const keyframe2 = keyframes2[i];
+
+ if (
+ keyframe1.offset !== keyframe2.offset ||
+ keyframe1.easing !== keyframe2.easing
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ updateState(props) {
+ const { animation, getAnimatedPropertyMap, timeScale } = props;
+
+ let animatedPropertyMap = null;
+ let thisEl = null;
+
+ try {
+ animatedPropertyMap = getAnimatedPropertyMap(animation);
+ thisEl = ReactDOM.findDOMNode(this);
+ } catch (e) {
+ // Expected if we've already been destroyed or other node have been selected
+ // in the meantime.
+ console.error(e);
+ return;
+ }
+
+ const keyframesList =
+ this.getOffsetAndEasingOnlyKeyframes(animatedPropertyMap);
+ const totalDuration =
+ timeScale.getDuration() * Math.abs(animation.state.playbackRate);
+ const durationPerPixel = totalDuration / thisEl.parentNode.clientWidth;
+
+ this.setState({
+ durationPerPixel,
+ isStateUpdating: false,
+ keyframesList,
+ });
+ }
+
+ render() {
+ const { durationPerPixel, keyframesList } = this.state;
+ const { animation, simulateAnimation, timeScale } = this.props;
+
+ if (!durationPerPixel || !animation.state.type) {
+ // Undefined animation.state.type means that the animation had been removed already.
+ // Even if the animation was removed, we still need the empty svg since the
+ // component might be re-used.
+ return dom.svg();
+ }
+
+ const { playbackRate } = animation.state;
+ const { createdTime } = animation.state.absoluteValues;
+ const absPlaybackRate = Math.abs(playbackRate);
+
+ // Absorb the playbackRate in viewBox of SVG and offset of child path elements
+ // in order to each graph path components can draw without considering to the
+ // playbackRate.
+ const offset = createdTime * absPlaybackRate;
+ const startTime = timeScale.minStartTime * absPlaybackRate;
+ const totalDuration = timeScale.getDuration() * absPlaybackRate;
+ const opacity = Math.max(
+ 1 / keyframesList.length,
+ MIN_KEYFRAMES_EASING_OPACITY
+ );
+
+ return dom.svg(
+ {
+ className: "animation-summary-graph-path",
+ preserveAspectRatio: "none",
+ viewBox:
+ `${startTime} -${DEFAULT_GRAPH_HEIGHT} ` +
+ `${totalDuration} ${DEFAULT_GRAPH_HEIGHT}`,
+ },
+ keyframesList.map(keyframes =>
+ ComputedTimingPath({
+ animation,
+ durationPerPixel,
+ keyframes,
+ offset,
+ opacity,
+ simulateAnimation,
+ totalDuration,
+ })
+ ),
+ animation.state.easing !== "linear"
+ ? EffectTimingPath({
+ animation,
+ durationPerPixel,
+ offset,
+ simulateAnimation,
+ totalDuration,
+ })
+ : null,
+ animation.state.delay < 0
+ ? keyframesList.map(keyframes => {
+ return NegativeDelayPath({
+ animation,
+ durationPerPixel,
+ keyframes,
+ offset,
+ simulateAnimation,
+ totalDuration,
+ });
+ })
+ : null,
+ animation.state.iterationCount && animation.state.endDelay < 0
+ ? keyframesList.map(keyframes => {
+ return NegativeEndDelayPath({
+ animation,
+ durationPerPixel,
+ keyframes,
+ offset,
+ simulateAnimation,
+ totalDuration,
+ });
+ })
+ : null
+ );
+ }
+}
+
+module.exports = SummaryGraphPath;
diff --git a/devtools/client/inspector/animation/components/graph/TimingPath.js b/devtools/client/inspector/animation/components/graph/TimingPath.js
new file mode 100644
index 0000000000..7949527988
--- /dev/null
+++ b/devtools/client/inspector/animation/components/graph/TimingPath.js
@@ -0,0 +1,450 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+// Show max 10 iterations for infinite animations
+// to give users a clue that the animation does repeat.
+const MAX_INFINITE_ANIMATIONS_ITERATIONS = 10;
+
+class TimingPath extends PureComponent {
+ /**
+ * Render a graph of given parameters and return as <path> element list.
+ *
+ * @param {Object} state
+ * State of animation.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ * @return {Array}
+ * list of <path> element.
+ */
+ renderGraph(state, helper) {
+ // Starting time of main iteration.
+ let mainIterationStartTime = 0;
+ let iterationStart = state.iterationStart;
+ let iterationCount = state.iterationCount ? state.iterationCount : Infinity;
+
+ const pathList = [];
+
+ // Append delay.
+ if (state.delay > 0) {
+ this.renderDelay(pathList, state, helper);
+ 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;
+ }
+ }
+
+ if (state.duration === Infinity) {
+ this.renderInfinityDuration(
+ pathList,
+ state,
+ mainIterationStartTime,
+ helper
+ );
+ return pathList;
+ }
+
+ // 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(1 - (iterationStart % 1), iterationCount);
+
+ if (firstSectionCount) {
+ this.renderFirstIteration(
+ pathList,
+ state,
+ mainIterationStartTime,
+ firstSectionCount,
+ helper
+ );
+ }
+
+ if (iterationCount === Infinity) {
+ // If the animation repeats infinitely,
+ // we fill the remaining area with iteration paths.
+ this.renderInfinity(
+ pathList,
+ state,
+ mainIterationStartTime,
+ firstSectionCount,
+ helper
+ );
+ } else {
+ // Otherwise, we show remaining iterations, endDelay and fill.
+
+ // Append forwards fill-mode.
+ if (state.fill === "both" || state.fill === "forwards") {
+ this.renderForwardsFill(
+ pathList,
+ state,
+ mainIterationStartTime,
+ iterationCount,
+ helper
+ );
+ }
+
+ // Append middle section of iterations.
+ // e.g.
+ // if { iterationStart: 0.25, iterations: 3 }, middleSectionCount is 2.
+ const middleSectionCount = Math.floor(iterationCount - firstSectionCount);
+ this.renderMiddleIterations(
+ pathList,
+ state,
+ mainIterationStartTime,
+ firstSectionCount,
+ middleSectionCount,
+ helper
+ );
+
+ // 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) {
+ this.renderLastIteration(
+ pathList,
+ state,
+ mainIterationStartTime,
+ firstSectionCount,
+ middleSectionCount,
+ lastSectionCount,
+ helper
+ );
+ }
+
+ // Append endDelay.
+ if (state.endDelay > 0) {
+ this.renderEndDelay(
+ pathList,
+ state,
+ mainIterationStartTime,
+ iterationCount,
+ helper
+ );
+ }
+ }
+ return pathList;
+ }
+
+ /**
+ * Render 'delay' part in animation and add a <path> element to given pathList.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> element to this array.
+ * @param {Object} state
+ * State of animation.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderDelay(pathList, state, helper) {
+ const startSegment = helper.getSegment(0);
+ const endSegment = { x: state.delay, y: startSegment.y };
+ const segments = [startSegment, endSegment];
+ pathList.push(
+ dom.path({
+ className: "animation-delay-path",
+ d: helper.toPathString(segments),
+ })
+ );
+ }
+
+ /**
+ * Render 1st section of iterations and add a <path> element to given pathList.
+ * This section is only useful in cases where iterationStart has decimals.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> element to this array.
+ * @param {Object} state
+ * State of animation.
+ * @param {Number} mainIterationStartTime
+ * Start time of main iteration.
+ * @param {Number} firstSectionCount
+ * Iteration count of first section.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderFirstIteration(
+ pathList,
+ state,
+ mainIterationStartTime,
+ firstSectionCount,
+ helper
+ ) {
+ const startTime = mainIterationStartTime;
+ const endTime = startTime + firstSectionCount * state.duration;
+ const segments = helper.createPathSegments(startTime, endTime);
+ pathList.push(
+ dom.path({
+ className: "animation-iteration-path",
+ d: helper.toPathString(segments),
+ })
+ );
+ }
+
+ /**
+ * Render middle iterations and add <path> elements to given pathList.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> elements to this array.
+ * @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 {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderMiddleIterations(
+ pathList,
+ state,
+ mainIterationStartTime,
+ firstSectionCount,
+ middleSectionCount,
+ helper
+ ) {
+ 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 = helper.createPathSegments(startTime, endTime);
+ pathList.push(
+ dom.path({
+ className: "animation-iteration-path",
+ d: helper.toPathString(segments),
+ })
+ );
+ }
+ }
+
+ /**
+ * Render last section of iterations and add a <path> element to given pathList.
+ * This section is only useful in cases where iterationStart has decimals.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> elements to this array.
+ * @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 {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderLastIteration(
+ pathList,
+ state,
+ mainIterationStartTime,
+ firstSectionCount,
+ middleSectionCount,
+ lastSectionCount,
+ helper
+ ) {
+ const startTime =
+ mainIterationStartTime +
+ (firstSectionCount + middleSectionCount) * state.duration;
+ const endTime = startTime + lastSectionCount * state.duration;
+ const segments = helper.createPathSegments(startTime, endTime);
+ pathList.push(
+ dom.path({
+ className: "animation-iteration-path",
+ d: helper.toPathString(segments),
+ })
+ );
+ }
+
+ /**
+ * Render infinity iterations and add <path> elements to given pathList.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> elements to this array.
+ * @param {Object} state
+ * State of animation.
+ * @param {Number} mainIterationStartTime
+ * Starting time of main iteration.
+ * @param {Number} firstSectionCount
+ * Iteration count of first section.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderInfinity(
+ pathList,
+ state,
+ mainIterationStartTime,
+ firstSectionCount,
+ helper
+ ) {
+ // Calculate the number of iterations to display,
+ // with a maximum of MAX_INFINITE_ANIMATIONS_ITERATIONS
+ let uncappedInfinityIterationCount =
+ (helper.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 = helper.createPathSegments(
+ firstStartTime,
+ firstEndTime
+ );
+ pathList.push(
+ dom.path({
+ className: "animation-iteration-path",
+ d: helper.toPathString(firstSegments),
+ })
+ );
+
+ // 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 };
+ });
+ }
+ pathList.push(
+ dom.path({
+ className: "animation-iteration-path infinity",
+ d: helper.toPathString(segments),
+ })
+ );
+ }
+ }
+
+ /**
+ * Render infinity duration.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> elements to this array.
+ * @param {Object} state
+ * State of animation.
+ * @param {Number} mainIterationStartTime
+ * Starting time of main iteration.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderInfinityDuration(pathList, state, mainIterationStartTime, helper) {
+ const startSegment = helper.getSegment(mainIterationStartTime);
+ const endSegment = { x: helper.totalDuration, y: startSegment.y };
+ const segments = [startSegment, endSegment];
+ pathList.push(
+ dom.path({
+ className: "animation-iteration-path infinity-duration",
+ d: helper.toPathString(segments),
+ })
+ );
+ }
+
+ /**
+ * Render 'endDelay' part in animation and add a <path> element to given pathList.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> element to this array.
+ * @param {Object} state
+ * State of animation.
+ * @param {Number} mainIterationStartTime
+ * Starting time of main iteration.
+ * @param {Number} iterationCount
+ * Iteration count of whole animation.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderEndDelay(
+ pathList,
+ state,
+ mainIterationStartTime,
+ iterationCount,
+ helper
+ ) {
+ const startTime = mainIterationStartTime + iterationCount * state.duration;
+ const startSegment = helper.getSegment(startTime);
+ const endSegment = { x: startTime + state.endDelay, y: startSegment.y };
+ pathList.push(
+ dom.path({
+ className: "animation-enddelay-path",
+ d: helper.toPathString([startSegment, endSegment]),
+ })
+ );
+ }
+
+ /**
+ * Render 'fill' for forwards part in animation and
+ * add a <path> element to given pathList.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> element to this array.
+ * @param {Object} state
+ * State of animation.
+ * @param {Number} mainIterationStartTime
+ * Starting time of main iteration.
+ * @param {Number} iterationCount
+ * Iteration count of whole animation.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderForwardsFill(
+ pathList,
+ state,
+ mainIterationStartTime,
+ iterationCount,
+ helper
+ ) {
+ const startTime =
+ mainIterationStartTime +
+ iterationCount * state.duration +
+ (state.endDelay > 0 ? state.endDelay : 0);
+ const startSegment = helper.getSegment(startTime);
+ const endSegment = { x: helper.totalDuration, y: startSegment.y };
+ pathList.push(
+ dom.path({
+ className: "animation-fill-forwards-path",
+ d: helper.toPathString([startSegment, endSegment]),
+ })
+ );
+ }
+}
+
+module.exports = TimingPath;
diff --git a/devtools/client/inspector/animation/components/graph/moz.build b/devtools/client/inspector/animation/components/graph/moz.build
new file mode 100644
index 0000000000..866bdd30ce
--- /dev/null
+++ b/devtools/client/inspector/animation/components/graph/moz.build
@@ -0,0 +1,17 @@
+# 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(
+ "AnimationName.js",
+ "ComputedTimingPath.js",
+ "DelaySign.js",
+ "EffectTimingPath.js",
+ "EndDelaySign.js",
+ "NegativeDelayPath.js",
+ "NegativeEndDelayPath.js",
+ "NegativePath.js",
+ "SummaryGraph.js",
+ "SummaryGraphPath.js",
+ "TimingPath.js",
+)
diff --git a/devtools/client/inspector/animation/components/keyframes-graph/ColorPath.js b/devtools/client/inspector/animation/components/keyframes-graph/ColorPath.js
new file mode 100644
index 0000000000..120b61c73b
--- /dev/null
+++ b/devtools/client/inspector/animation/components/keyframes-graph/ColorPath.js
@@ -0,0 +1,209 @@
+/* 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 dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+const { colorUtils } = require("resource://devtools/shared/css/color.js");
+
+const ComputedStylePath = require("resource://devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js");
+
+const DEFAULT_COLOR = { r: 0, g: 0, b: 0, a: 1 };
+
+/* Count for linearGradient ID */
+let LINEAR_GRADIENT_ID_COUNT = 0;
+
+class ColorPath extends ComputedStylePath {
+ constructor(props) {
+ super(props);
+
+ this.state = this.propToState(props);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ this.setState(this.propToState(nextProps));
+ }
+
+ getPropertyName() {
+ return "color";
+ }
+
+ getPropertyValue(keyframe) {
+ return keyframe.value;
+ }
+
+ propToState({ keyframes, name }) {
+ const maxObject = { distance: -Number.MAX_VALUE };
+
+ for (let i = 0; i < keyframes.length - 1; i++) {
+ const value1 = getRGBA(name, keyframes[i].value);
+ for (let j = i + 1; j < keyframes.length; j++) {
+ const value2 = getRGBA(name, keyframes[j].value);
+ const distance = getRGBADistance(value1, value2);
+
+ if (maxObject.distance >= distance) {
+ continue;
+ }
+
+ maxObject.distance = distance;
+ maxObject.value1 = value1;
+ maxObject.value2 = value2;
+ }
+ }
+
+ const maxDistance = maxObject.distance;
+ const baseValue =
+ maxObject.value1 < maxObject.value2 ? maxObject.value1 : maxObject.value2;
+
+ return { baseValue, maxDistance, name };
+ }
+
+ toSegmentValue(computedStyle) {
+ const { baseValue, maxDistance, name } = this.state;
+ const value = getRGBA(name, computedStyle);
+ return getRGBADistance(baseValue, value) / maxDistance;
+ }
+
+ /**
+ * Overide parent's method.
+ */
+ renderEasingHint() {
+ const { easingHintStrokeWidth, graphHeight, keyframes, totalDuration } =
+ this.props;
+
+ const hints = [];
+
+ for (let i = 0; i < keyframes.length - 1; i++) {
+ const startKeyframe = keyframes[i];
+ const endKeyframe = keyframes[i + 1];
+ const startTime = startKeyframe.offset * totalDuration;
+ const endTime = endKeyframe.offset * totalDuration;
+
+ const g = dom.g(
+ {
+ className: "hint",
+ },
+ dom.title({}, startKeyframe.easing),
+ dom.rect({
+ x: startTime,
+ y: -graphHeight,
+ height: graphHeight,
+ width: endTime - startTime,
+ }),
+ dom.line({
+ x1: startTime,
+ y1: -graphHeight,
+ x2: endTime,
+ y2: -graphHeight,
+ style: {
+ "stroke-width": easingHintStrokeWidth,
+ },
+ })
+ );
+ hints.push(g);
+ }
+
+ return hints;
+ }
+
+ /**
+ * Overide parent's method.
+ */
+ renderPathSegments(segments) {
+ for (const segment of segments) {
+ segment.y = 1;
+ }
+
+ const lastSegment = segments[segments.length - 1];
+ const id = `color-property-${LINEAR_GRADIENT_ID_COUNT++}`;
+ const path = super.renderPathSegments(segments, { fill: `url(#${id})` });
+ const linearGradient = dom.linearGradient(
+ { id },
+ segments.map(segment => {
+ return dom.stop({
+ stopColor: segment.computedStyle,
+ offset: segment.x / lastSegment.x,
+ });
+ })
+ );
+
+ return [path, linearGradient];
+ }
+
+ render() {
+ return dom.g(
+ {
+ className: "color-path",
+ },
+ super.renderGraph()
+ );
+ }
+}
+
+/**
+ * Parse given RGBA string.
+ *
+ * @param {String} propertyName
+ * @param {String} colorString
+ * e.g. rgb(0, 0, 0) or rgba(0, 0, 0, 0.5) and so on.
+ * @return {Object}
+ * RGBA {r: r, g: g, b: b, a: a}.
+ */
+function getRGBA(propertyName, colorString) {
+ // Special handling for CSS property which can specify the not normal CSS color value.
+ switch (propertyName) {
+ case "caret-color": {
+ // This property can specify "auto" keyword.
+ if (colorString === "auto") {
+ return DEFAULT_COLOR;
+ }
+ break;
+ }
+ case "scrollbar-color": {
+ // This property can specify "auto", "dark", "light" keywords and multiple colors.
+ if (
+ ["auto", "dark", "light"].includes(colorString) ||
+ colorString.indexOf(" ") > 0
+ ) {
+ return DEFAULT_COLOR;
+ }
+ break;
+ }
+ }
+
+ const color = new colorUtils.CssColor(colorString);
+ return color.getRGBATuple();
+}
+
+/**
+ * Return the distance from give two RGBA.
+ *
+ * @param {Object} rgba1
+ * RGBA (format is same to getRGBA)
+ * @param {Object} rgba2
+ * RGBA (format is same to getRGBA)
+ * @return {Number}
+ * The range is 0 - 1.0.
+ */
+function getRGBADistance(rgba1, rgba2) {
+ const startA = rgba1.a;
+ const startR = rgba1.r * startA;
+ const startG = rgba1.g * startA;
+ const startB = rgba1.b * startA;
+ const endA = rgba2.a;
+ const endR = rgba2.r * endA;
+ const endG = rgba2.g * endA;
+ const endB = rgba2.b * endA;
+ const diffA = startA - endA;
+ const diffR = startR - endR;
+ const diffG = startG - endG;
+ const diffB = startB - endB;
+ return Math.sqrt(
+ diffA * diffA + diffR * diffR + diffG * diffG + diffB * diffB
+ );
+}
+
+module.exports = ColorPath;
diff --git a/devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js b/devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js
new file mode 100644
index 0000000000..1da5c4da96
--- /dev/null
+++ b/devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js
@@ -0,0 +1,245 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const {
+ createPathSegments,
+ DEFAULT_DURATION_RESOLUTION,
+ getPreferredProgressThresholdByKeyframes,
+ toPathString,
+} = require("resource://devtools/client/inspector/animation/utils/graph-helper.js");
+
+/*
+ * This class is an abstraction for computed style path of keyframes.
+ * Subclass of this should implement the following methods:
+ *
+ * getPropertyName()
+ * Returns property name which will be animated.
+ * @return {String}
+ * e.g. opacity
+ *
+ * getPropertyValue(keyframe)
+ * Returns value which uses as animated keyframe value from given parameter.
+ * @param {Object} keyframe
+ * @return {String||Number}
+ * e.g. 0
+ *
+ * toSegmentValue(computedStyle)
+ * Convert computed style to segment value of graph.
+ * @param {String||Number}
+ * e.g. 0
+ * @return {Number}
+ * e.g. 0 (should be 0 - 1.0)
+ */
+class ComputedStylePath extends PureComponent {
+ static get propTypes() {
+ return {
+ componentWidth: PropTypes.number.isRequired,
+ easingHintStrokeWidth: PropTypes.number.isRequired,
+ graphHeight: PropTypes.number.isRequired,
+ keyframes: PropTypes.array.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ totalDuration: PropTypes.number.isRequired,
+ };
+ }
+
+ /**
+ * Return an array containing the path segments between the given start and
+ * end keyframe values.
+ *
+ * @param {Object} startKeyframe
+ * Starting keyframe.
+ * @param {Object} endKeyframe
+ * Ending keyframe.
+ * @return {Array}
+ * Array of path segment.
+ * [{x: {Number} time, y: {Number} segment value}, ...]
+ */
+ getPathSegments(startKeyframe, endKeyframe) {
+ const { componentWidth, simulateAnimation, totalDuration } = this.props;
+
+ const propertyName = this.getPropertyName();
+ const offsetDistance = endKeyframe.offset - startKeyframe.offset;
+ const duration = offsetDistance * totalDuration;
+
+ const keyframes = [startKeyframe, endKeyframe].map((keyframe, index) => {
+ return {
+ offset: index,
+ easing: keyframe.easing,
+ [getJsPropertyName(propertyName)]: this.getPropertyValue(keyframe),
+ };
+ });
+ const effect = {
+ duration,
+ fill: "forwards",
+ };
+
+ const simulatedAnimation = simulateAnimation(keyframes, effect, true);
+
+ if (!simulatedAnimation) {
+ return null;
+ }
+
+ const simulatedElement = simulatedAnimation.effect.target;
+ const win = simulatedElement.ownerGlobal;
+ const threshold = getPreferredProgressThresholdByKeyframes(keyframes);
+
+ const getSegment = time => {
+ simulatedAnimation.currentTime = time;
+ const computedStyle = win
+ .getComputedStyle(simulatedElement)
+ .getPropertyValue(propertyName);
+
+ return {
+ computedStyle,
+ x: time,
+ y: this.toSegmentValue(computedStyle),
+ };
+ };
+
+ const segments = createPathSegments(
+ 0,
+ duration,
+ duration / componentWidth,
+ threshold,
+ DEFAULT_DURATION_RESOLUTION,
+ getSegment
+ );
+ const offset = startKeyframe.offset * totalDuration;
+
+ for (const segment of segments) {
+ segment.x += offset;
+ }
+
+ return segments;
+ }
+
+ /**
+ * Render easing hint from given path segments.
+ *
+ * @param {Array} segments
+ * Path segments.
+ * @return {Element}
+ * Element which represents easing hint.
+ */
+ renderEasingHint(segments) {
+ const { easingHintStrokeWidth, keyframes, totalDuration } = this.props;
+
+ const hints = [];
+
+ for (let i = 0, indexOfSegments = 0; i < keyframes.length - 1; i++) {
+ const startKeyframe = keyframes[i];
+ const endKeyframe = keyframes[i + 1];
+ const endTime = endKeyframe.offset * totalDuration;
+ const hintSegments = [];
+
+ for (; indexOfSegments < segments.length; indexOfSegments++) {
+ const segment = segments[indexOfSegments];
+ hintSegments.push(segment);
+
+ if (startKeyframe.offset === endKeyframe.offset) {
+ hintSegments.push(segments[++indexOfSegments]);
+ break;
+ } else if (segment.x === endTime) {
+ break;
+ }
+ }
+
+ const g = dom.g(
+ {
+ className: "hint",
+ },
+ dom.title({}, startKeyframe.easing),
+ dom.path({
+ d:
+ `M${hintSegments[0].x},${hintSegments[0].y} ` +
+ toPathString(hintSegments),
+ style: {
+ "stroke-width": easingHintStrokeWidth,
+ },
+ })
+ );
+
+ hints.push(g);
+ }
+
+ return hints;
+ }
+
+ /**
+ * Render graph. This method returns React dom.
+ *
+ * @return {Element}
+ */
+ renderGraph() {
+ const { keyframes } = this.props;
+
+ const segments = [];
+
+ for (let i = 0; i < keyframes.length - 1; i++) {
+ const startKeyframe = keyframes[i];
+ const endKeyframe = keyframes[i + 1];
+ const keyframesSegments = this.getPathSegments(
+ startKeyframe,
+ endKeyframe
+ );
+
+ if (!keyframesSegments) {
+ return null;
+ }
+
+ segments.push(...keyframesSegments);
+ }
+
+ return [this.renderPathSegments(segments), this.renderEasingHint(segments)];
+ }
+
+ /**
+ * Return react dom fron given path segments.
+ *
+ * @param {Array} segments
+ * @param {Object} style
+ * @return {Element}
+ */
+ renderPathSegments(segments, style) {
+ const { graphHeight } = this.props;
+
+ for (const segment of segments) {
+ segment.y *= graphHeight;
+ }
+
+ let d = `M${segments[0].x},0 `;
+ d += toPathString(segments);
+ d += `L${segments[segments.length - 1].x},0 Z`;
+
+ return dom.path({ d, style });
+ }
+}
+
+/**
+ * Convert given CSS property name to JavaScript CSS name.
+ *
+ * @param {String} cssPropertyName
+ * CSS property name (e.g. background-color).
+ * @return {String}
+ * JavaScript CSS property name (e.g. backgroundColor).
+ */
+function getJsPropertyName(cssPropertyName) {
+ if (cssPropertyName == "float") {
+ return "cssFloat";
+ }
+ // https://drafts.csswg.org/cssom/#css-property-to-idl-attribute
+ return cssPropertyName.replace(/-([a-z])/gi, (str, group) => {
+ return group.toUpperCase();
+ });
+}
+
+module.exports = ComputedStylePath;
diff --git a/devtools/client/inspector/animation/components/keyframes-graph/DiscretePath.js b/devtools/client/inspector/animation/components/keyframes-graph/DiscretePath.js
new file mode 100644
index 0000000000..26e5373f7c
--- /dev/null
+++ b/devtools/client/inspector/animation/components/keyframes-graph/DiscretePath.js
@@ -0,0 +1,67 @@
+/* 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 dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const ComputedStylePath = require("resource://devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js");
+
+class DiscretePath extends ComputedStylePath {
+ static get propTypes() {
+ return {
+ name: PropTypes.string.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = this.propToState(props);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ this.setState(this.propToState(nextProps));
+ }
+
+ getPropertyName() {
+ return this.props.name;
+ }
+
+ getPropertyValue(keyframe) {
+ return keyframe.value;
+ }
+
+ propToState({ getComputedStyle, keyframes, name }) {
+ const discreteValues = [];
+
+ for (const keyframe of keyframes) {
+ const style = getComputedStyle(name, { [name]: keyframe.value });
+
+ if (!discreteValues.includes(style)) {
+ discreteValues.push(style);
+ }
+ }
+
+ return { discreteValues };
+ }
+
+ toSegmentValue(computedStyle) {
+ const { discreteValues } = this.state;
+ return discreteValues.indexOf(computedStyle) / (discreteValues.length - 1);
+ }
+
+ render() {
+ return dom.g(
+ {
+ className: "discrete-path",
+ },
+ super.renderGraph()
+ );
+ }
+}
+
+module.exports = DiscretePath;
diff --git a/devtools/client/inspector/animation/components/keyframes-graph/DistancePath.js b/devtools/client/inspector/animation/components/keyframes-graph/DistancePath.js
new file mode 100644
index 0000000000..3436366c30
--- /dev/null
+++ b/devtools/client/inspector/animation/components/keyframes-graph/DistancePath.js
@@ -0,0 +1,34 @@
+/* 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 dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+const ComputedStylePath = require("resource://devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js");
+
+class DistancePath extends ComputedStylePath {
+ getPropertyName() {
+ return "opacity";
+ }
+
+ getPropertyValue(keyframe) {
+ return keyframe.distance;
+ }
+
+ toSegmentValue(computedStyle) {
+ return computedStyle;
+ }
+
+ render() {
+ return dom.g(
+ {
+ className: "distance-path",
+ },
+ super.renderGraph()
+ );
+ }
+}
+
+module.exports = DistancePath;
diff --git a/devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerItem.js b/devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerItem.js
new file mode 100644
index 0000000000..4212fdb88f
--- /dev/null
+++ b/devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerItem.js
@@ -0,0 +1,33 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+class KeyframeMarkerItem extends PureComponent {
+ static get propTypes() {
+ return {
+ keyframe: PropTypes.object.isRequired,
+ };
+ }
+
+ render() {
+ const { keyframe } = this.props;
+
+ return dom.li({
+ className: "keyframe-marker-item",
+ title: keyframe.value,
+ style: {
+ marginInlineStart: `${keyframe.offset * 100}%`,
+ },
+ });
+ }
+}
+
+module.exports = KeyframeMarkerItem;
diff --git a/devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerList.js b/devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerList.js
new file mode 100644
index 0000000000..7fd112f641
--- /dev/null
+++ b/devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerList.js
@@ -0,0 +1,37 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const KeyframeMarkerItem = createFactory(
+ require("resource://devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerItem.js")
+);
+
+class KeyframeMarkerList extends PureComponent {
+ static get propTypes() {
+ return {
+ keyframes: PropTypes.array.isRequired,
+ };
+ }
+
+ render() {
+ const { keyframes } = this.props;
+
+ return dom.ul(
+ {
+ className: "keyframe-marker-list",
+ },
+ keyframes.map(keyframe => KeyframeMarkerItem({ keyframe }))
+ );
+ }
+}
+
+module.exports = KeyframeMarkerList;
diff --git a/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraph.js b/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraph.js
new file mode 100644
index 0000000000..cbab6806d2
--- /dev/null
+++ b/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraph.js
@@ -0,0 +1,52 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const KeyframeMarkerList = createFactory(
+ require("resource://devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerList.js")
+);
+const KeyframesGraphPath = createFactory(
+ require("resource://devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraphPath.js")
+);
+
+class KeyframesGraph extends PureComponent {
+ static get propTypes() {
+ return {
+ getComputedStyle: PropTypes.func.isRequired,
+ keyframes: PropTypes.array.isRequired,
+ name: PropTypes.string.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ type: PropTypes.string.isRequired,
+ };
+ }
+
+ render() {
+ const { getComputedStyle, keyframes, name, simulateAnimation, type } =
+ this.props;
+
+ return dom.div(
+ {
+ className: `keyframes-graph ${name}`,
+ },
+ KeyframesGraphPath({
+ getComputedStyle,
+ keyframes,
+ name,
+ simulateAnimation,
+ type,
+ }),
+ KeyframeMarkerList({ keyframes })
+ );
+ }
+}
+
+module.exports = KeyframesGraph;
diff --git a/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraphPath.js b/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraphPath.js
new file mode 100644
index 0000000000..70c2720194
--- /dev/null
+++ b/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraphPath.js
@@ -0,0 +1,111 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js");
+
+const ColorPath = createFactory(
+ require("resource://devtools/client/inspector/animation/components/keyframes-graph/ColorPath.js")
+);
+const DiscretePath = createFactory(
+ require("resource://devtools/client/inspector/animation/components/keyframes-graph/DiscretePath.js")
+);
+const DistancePath = createFactory(
+ require("resource://devtools/client/inspector/animation/components/keyframes-graph/DistancePath.js")
+);
+
+const {
+ DEFAULT_EASING_HINT_STROKE_WIDTH,
+ DEFAULT_GRAPH_HEIGHT,
+ DEFAULT_KEYFRAMES_GRAPH_DURATION,
+} = require("resource://devtools/client/inspector/animation/utils/graph-helper.js");
+
+class KeyframesGraphPath extends PureComponent {
+ static get propTypes() {
+ return {
+ getComputedStyle: PropTypes.func.isRequired,
+ keyframes: PropTypes.array.isRequired,
+ name: PropTypes.string.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ type: PropTypes.string.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ componentHeight: 0,
+ componentWidth: 0,
+ };
+ }
+
+ componentDidMount() {
+ this.updateState();
+ }
+
+ getPathComponent(type) {
+ switch (type) {
+ case "color":
+ return ColorPath;
+ case "discrete":
+ return DiscretePath;
+ default:
+ return DistancePath;
+ }
+ }
+
+ updateState() {
+ const thisEl = ReactDOM.findDOMNode(this);
+ this.setState({
+ componentHeight: thisEl.parentNode.clientHeight,
+ componentWidth: thisEl.parentNode.clientWidth,
+ });
+ }
+
+ render() {
+ const { getComputedStyle, keyframes, name, simulateAnimation, type } =
+ this.props;
+ const { componentHeight, componentWidth } = this.state;
+
+ if (!componentWidth) {
+ return dom.svg();
+ }
+
+ const pathComponent = this.getPathComponent(type);
+ const strokeWidthInViewBox =
+ (DEFAULT_EASING_HINT_STROKE_WIDTH / 2 / componentHeight) *
+ DEFAULT_GRAPH_HEIGHT;
+
+ return dom.svg(
+ {
+ className: "keyframes-graph-path",
+ preserveAspectRatio: "none",
+ viewBox:
+ `0 -${DEFAULT_GRAPH_HEIGHT + strokeWidthInViewBox} ` +
+ `${DEFAULT_KEYFRAMES_GRAPH_DURATION} ` +
+ `${DEFAULT_GRAPH_HEIGHT + strokeWidthInViewBox * 2}`,
+ },
+ pathComponent({
+ componentWidth,
+ easingHintStrokeWidth: DEFAULT_EASING_HINT_STROKE_WIDTH,
+ getComputedStyle,
+ graphHeight: DEFAULT_GRAPH_HEIGHT,
+ keyframes,
+ name,
+ simulateAnimation,
+ totalDuration: DEFAULT_KEYFRAMES_GRAPH_DURATION,
+ })
+ );
+ }
+}
+
+module.exports = KeyframesGraphPath;
diff --git a/devtools/client/inspector/animation/components/keyframes-graph/moz.build b/devtools/client/inspector/animation/components/keyframes-graph/moz.build
new file mode 100644
index 0000000000..1ff518e21d
--- /dev/null
+++ b/devtools/client/inspector/animation/components/keyframes-graph/moz.build
@@ -0,0 +1,14 @@
+# 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(
+ "ColorPath.js",
+ "ComputedStylePath.js",
+ "DiscretePath.js",
+ "DistancePath.js",
+ "KeyframeMarkerItem.js",
+ "KeyframeMarkerList.js",
+ "KeyframesGraph.js",
+ "KeyframesGraphPath.js",
+)
diff --git a/devtools/client/inspector/animation/components/moz.build b/devtools/client/inspector/animation/components/moz.build
new file mode 100644
index 0000000000..cd8ecc05c4
--- /dev/null
+++ b/devtools/client/inspector/animation/components/moz.build
@@ -0,0 +1,30 @@
+# 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/.
+
+DIRS += ["graph", "keyframes-graph"]
+
+DevToolsModules(
+ "AnimatedPropertyItem.js",
+ "AnimatedPropertyList.js",
+ "AnimatedPropertyListContainer.js",
+ "AnimatedPropertyName.js",
+ "AnimationDetailContainer.js",
+ "AnimationDetailHeader.js",
+ "AnimationItem.js",
+ "AnimationList.js",
+ "AnimationListContainer.js",
+ "AnimationTarget.js",
+ "AnimationToolbar.js",
+ "App.js",
+ "CurrentTimeLabel.js",
+ "CurrentTimeScrubber.js",
+ "KeyframesProgressBar.js",
+ "NoAnimationPanel.js",
+ "PauseResumeButton.js",
+ "PlaybackRateSelector.js",
+ "ProgressInspectionPanel.js",
+ "RewindButton.js",
+ "TickLabels.js",
+ "TickLines.js",
+)
diff --git a/devtools/client/inspector/animation/current-time-timer.js b/devtools/client/inspector/animation/current-time-timer.js
new file mode 100644
index 0000000000..4c08eb09ad
--- /dev/null
+++ b/devtools/client/inspector/animation/current-time-timer.js
@@ -0,0 +1,75 @@
+/* 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";
+
+/**
+ * In animation inspector, the scrubber and the progress bar moves along the current time
+ * of animation. However, the processing which sync with actual animations is heavy since
+ * we have to communication by the actor. The role of this class is to make the pseudo
+ * current time in animation inspector to proceed.
+ */
+class CurrentTimeTimer {
+ /**
+ * Constructor.
+ *
+ * @param {Object} timeScale
+ * @param {Bool} shouldStopAfterEndTime
+ * If need to stop the timer after animation end time, set true.
+ * @param {window} win
+ * Be used for requestAnimationFrame and performance.
+ * @param {Function} onUpdated
+ * Listener function to get updating.
+ * This function is called with 2 parameters.
+ * 1st: current time
+ * 2nd: if shouldStopAfterEndTime is true and
+ * the current time is over the end time, true is given.
+ */
+ constructor(timeScale, shouldStopAfterEndTime, win, onUpdated) {
+ this.baseCurrentTime = timeScale.getCurrentTime();
+ this.endTime = timeScale.getDuration();
+ this.timerStartTime = win.performance.now();
+
+ this.shouldStopAfterEndTime = shouldStopAfterEndTime;
+ this.onUpdated = onUpdated;
+ this.win = win;
+ this.next = this.next.bind(this);
+ }
+
+ destroy() {
+ this.stop();
+ this.onUpdated = null;
+ this.win = null;
+ }
+
+ /**
+ * Proceed the pseudo current time.
+ */
+ next() {
+ if (this.doStop) {
+ return;
+ }
+
+ const currentTime =
+ this.baseCurrentTime + this.win.performance.now() - this.timerStartTime;
+
+ if (this.endTime < currentTime && this.shouldStopAfterEndTime) {
+ this.onUpdated(this.endTime, true);
+ return;
+ }
+
+ this.onUpdated(currentTime);
+ this.win.requestAnimationFrame(this.next);
+ }
+
+ start() {
+ this.next();
+ }
+
+ stop() {
+ this.doStop = true;
+ }
+}
+
+module.exports = CurrentTimeTimer;
diff --git a/devtools/client/inspector/animation/moz.build b/devtools/client/inspector/animation/moz.build
new file mode 100644
index 0000000000..7620ce2875
--- /dev/null
+++ b/devtools/client/inspector/animation/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/.
+
+DIRS += ["actions", "components", "reducers", "utils"]
+
+BROWSER_CHROME_MANIFESTS += ["test/browser.toml"]
+
+DevToolsModules("animation.js", "current-time-timer.js")
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "Inspector: Animations")
diff --git a/devtools/client/inspector/animation/reducers/animations.js b/devtools/client/inspector/animation/reducers/animations.js
new file mode 100644
index 0000000000..ead3e84147
--- /dev/null
+++ b/devtools/client/inspector/animation/reducers/animations.js
@@ -0,0 +1,117 @@
+/* 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 {
+ UPDATE_ANIMATIONS,
+ UPDATE_DETAIL_VISIBILITY,
+ UPDATE_ELEMENT_PICKER_ENABLED,
+ UPDATE_HIGHLIGHTED_NODE,
+ UPDATE_PLAYBACK_RATES,
+ UPDATE_SELECTED_ANIMATION,
+ UPDATE_SIDEBAR_SIZE,
+} = require("resource://devtools/client/inspector/animation/actions/index.js");
+
+loader.lazyRequireGetter(
+ this,
+ "TimeScale",
+ "resource://devtools/client/inspector/animation/utils/timescale.js"
+);
+
+const INITIAL_STATE = {
+ animations: [],
+ detailVisibility: false,
+ elementPickerEnabled: false,
+ highlightedNode: null,
+ playbackRates: [],
+ selectedAnimation: null,
+ sidebarSize: {
+ height: 0,
+ width: 0,
+ },
+ timeScale: null,
+};
+
+const reducers = {
+ [UPDATE_ANIMATIONS](state, { animations }) {
+ let detailVisibility = state.detailVisibility;
+ let selectedAnimation = state.selectedAnimation;
+
+ if (
+ !state.selectedAnimation ||
+ !animations.find(
+ animation => animation.actorID === selectedAnimation.actorID
+ )
+ ) {
+ selectedAnimation = animations.length === 1 ? animations[0] : null;
+ detailVisibility = !!selectedAnimation;
+ }
+
+ const playbackRates = getPlaybackRates(state.playbackRates, animations);
+
+ return Object.assign({}, state, {
+ animations,
+ detailVisibility,
+ playbackRates,
+ selectedAnimation,
+ timeScale: new TimeScale(animations),
+ });
+ },
+
+ [UPDATE_DETAIL_VISIBILITY](state, { detailVisibility }) {
+ const selectedAnimation = detailVisibility ? state.selectedAnimation : null;
+
+ return Object.assign({}, state, {
+ detailVisibility,
+ selectedAnimation,
+ });
+ },
+
+ [UPDATE_ELEMENT_PICKER_ENABLED](state, { elementPickerEnabled }) {
+ return Object.assign({}, state, {
+ elementPickerEnabled,
+ });
+ },
+
+ [UPDATE_HIGHLIGHTED_NODE](state, { highlightedNode }) {
+ return Object.assign({}, state, {
+ highlightedNode,
+ });
+ },
+
+ [UPDATE_PLAYBACK_RATES](state) {
+ return Object.assign({}, state, {
+ playbackRates: getPlaybackRates([], state.animations),
+ });
+ },
+
+ [UPDATE_SELECTED_ANIMATION](state, { selectedAnimation }) {
+ const detailVisibility = !!selectedAnimation;
+
+ return Object.assign({}, state, {
+ detailVisibility,
+ selectedAnimation,
+ });
+ },
+
+ [UPDATE_SIDEBAR_SIZE](state, { sidebarSize }) {
+ return Object.assign({}, state, {
+ sidebarSize,
+ });
+ },
+};
+
+function getPlaybackRates(basePlaybackRate, animations) {
+ return [
+ ...new Set(
+ animations.map(a => a.state.playbackRate).concat(basePlaybackRate)
+ ),
+ ];
+}
+
+module.exports = function (state = INITIAL_STATE, action) {
+ const reducer = reducers[action.type];
+ return reducer ? reducer(state, action) : state;
+};
diff --git a/devtools/client/inspector/animation/reducers/moz.build b/devtools/client/inspector/animation/reducers/moz.build
new file mode 100644
index 0000000000..8b20a9f6cd
--- /dev/null
+++ b/devtools/client/inspector/animation/reducers/moz.build
@@ -0,0 +1,7 @@
+# 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(
+ "animations.js",
+)
diff --git a/devtools/client/inspector/animation/test/browser.toml b/devtools/client/inspector/animation/test/browser.toml
new file mode 100644
index 0000000000..a13e259df3
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser.toml
@@ -0,0 +1,221 @@
+[DEFAULT]
+prefs = [
+ "dom.svg.pathSeg.enabled=true",
+ "layout.css.properties-and-values.enabled=true"
+]
+tags = "devtools"
+subsuite = "devtools"
+support-files = [
+ "current-time-scrubber_head.js",
+ "doc_custom_playback_rate.html",
+ "doc_infinity_duration.html",
+ "doc_multi_easings.html",
+ "doc_multi_keyframes.html",
+ "doc_multi_timings.html",
+ "doc_mutations_add_remove_immediately.html",
+ "doc_mutations_fast.html",
+ "doc_negative_playback_rate.html",
+ "doc_overflowed_delay_end_delay.html",
+ "doc_pseudo.html",
+ "doc_short_duration.html",
+ "doc_simple_animation.html",
+ "doc_special_colors.html",
+ "head.js",
+ "keyframes-graph_keyframe-marker_head.js",
+ "summary-graph_computed-timing-path_head.js",
+ "summary-graph_delay-sign_head.js",
+ "summary-graph_end-delay-sign_head.js",
+ "!/devtools/client/inspector/test/head.js",
+ "!/devtools/client/inspector/test/shared-head.js",
+ "!/devtools/client/shared/test/shared-head.js",
+ "!/devtools/client/shared/test/telemetry-test-helpers.js",
+ "!/devtools/client/shared/test/highlighter-test-actor.js",
+]
+
+["browser_animation_animated-property-list.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_animated-property-list_unchanged-items.js"]
+
+["browser_animation_animated-property-name.js"]
+
+["browser_animation_animation-detail_close-button.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_animation-detail_title.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_animation-detail_visibility.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_animation-list.js"]
+
+["browser_animation_animation-list_one-animation-select.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_animation-list_select.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_animation-target.js"]
+
+["browser_animation_animation-target_highlight.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+skip-if = [
+ "apple_catalina && !debug", # Disabled in Bug 1713158. Intemittent bug: Bug 1665011
+ "os == 'linux' && !debug && !asan && !swgl && !ccov", # Bug 1665011
+ "win11_2009", # Bug 1798331
+ "a11y_checks && debug", # Bugs 1849028 and 1858041 for causing intermittent test results
+]
+
+["browser_animation_animation-target_select.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_animation-timeline-tick.js"]
+
+["browser_animation_css-transition-with-playstate-idle.js"]
+
+["browser_animation_current-time-label.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_current-time-scrubber-rtl.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+skip-if = ["os == 'linux' && debug"] # Bug 1721716
+
+["browser_animation_current-time-scrubber-with-negative-delay.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_current-time-scrubber.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_current-time-scrubber_each-different-creation-time-animations.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_empty_on_invalid_nodes.js"]
+
+["browser_animation_fission_switch-target.js"]
+
+["browser_animation_indication-bar.js"]
+skip-if = ["a11y_checks"] # Bugs 1858041 and 1849028 for causing intermittent a11y_checks results
+
+["browser_animation_infinity-duration_current-time-scrubber.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_infinity-duration_summary-graph.js"]
+
+["browser_animation_infinity-duration_tick-label.js"]
+
+["browser_animation_keyframes-graph_computed-value-path-01.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_keyframes-graph_computed-value-path-02.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_keyframes-graph_computed-value-path-03.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_keyframes-graph_computed-value-path_easing-hint.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+skip-if = ["verify && !debug"]
+
+["browser_animation_keyframes-graph_keyframe-marker-rtl.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_keyframes-graph_keyframe-marker.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_keyframes-graph_special-colors.js"]
+
+["browser_animation_keyframes-progress-bar.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+skip-if = ["os == 'win' && ccov"] # Bug 1490981
+
+["browser_animation_keyframes-progress-bar_after-resuming.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_logic_adjust-time-with-playback-rate.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_logic_adjust-time.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_logic_auto-stop.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_logic_avoid-updating-during-hiding.js"]
+
+["browser_animation_logic_created-time.js"]
+
+["browser_animation_logic_mutations.js"]
+
+["browser_animation_logic_mutations_add_remove_immediately.js"]
+
+["browser_animation_logic_mutations_fast.js"]
+skip-if = [
+ "debug",
+ "win11_2009' && bits == 32", # Bug 1567800
+ "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1567800
+]
+
+["browser_animation_logic_mutations_properties.js"]
+
+["browser_animation_logic_overflowed_delay_end-delay.js"]
+skip-if = ["debug"] #bug 1480027
+
+["browser_animation_logic_scroll-amount.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_pause-resume-button.js"]
+
+["browser_animation_pause-resume-button_end-time.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+skip-if = ["os == 'linux' && bits == 64 && debug"] # Bug 1767699
+
+["browser_animation_pause-resume-button_respectively.js"]
+
+["browser_animation_pause-resume-button_spacebar.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_playback-rate-selector.js"]
+
+["browser_animation_pseudo-element.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_rewind-button.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_short-duration.js"]
+
+["browser_animation_summary-graph_animation-name.js"]
+
+["browser_animation_summary-graph_compositor.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_summary-graph_computed-timing-path_1.js"]
+
+["browser_animation_summary-graph_computed-timing-path_2.js"]
+
+["browser_animation_summary-graph_computed-timing-path_different-timescale.js"]
+
+["browser_animation_summary-graph_delay-sign-rtl.js"]
+
+["browser_animation_summary-graph_delay-sign.js"]
+
+["browser_animation_summary-graph_effect-timing-path.js"]
+
+["browser_animation_summary-graph_end-delay-sign-rtl.js"]
+
+["browser_animation_summary-graph_end-delay-sign.js"]
+
+["browser_animation_summary-graph_layout-by-seek.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_summary-graph_negative-delay-path.js"]
+
+["browser_animation_summary-graph_negative-end-delay-path.js"]
+
+["browser_animation_summary-graph_tooltip.js"]
+
+["browser_animation_timing_negative-playback-rate_current-time-scrubber.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_animation_timing_negative-playback-rate_summary-graph.js"]
diff --git a/devtools/client/inspector/animation/test/browser_animation_animated-property-list.js b/devtools/client/inspector/animation/test/browser_animation_animated-property-list.js
new file mode 100644
index 0000000000..47661d00fc
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_animated-property-list.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test following animated property list test.
+// 1. Existence for animated property list.
+// 2. Number of animated property item.
+
+const TEST_DATA = [
+ {
+ targetClass: "animated",
+ expectedNumber: 1,
+ },
+ {
+ targetClass: "compositor-notall",
+ expectedNumber: 4,
+ },
+];
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`));
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Checking animated property list and items existence at initial");
+ ok(
+ !panel.querySelector(".animated-property-list"),
+ "The animated-property-list should not be in the DOM at initial"
+ );
+
+ for (const { targetClass, expectedNumber } of TEST_DATA) {
+ info(
+ `Checking animated-property-list and items existence at ${targetClass}`
+ );
+ await clickOnAnimationByTargetSelector(
+ animationInspector,
+ panel,
+ `.${targetClass}`
+ );
+
+ await waitUntil(
+ () =>
+ panel.querySelectorAll(".animated-property-item").length ===
+ expectedNumber
+ );
+ ok(
+ true,
+ `The number of animated-property-list should be ${expectedNumber} at ${targetClass}`
+ );
+ }
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_animated-property-list_unchanged-items.js b/devtools/client/inspector/animation/test/browser_animation_animated-property-list_unchanged-items.js
new file mode 100644
index 0000000000..22b889297f
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_animated-property-list_unchanged-items.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the position and the class of unchanged animated property items.
+
+const TEST_DATA = [
+ { property: "background-color", isUnchanged: false },
+ { property: "padding-left", isUnchanged: false },
+ { property: "background-attachment", isUnchanged: true },
+ { property: "background-clip", isUnchanged: true },
+ { property: "background-image", isUnchanged: true },
+ { property: "background-origin", isUnchanged: true },
+ { property: "background-position-x", isUnchanged: true },
+ { property: "background-position-y", isUnchanged: true },
+ { property: "background-repeat", isUnchanged: true },
+ { property: "background-size", isUnchanged: true },
+ { property: "padding-bottom", isUnchanged: true },
+ { property: "padding-right", isUnchanged: true },
+ { property: "padding-top", isUnchanged: true },
+];
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".longhand"]);
+ const { panel } = await openAnimationInspector();
+
+ info("Checking unchanged animated property item");
+ const itemEls = panel.querySelectorAll(".animated-property-item");
+ is(
+ itemEls.length,
+ TEST_DATA.length,
+ `Count of animated property item should be ${TEST_DATA.length}`
+ );
+
+ for (let i = 0; i < TEST_DATA.length; i++) {
+ const { property, isUnchanged } = TEST_DATA[i];
+ const itemEl = itemEls[i];
+
+ ok(
+ itemEl.querySelector(`.keyframes-graph.${property}`),
+ `Item of ${property} should display at here`
+ );
+
+ if (isUnchanged) {
+ ok(
+ itemEl.classList.contains("unchanged"),
+ "Animated property item should have 'unchanged' class"
+ );
+ } else {
+ ok(
+ !itemEl.classList.contains("unchanged"),
+ "Animated property item should not have 'unchanged' class"
+ );
+ }
+ }
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_animated-property-name.js b/devtools/client/inspector/animation/test/browser_animation_animated-property-name.js
new file mode 100644
index 0000000000..a1505125f6
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_animated-property-name.js
@@ -0,0 +1,127 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the following animated property name component features:
+// * name of property
+// * display compositor sign when the property was running on compositor.
+// * display warning when the property is runnable on compositor but was not.
+
+async function test_element(className, data) {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([className]);
+ const { panel } = await openAnimationInspector();
+
+ info("Checking animated property name component");
+ const animatedPropertyNameEls = panel.querySelectorAll(
+ ".animated-property-name"
+ );
+ is(
+ animatedPropertyNameEls.length,
+ data.length,
+ `Number of animated property name elements should be ${data.length}`
+ );
+
+ for (const [
+ index,
+ animatedPropertyNameEl,
+ ] of animatedPropertyNameEls.entries()) {
+ const { property, isOnCompositor, isWarning } = data[index];
+
+ info(`Checking text content for ${property}`);
+
+ const spanEl = animatedPropertyNameEl.querySelector("span");
+ ok(
+ spanEl,
+ `<span> element should be in animated-property-name of ${property}`
+ );
+ is(spanEl.textContent, property, `textContent should be ${property}`);
+
+ info(`Checking compositor sign for ${property}`);
+
+ if (isOnCompositor) {
+ ok(
+ animatedPropertyNameEl.classList.contains("compositor"),
+ "animatedPropertyNameEl should has .compositor class"
+ );
+ isnot(
+ getComputedStyle(spanEl, "::before").width,
+ "auto",
+ "width of ::before pseud should not be auto"
+ );
+ } else {
+ ok(
+ !animatedPropertyNameEl.classList.contains("compositor"),
+ "animatedPropertyNameEl should not have .compositor class"
+ );
+ is(
+ getComputedStyle(spanEl, "::before").width,
+ "auto",
+ "width of ::before pseud should be auto"
+ );
+ }
+
+ info(`Checking warning for ${property}`);
+
+ if (isWarning) {
+ ok(
+ animatedPropertyNameEl.classList.contains("warning"),
+ "animatedPropertyNameEl should has .warning class"
+ );
+ is(
+ getComputedStyle(spanEl).textDecorationStyle,
+ "dotted",
+ "text-decoration-style of spanEl should be 'dotted'"
+ );
+ is(
+ getComputedStyle(spanEl).textDecorationLine,
+ "underline",
+ "text-decoration-line of spanEl should be 'underline'"
+ );
+ } else {
+ ok(
+ !animatedPropertyNameEl.classList.contains("warning"),
+ "animatedPropertyNameEl should not have .warning class"
+ );
+ is(
+ getComputedStyle(spanEl).textDecorationStyle,
+ "solid",
+ "text-decoration-style of spanEl should be 'solid'"
+ );
+ is(
+ getComputedStyle(spanEl).textDecorationLine,
+ "none",
+ "text-decoration-line of spanEl should be 'none'"
+ );
+ }
+ }
+}
+
+add_task(async function compositor_notall() {
+ await test_element(".compositor-notall", [
+ {
+ property: "--ball-color",
+ },
+ {
+ property: "opacity",
+ isOnCompositor: true,
+ },
+ {
+ property: "transform",
+ isOnCompositor: true,
+ },
+ {
+ property: "width",
+ },
+ ]);
+});
+
+add_task(async function compositor_warning() {
+ await test_element(".compositor-warning", [
+ {
+ property: "opacity",
+ isWarning: true,
+ },
+ ]);
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-detail_close-button.js b/devtools/client/inspector/animation/test/browser_animation_animation-detail_close-button.js
new file mode 100644
index 0000000000..f7fa4cee70
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_animation-detail_close-button.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that whether close button in header of animation detail works.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_custom_playback_rate.html");
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Checking close button in header of animation detail");
+ await clickOnAnimation(animationInspector, panel, 0);
+ const detailEl = panel.querySelector("#animation-container .controlled");
+ const win = panel.ownerGlobal;
+ isnot(
+ win.getComputedStyle(detailEl).display,
+ "none",
+ "detailEl should be visibled before clicking close button"
+ );
+ clickOnDetailCloseButton(panel);
+ is(
+ win.getComputedStyle(detailEl).display,
+ "none",
+ "detailEl should be unvisibled after clicking close button"
+ );
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-detail_title.js b/devtools/client/inspector/animation/test/browser_animation_animation-detail_title.js
new file mode 100644
index 0000000000..91dfd2e50f
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_animation-detail_title.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that whether title in header of animations detail.
+
+const TEST_DATA = [
+ {
+ targetClass: "cssanimation-normal",
+ expectedTitle: "cssanimation — CSS Animation",
+ },
+ {
+ targetClass: "delay-positive",
+ expectedTitle: "test-delay-animation — Script Animation",
+ },
+ {
+ targetClass: "easing-step",
+ expectedTitle: "Script Animation",
+ },
+];
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_multi_timings.html");
+ await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`));
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Checking title in each header of animation detail");
+
+ for (const { targetClass, expectedTitle } of TEST_DATA) {
+ info(`Checking title at ${targetClass}`);
+ await clickOnAnimationByTargetSelector(
+ animationInspector,
+ panel,
+ `.${targetClass}`
+ );
+ const titleEl = panel.querySelector(".animation-detail-title");
+ is(
+ titleEl.textContent,
+ expectedTitle,
+ `Title of "${targetClass}" should be "${expectedTitle}"`
+ );
+ }
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-detail_visibility.js b/devtools/client/inspector/animation/test/browser_animation_animation-detail_visibility.js
new file mode 100644
index 0000000000..14f406a1a3
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_animation-detail_visibility.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that whether animations detail could be displayed if there is selected animation.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_custom_playback_rate.html");
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+
+ info("Checking animation detail visibility if animation was unselected");
+ const detailEl = panel.querySelector("#animation-container .controlled");
+ ok(detailEl, "The detail pane should be in the DOM");
+ await assertDisplayStyle(detailEl, true, "detailEl should be unvisibled");
+
+ info(
+ "Checking animation detail visibility if animation was selected by click"
+ );
+ await clickOnAnimation(animationInspector, panel, 0);
+ await assertDisplayStyle(detailEl, false, "detailEl should be visibled");
+
+ info(
+ "Checking animation detail visibility when choose node which has animations"
+ );
+ await selectNode("html", inspector);
+ await assertDisplayStyle(
+ detailEl,
+ true,
+ "detailEl should be unvisibled after choose html node"
+ );
+
+ info(
+ "Checking animation detail visibility when choose node which has an animation"
+ );
+ await selectNode("div", inspector);
+ await assertDisplayStyle(
+ detailEl,
+ false,
+ "detailEl should be visibled after choose .cssanimation-normal node"
+ );
+});
+
+async function assertDisplayStyle(detailEl, isNoneExpected, description) {
+ const win = detailEl.ownerGlobal;
+ await waitUntil(() => {
+ const isNone = win.getComputedStyle(detailEl).display === "none";
+ return isNone === isNoneExpected;
+ });
+ ok(true, description);
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-list.js b/devtools/client/inspector/animation/test/browser_animation_animation-list.js
new file mode 100644
index 0000000000..4f2c4419b3
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_animation-list.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that whether animations ui could be displayed
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".animated", ".long"]);
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+
+ info("Checking animation list and items existence");
+ ok(
+ panel.querySelector(".animation-list"),
+ "The animation-list is in the DOM"
+ );
+ is(
+ panel.querySelectorAll(".animation-list .animation-item").length,
+ animationInspector.state.animations.length,
+ "The number of animations displayed matches the number of animations"
+ );
+
+ info(
+ "Checking list and items existence after select a element which has an animation"
+ );
+ await selectNode(".animated", inspector);
+ await waitUntil(
+ () => panel.querySelectorAll(".animation-list .animation-item").length === 1
+ );
+ ok(
+ true,
+ "The number of animations displayed should be 1 for .animated element"
+ );
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-list_one-animation-select.js b/devtools/client/inspector/animation/test/browser_animation_animation-list_one-animation-select.js
new file mode 100644
index 0000000000..d84750385c
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_animation-list_one-animation-select.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether the animation item has been selected from first time
+// if count of the animations is one.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".animated"]);
+ const { panel } = await openAnimationInspector();
+
+ info("Checking whether an item element has been selected");
+ is(
+ panel.querySelector(".animation-item").classList.contains("selected"),
+ true,
+ "The animation item should have 'selected' class"
+ );
+
+ info(
+ "Checking whether the element will be unselected after closing the detail pane"
+ );
+ clickOnDetailCloseButton(panel);
+ is(
+ panel.querySelector(".animation-item").classList.contains("selected"),
+ false,
+ "The animation item should not have 'selected' class"
+ );
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-list_select.js b/devtools/client/inspector/animation/test/browser_animation_animation-list_select.js
new file mode 100644
index 0000000000..0d8901ef46
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_animation-list_select.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether the animation items in the list were selectable.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".animated", ".long"]);
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Checking whether 1st element will be selected");
+ await clickOnAnimation(animationInspector, panel, 0);
+ assertSelection(panel, [true, false]);
+
+ info("Checking whether 2nd element will be selected");
+ await clickOnAnimation(animationInspector, panel, 1);
+ assertSelection(panel, [false, true]);
+
+ info(
+ "Checking whether all elements will be unselected after closing the detail pane"
+ );
+ clickOnDetailCloseButton(panel);
+ assertSelection(panel, [false, false]);
+});
+
+function assertSelection(panel, expectedResult) {
+ panel.querySelectorAll(".animation-item").forEach((item, index) => {
+ const shouldSelected = expectedResult[index];
+ is(
+ item.classList.contains("selected"),
+ shouldSelected,
+ `Animation item[${index}] should ` +
+ `${shouldSelected ? "" : "not"} have 'selected' class`
+ );
+ });
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-target.js b/devtools/client/inspector/animation/test/browser_animation_animation-target.js
new file mode 100644
index 0000000000..e38ae18755
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_animation-target.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following AnimationTarget component works.
+// * element existance
+// * number of elements
+// * content of element
+// * title of inspect icon
+
+const TEST_DATA = [
+ { expectedTextContent: "div.ball.animated" },
+ { expectedTextContent: "div.ball.long" },
+];
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".animated", ".long"]);
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Checking the animation target elements existance");
+ const animationItemEls = panel.querySelectorAll(
+ ".animation-list .animation-item"
+ );
+ is(
+ animationItemEls.length,
+ animationInspector.state.animations.length,
+ "Number of animation target element should be same to number of animations " +
+ "that displays"
+ );
+
+ for (let i = 0; i < animationItemEls.length; i++) {
+ const animationItemEl = animationItemEls[i];
+ animationItemEl.scrollIntoView(false);
+ await waitUntil(() => animationItemEl.querySelector(".animation-target"));
+
+ const animationTargetEl =
+ animationItemEl.querySelector(".animation-target");
+ ok(
+ animationTargetEl,
+ "The animation target element should be in each animation item element"
+ );
+
+ info("Checking the content of animation target");
+ const testData = TEST_DATA[i];
+ is(
+ animationTargetEl.textContent,
+ testData.expectedTextContent,
+ "The target element's content is correct"
+ );
+ ok(
+ animationTargetEl.querySelector(".objectBox"),
+ "objectBox is in the page exists"
+ );
+ ok(
+ animationTargetEl.querySelector(".highlight-node").title,
+ INSPECTOR_L10N.getStr("inspector.nodePreview.highlightNodeLabel")
+ );
+ }
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-target_highlight.js b/devtools/client/inspector/animation/test/browser_animation_animation-target_highlight.js
new file mode 100644
index 0000000000..0ff5b08018
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_animation-target_highlight.js
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following highlighting related.
+// * highlight when mouse over on a target node
+// * unhighlight when mouse out from the above element
+// * lock highlighting when click on the inspect icon in animation target component
+// * add 'highlighting' class to animation target component during locking
+// * mouseover locked target node
+// * unlock highlighting when click on the above icon
+// * lock highlighting when click on the other inspect icon
+// * if the locked node has multi animations,
+// the class will add to those animation target as well
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".animated", ".multi"]);
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+ const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } =
+ getHighlighterTestHelpers(inspector);
+
+ info("Check highlighting when mouse over on a target node");
+ const onHighlight = waitForHighlighterTypeShown(
+ inspector.highlighters.TYPES.BOXMODEL
+ );
+ mouseOverOnTargetNode(animationInspector, panel, 0);
+ let data = await onHighlight;
+ assertNodeFront(data.nodeFront, "DIV", "ball animated");
+
+ info("Check unhighlighting when mouse out on a target node");
+ const onUnhighlight = waitForHighlighterTypeHidden(
+ inspector.highlighters.TYPES.BOXMODEL
+ );
+ mouseOutOnTargetNode(animationInspector, panel, 0);
+ await onUnhighlight;
+ ok(true, "Unhighlighted the targe node");
+
+ info("Check node is highlighted when the inspect icon is clicked");
+ const onHighlighterShown = waitForHighlighterTypeShown(
+ inspector.highlighters.TYPES.BOXMODEL
+ );
+ await clickOnInspectIcon(animationInspector, panel, 0);
+ data = await onHighlighterShown;
+ assertNodeFront(data.nodeFront, "DIV", "ball animated");
+ await assertHighlight(panel, 0, true);
+
+ info("Check if the animation target is still highlighted on mouse out");
+ mouseOutOnTargetNode(animationInspector, panel, 0);
+ await wait(500);
+ await assertHighlight(panel, 0, true);
+
+ info("Check no highlight event occur by mouse over locked target");
+ let highlightEventCount = 0;
+ function onHighlighterHidden({ type }) {
+ if (type === inspector.highlighters.TYPES.BOXMODEL) {
+ highlightEventCount += 1;
+ }
+ }
+ inspector.highlighters.on("highlighter-hidden", onHighlighterHidden);
+ mouseOverOnTargetNode(animationInspector, panel, 0);
+ await wait(500);
+ is(highlightEventCount, 0, "Highlight event should not occur");
+ inspector.highlighters.off("highlighter-hidden", onHighlighterHidden);
+
+ info("Show persistent highlighter on an animation target");
+ const onPersistentHighlighterShown = waitForHighlighterTypeShown(
+ inspector.highlighters.TYPES.SELECTOR
+ );
+ await clickOnInspectIcon(animationInspector, panel, 1);
+ data = await onPersistentHighlighterShown;
+ assertNodeFront(data.nodeFront, "DIV", "ball multi");
+
+ info("Check the highlighted state of the animation targets");
+ await assertHighlight(panel, 0, false);
+ await assertHighlight(panel, 1, true);
+ await assertHighlight(panel, 2, true);
+
+ info("Hide persistent highlighter");
+ const onPersistentHighlighterHidden = waitForHighlighterTypeHidden(
+ inspector.highlighters.TYPES.SELECTOR
+ );
+ await clickOnInspectIcon(animationInspector, panel, 1);
+ await onPersistentHighlighterHidden;
+
+ info("Check the highlighted state of the animation targets");
+ await assertHighlight(panel, 0, false);
+ await assertHighlight(panel, 1, false);
+ await assertHighlight(panel, 2, false);
+});
+
+async function assertHighlight(panel, index, isHighlightExpected) {
+ const animationItemEl = await findAnimationItemByIndex(panel, index);
+ const animationTargetEl = animationItemEl.querySelector(".animation-target");
+
+ await waitUntil(
+ () =>
+ animationTargetEl.classList.contains("highlighting") ===
+ isHighlightExpected
+ );
+ ok(true, `Highlighting class of animation target[${index}] is correct`);
+}
+
+function assertNodeFront(nodeFront, tagName, classValue) {
+ 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,
+ classValue,
+ "The highlighted node has the correct class"
+ );
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-target_select.js b/devtools/client/inspector/animation/test/browser_animation_animation-target_select.js
new file mode 100644
index 0000000000..970778c4c6
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_animation-target_select.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following selection feature related AnimationTarget component works:
+// * select selected node by clicking on target node
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".multi", ".long"]);
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Check initial status");
+ is(
+ panel.querySelectorAll(".animation-item").length,
+ 3,
+ "The length of animations should be 3. Two .multi animations and one .long animation"
+ );
+
+ info("Check selecting an animated node by clicking on the target node");
+ await clickOnTargetNode(animationInspector, panel, 0);
+ assertNodeFront(
+ animationInspector.inspector.selection.nodeFront,
+ "DIV",
+ "ball multi"
+ );
+ is(
+ panel.querySelectorAll(".animation-item").length,
+ 2,
+ "The length of animations should be 2"
+ );
+
+ info("Check if the both target nodes refer to the same node");
+ await clickOnTargetNode(animationInspector, panel, 1);
+ assertNodeFront(
+ animationInspector.inspector.selection.nodeFront,
+ "DIV",
+ "ball multi"
+ );
+ is(
+ panel.querySelectorAll(".animation-item").length,
+ 2,
+ "The length of animations should be 2"
+ );
+});
+
+function assertNodeFront(nodeFront, tagName, classValue) {
+ is(
+ nodeFront.tagName,
+ tagName,
+ "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,
+ classValue,
+ "The highlighted node has the correct class"
+ );
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-timeline-tick.js b/devtools/client/inspector/animation/test/browser_animation_animation-timeline-tick.js
new file mode 100644
index 0000000000..03e9535558
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_animation-timeline-tick.js
@@ -0,0 +1,109 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following timeline tick items.
+// * animation list header elements existence
+// * tick labels elements existence
+// * count and text of tick label elements changing by the sidebar width
+
+const TimeScale = require("resource://devtools/client/inspector/animation/utils/timescale.js");
+const {
+ findOptimalTimeInterval,
+} = require("resource://devtools/client/inspector/animation/utils/utils.js");
+
+// Should be kept in sync with TIME_GRADUATION_MIN_SPACING in
+// AnimationTimeTickList component.
+const TIME_GRADUATION_MIN_SPACING = 40;
+
+add_task(async function () {
+ await pushPref("devtools.inspector.three-pane-enabled", false);
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".end-delay", ".negative-delay"]);
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+ const timeScale = new TimeScale(animationInspector.state.animations);
+
+ info("Checking animation list header element existence");
+ const listContainerEl = panel.querySelector(".animation-list-container");
+ const listHeaderEl = listContainerEl.querySelector(".devtools-toolbar");
+ ok(
+ listHeaderEl,
+ "The header element should be in animation list container element"
+ );
+
+ info("Checking time tick item elements existence");
+ await assertTickLabels(timeScale, listContainerEl);
+ const timelineTickItemLength =
+ listContainerEl.querySelectorAll(".tick-label").length;
+
+ info("Checking timeline tick item elements after enlarge sidebar width");
+ await setSidebarWidth("100%", inspector);
+ await assertTickLabels(timeScale, listContainerEl);
+ Assert.less(
+ timelineTickItemLength,
+ listContainerEl.querySelectorAll(".tick-label").length,
+ "The timeline tick item elements should increase"
+ );
+});
+
+/**
+ * Assert tick label's position and label.
+ *
+ * @param {TimeScale} - timeScale
+ * @param {Element} - listContainerEl
+ */
+async function assertTickLabels(timeScale, listContainerEl) {
+ const timelineTickListEl = listContainerEl.querySelector(".tick-labels");
+ ok(
+ timelineTickListEl,
+ "The animation timeline tick list element should be in header"
+ );
+
+ const width = timelineTickListEl.offsetWidth;
+ const animationDuration = timeScale.getDuration();
+ const minTimeInterval =
+ (TIME_GRADUATION_MIN_SPACING * animationDuration) / width;
+ const interval = findOptimalTimeInterval(minTimeInterval);
+ const shiftWidth = timeScale.zeroPositionTime % interval;
+ const expectedTickItem =
+ Math.ceil(animationDuration / interval) + (shiftWidth !== 0 ? 1 : 0);
+
+ await waitUntil(
+ () =>
+ timelineTickListEl.querySelectorAll(".tick-label").length ===
+ expectedTickItem
+ );
+ ok(true, "The expected number of timeline ticks were found");
+
+ const timelineTickItemEls =
+ timelineTickListEl.querySelectorAll(".tick-label");
+
+ info("Make sure graduations are evenly distributed and show the right times");
+ for (const [index, tickEl] of timelineTickItemEls.entries()) {
+ const left = parseFloat(tickEl.style.marginInlineStart);
+ let expectedPos =
+ (((index - 1) * interval + shiftWidth) / animationDuration) * 100;
+ if (shiftWidth !== 0 && index === 0) {
+ expectedPos = 0;
+ }
+ is(
+ Math.round(left),
+ Math.round(expectedPos),
+ `Graduation ${index} 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.
+ const formattedTime = timeScale.formatTime(
+ timeScale.distanceToRelativeTime(expectedPos, width)
+ );
+ is(
+ tickEl.textContent,
+ formattedTime,
+ `Graduation ${index} has the right text content`
+ );
+ }
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_css-transition-with-playstate-idle.js b/devtools/client/inspector/animation/test/browser_animation_css-transition-with-playstate-idle.js
new file mode 100644
index 0000000000..1970884623
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_css-transition-with-playstate-idle.js
@@ -0,0 +1,81 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that animation inspector does not fail when rendering an animation that
+// transitions from the playState "idle".
+
+const PAGE_URL = `data:text/html;charset=utf-8,
+<!DOCTYPE html>
+<html>
+<head>
+ <style type="text/css">
+ div {
+ opacity: 0;
+ transition-duration: 5000ms;
+ transition-property: opacity;
+ }
+
+ div.visible {
+ opacity: 1;
+ }
+ </style>
+</head>
+<body>
+ <div>test</div>
+</body>
+</html>`;
+
+add_task(async function () {
+ const tab = await addTab(PAGE_URL);
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Toggle the visible class to start the animation");
+ await toggleVisibleClass(tab);
+
+ info("Wait until the scrubber is displayed");
+ await waitUntil(() => panel.querySelector(".current-time-scrubber"));
+ const scrubberEl = panel.querySelector(".current-time-scrubber");
+
+ info("Wait until animations are paused");
+ await waitUntilAnimationsPaused(animationInspector);
+
+ // Check the initial position of the scrubber to detect the animation.
+ const scrubberX = scrubberEl.getBoundingClientRect().x;
+
+ info("Toggle the visible class to start the animation");
+ await toggleVisibleClass(tab);
+
+ info("Wait until the scrubber starts moving");
+ await waitUntil(() => scrubberEl.getBoundingClientRect().x != scrubberX);
+
+ info("Wait until animations are paused");
+ await waitUntilAnimationsPaused(animationInspector);
+
+ // Query the scrubber element again to check that the UI is still rendered.
+ ok(
+ !!panel.querySelector(".current-time-scrubber"),
+ "The scrubber element is still rendered in the animation inspector panel"
+ );
+});
+
+/**
+ * Local helper to toggle the "visible" class on the element with a transition defined.
+ */
+async function toggleVisibleClass(tab) {
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ const win = content.wrappedJSObject;
+ win.document.querySelector("div").classList.toggle("visible");
+ });
+}
+
+async function waitUntilAnimationsPaused(animationInspector) {
+ await waitUntil(() => {
+ const animations = animationInspector.state.animations;
+ return animations.every(animation => {
+ const state = animation.state.playState;
+ return state === "paused" || state === "finished";
+ });
+ });
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_current-time-label.js b/devtools/client/inspector/animation/test/browser_animation_current-time-label.js
new file mode 100644
index 0000000000..0cff5b1f53
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_current-time-label.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following CurrentTimeLabel component:
+// * element existence
+// * label content at plural timing
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_multi_timings.html");
+ await removeAnimatedElementsExcept([".keyframes-easing-step"]);
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Checking current time label existence");
+ const labelEl = panel.querySelector(".current-time-label");
+ ok(labelEl, "current time label should exist");
+
+ info("Checking current time label content");
+ const duration = animationInspector.state.timeScale.getDuration();
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ await waitUntilCurrentTimeChangedAt(animationInspector, duration * 0.5);
+ const targetAnimation = animationInspector.state.animations[0];
+ assertLabelContent(labelEl, targetAnimation.state.currentTime);
+
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0.2);
+ await waitUntilCurrentTimeChangedAt(animationInspector, duration * 0.2);
+ assertLabelContent(labelEl, targetAnimation.state.currentTime);
+
+ info("Checking current time label content during running");
+ // Resume
+ clickOnPauseResumeButton(animationInspector, panel);
+ const previousContent = labelEl.textContent;
+
+ info("Wait until the time label changes");
+ await waitFor(() => labelEl.textContent != previousContent);
+ isnot(
+ previousContent,
+ labelEl.textContent,
+ "Current time label should change"
+ );
+});
+
+function assertLabelContent(labelEl, time) {
+ const expected = formatStopwatchTime(time);
+ is(labelEl.textContent, expected, `Content of label should be ${expected}`);
+}
+
+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);
+
+ const 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}`;
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber-rtl.js b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber-rtl.js
new file mode 100644
index 0000000000..1da1c56e10
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber-rtl.js
@@ -0,0 +1,18 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* import-globals-from current-time-scrubber_head.js */
+
+// Test for CurrentTimeScrubber on RTL environment.
+
+add_task(async function () {
+ Services.scriptloader.loadSubScript(
+ CHROME_URL_ROOT + "current-time-scrubber_head.js",
+ this
+ );
+ await pushPref("intl.l10n.pseudo", "bidi");
+ // eslint-disable-next-line no-undef
+ await testCurrentTimeScrubber(true);
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber-with-negative-delay.js b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber-with-negative-delay.js
new file mode 100644
index 0000000000..8b2e177079
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber-with-negative-delay.js
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether the most left position means negative current time.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_multi_timings.html");
+ await removeAnimatedElementsExcept([
+ ".cssanimation-normal",
+ ".delay-negative",
+ ]);
+ const { animationInspector, panel, inspector } =
+ await openAnimationInspector();
+
+ info("Checking scrubber controller existence");
+ const controllerEl = panel.querySelector(".current-time-scrubber-area");
+ ok(controllerEl, "scrubber controller should exist");
+
+ info("Checking the current time of most left scrubber position");
+ const timeScale = animationInspector.state.timeScale;
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ await waitUntilCurrentTimeChangedAt(
+ animationInspector,
+ -1 * timeScale.zeroPositionTime
+ );
+ ok(true, "Current time is correct");
+
+ info("Select negative current time animation");
+ await selectNode(".cssanimation-normal", inspector);
+ await waitUntilCurrentTimeChangedAt(
+ animationInspector,
+ -1 * timeScale.zeroPositionTime
+ );
+ ok(true, "Current time is correct");
+
+ info("Back to 'body' and rewind the animation");
+ await selectNode("body", inspector);
+ await waitUntil(
+ () =>
+ panel.querySelectorAll(".animation-item").length ===
+ animationInspector.state.animations.length
+ );
+ clickOnRewindButton(animationInspector, panel);
+ await waitUntilCurrentTimeChangedAt(animationInspector, 0);
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber.js b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber.js
new file mode 100644
index 0000000000..76cd42f282
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber.js
@@ -0,0 +1,14 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* import-globals-from current-time-scrubber_head.js */
+
+add_task(async function () {
+ Services.scriptloader.loadSubScript(
+ CHROME_URL_ROOT + "current-time-scrubber_head.js",
+ this
+ );
+ await testCurrentTimeScrubber();
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber_each-different-creation-time-animations.js b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber_each-different-creation-time-animations.js
new file mode 100644
index 0000000000..6761bacb19
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber_each-different-creation-time-animations.js
@@ -0,0 +1,34 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether proper currentTime was set for each animations.
+
+const WAIT_TIME = 3000;
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".animated", ".still"]);
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info(
+ "Add an animation to make a situation which has different creation time"
+ );
+ await wait(WAIT_TIME);
+ await setClassAttribute(animationInspector, ".still", "ball compositor-all");
+ await waitUntil(() => panel.querySelectorAll(".animation-item").length === 2);
+
+ info("Move the scrubber");
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+
+ info("Check existed animations have different currentTime");
+ const animations = animationInspector.state.animations;
+ Assert.greater(
+ animations[0].state.currentTime + WAIT_TIME,
+ animations[1].state.currentTime,
+ `The currentTime of added animation shold be ${WAIT_TIME}ms less than ` +
+ "at least that currentTime of first animation"
+ );
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_empty_on_invalid_nodes.js b/devtools/client/inspector/animation/test/browser_animation_empty_on_invalid_nodes.js
new file mode 100644
index 0000000000..d3fa9166a1
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_empty_on_invalid_nodes.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test that the panel shows no animation data for invalid or not animated nodes
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".animated", ".long", ".still"]);
+ const { inspector, panel } = await openAnimationInspector();
+
+ info("Checking animation list and error message existence for a still node");
+ const stillNode = await getNodeFront(".still", inspector);
+ await selectNode(stillNode, inspector);
+
+ await waitUntil(() => panel.querySelector(".animation-error-message"));
+ ok(
+ true,
+ "Element which has animation-error-message class should exist for a still node"
+ );
+ is(
+ panel.querySelector(".animation-error-message > p").textContent,
+ ANIMATION_L10N.getStr("panel.noAnimation"),
+ "The correct error message is displayed"
+ );
+ ok(
+ !panel.querySelector(".animation-list"),
+ "Element which has animations class should not exist for a still node"
+ );
+
+ info(
+ "Show animations once to confirm if there is no animations on the comment node"
+ );
+ await selectNode(".long", inspector);
+ await waitUntil(() => !panel.querySelector(".animation-error-message"));
+
+ info("Checking animation list and error message existence for a text node");
+ const commentNode = await inspector.walker.previousSibling(stillNode);
+ await selectNode(commentNode, inspector);
+ await waitUntil(() => panel.querySelector(".animation-error-message"));
+ ok(
+ panel.querySelector(".animation-error-message"),
+ "Element which has animation-error-message class should exist for a text node"
+ );
+ ok(
+ !panel.querySelector(".animation-list"),
+ "Element which has animations class should not exist for a text node"
+ );
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_fission_switch-target.js b/devtools/client/inspector/animation/test/browser_animation_fission_switch-target.js
new file mode 100644
index 0000000000..5a50e4098f
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_fission_switch-target.js
@@ -0,0 +1,99 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the animation inspector works after switching targets.
+
+const PAGE_ON_CONTENT = `data:text/html;charset=utf-8,
+<!DOCTYPE html>
+<style type="text/css">
+ div {
+ opacity: 0;
+ transition-duration: 5000ms;
+ transition-property: opacity;
+ }
+
+ div:hover {
+ opacity: 1;
+ }
+</style>
+<div class="anim">animation</div>
+`;
+const PAGE_ON_MAIN = "about:networking";
+
+add_task(async function () {
+ await pushPref("devtools.inspector.three-pane-enabled", false);
+
+ info("Open a page that runs on the content process and has animations");
+ const tab = await addTab(PAGE_ON_CONTENT);
+ const { animationInspector, inspector } = await openAnimationInspector();
+
+ info("Check the length of the initial animations of the content process");
+ is(
+ animationInspector.state.animations.length,
+ 0,
+ "The length of the initial animation is correct"
+ );
+
+ info("Check whether the mutation on content process page is worked or not");
+ await assertAnimationsMutation(tab, "div", animationInspector, 1);
+
+ info("Load a page that runs on the main process");
+ await navigateTo(
+ PAGE_ON_MAIN,
+ tab.linkedBrowser,
+ animationInspector,
+ inspector
+ );
+ await waitUntil(() => animationInspector.state.animations.length === 0);
+ ok(true, "The animations are replaced");
+
+ info("Check whether the mutation on main process page is worked or not");
+ await assertAnimationsMutation(tab, "#category-http", animationInspector, 1);
+
+ info("Load a content process page again");
+ await navigateTo(
+ PAGE_ON_CONTENT,
+ tab.linkedBrowser,
+ animationInspector,
+ inspector
+ );
+ await waitUntil(() => animationInspector.state.animations.length === 0);
+ ok(true, "The animations are replaced again");
+
+ info("Check the mutation on content process again");
+ await assertAnimationsMutation(tab, "div", animationInspector, 1);
+});
+
+async function assertAnimationsMutation(
+ tab,
+ selector,
+ animationInspector,
+ expectedAnimationCount
+) {
+ await hover(tab, selector);
+ await waitUntil(
+ () => animationInspector.state.animations.length === expectedAnimationCount
+ );
+ ok(true, "Animations mutation is worked");
+}
+
+async function navigateTo(uri, browser, animationInspector, inspector) {
+ const previousAnimationsFront = animationInspector.animationsFront;
+ const onReloaded = inspector.once("reloaded");
+ const onUpdated = inspector.once("inspector-updated");
+ BrowserTestUtils.startLoadingURIString(browser, uri);
+ await waitUntil(
+ () => previousAnimationsFront !== animationInspector.animationsFront
+ );
+ ok(true, "Target is switched correctly");
+ await Promise.all([onReloaded, onUpdated]);
+}
+
+async function hover(tab, selector) {
+ await SpecialPowers.spawn(tab.linkedBrowser, [selector], async s => {
+ const element = content.wrappedJSObject.document.querySelector(s);
+ InspectorUtils.addPseudoClassLock(element, ":hover", true);
+ });
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_indication-bar.js b/devtools/client/inspector/animation/test/browser_animation_indication-bar.js
new file mode 100644
index 0000000000..829054178a
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_indication-bar.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether the indication bar of both scrubber and progress bar indicates correct
+// progress after resizing animation inspector.
+
+add_task(async function () {
+ await pushPref("devtools.inspector.three-pane-enabled", false);
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".animated"]);
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+
+ info("Checking timeline tick item elements after enlarge sidebar width");
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ await setSidebarWidth("100%", inspector);
+ assertPosition(".current-time-scrubber", panel, 0.5);
+ assertPosition(".keyframes-progress-bar", panel, 0.5);
+});
+
+/**
+ * Assert indication bar position.
+ *
+ * @param {String} indicationBarSelector
+ * @param {Element} panel
+ * @param {Number} expectedPositionRate
+ */
+function assertPosition(indicationBarSelector, panel, expectedPositionRate) {
+ const barEl = panel.querySelector(indicationBarSelector);
+ const parentEl = barEl.parentNode;
+ const rectBar = barEl.getBoundingClientRect();
+ const rectParent = parentEl.getBoundingClientRect();
+ const barX = rectBar.x + rectBar.width * 0.5 - rectParent.x;
+ const expectedPosition = rectParent.width * expectedPositionRate;
+ ok(
+ expectedPosition - 1 <= barX && barX <= expectedPosition + 1,
+ `Indication bar position should be approximately ${expectedPosition}`
+ );
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_infinity-duration_current-time-scrubber.js b/devtools/client/inspector/animation/test/browser_animation_infinity-duration_current-time-scrubber.js
new file mode 100644
index 0000000000..b4f82a950e
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_infinity-duration_current-time-scrubber.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether the scrubber was working for even the animation of infinity duration.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_infinity_duration.html");
+ await removeAnimatedElementsExcept([".infinity-delay-iteration-start"]);
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Set initial state");
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ const initialCurrentTime =
+ animationInspector.state.animations[0].state.currentTime;
+
+ info("Check whether the animation currentTime was increased");
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 1);
+ await waitUntil(
+ () =>
+ initialCurrentTime <
+ animationInspector.state.animations[0].state.currentTime
+ );
+ ok(true, "currentTime should be increased");
+
+ info("Check whether the progress bar was moved");
+ const areaEl = panel.querySelector(".keyframes-progress-bar-area");
+ const barEl = areaEl.querySelector(".keyframes-progress-bar");
+ const controllerBounds = areaEl.getBoundingClientRect();
+ const barBounds = barEl.getBoundingClientRect();
+ const barX = barBounds.x + barBounds.width / 2 - controllerBounds.x;
+ const expectedBarX = controllerBounds.width * 0.5;
+ Assert.less(
+ Math.abs(barX - expectedBarX),
+ 1,
+ "Progress bar should indicate at progress of 0.5"
+ );
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_infinity-duration_summary-graph.js b/devtools/client/inspector/animation/test/browser_animation_infinity-duration_summary-graph.js
new file mode 100644
index 0000000000..44343c3aa8
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_infinity-duration_summary-graph.js
@@ -0,0 +1,147 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following summary graph with the animation which has infinity duration.
+// * Tooltips
+// * Graph path
+// * Delay sign
+
+const TEST_DATA = [
+ {
+ targetClass: "infinity",
+ expectedIterationPath: [
+ { x: 0, y: 0 },
+ { x: 200000, y: 0 },
+ ],
+ expectedTooltip: {
+ duration: "\u221E",
+ },
+ },
+ {
+ targetClass: "infinity-delay-iteration-start",
+ expectedDelayPath: [
+ { x: 0, y: 0 },
+ { x: 100000, y: 0 },
+ ],
+ expectedDelaySign: {
+ marginInlineStart: "0%",
+ width: "50%",
+ },
+ expectedIterationPath: [
+ { x: 100000, y: 50 },
+ { x: 200000, y: 50 },
+ ],
+ expectedTooltip: {
+ delay: "100s",
+ duration: "\u221E",
+ iterationStart: "0.5 (\u221E)",
+ },
+ },
+ {
+ targetClass: "limited",
+ expectedIterationPath: [
+ { x: 0, y: 0 },
+ { x: 100000, y: 100 },
+ ],
+ expectedTooltip: {
+ duration: "100s",
+ },
+ },
+];
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_infinity_duration.html");
+ const { panel } = await openAnimationInspector();
+
+ for (const testData of TEST_DATA) {
+ const {
+ targetClass,
+ expectedDelayPath,
+ expectedDelaySign,
+ expectedIterationPath,
+ expectedTooltip,
+ } = testData;
+
+ const animationItemEl = await findAnimationItemByTargetSelector(
+ panel,
+ `.${targetClass}`
+ );
+ const summaryGraphEl = animationItemEl.querySelector(
+ ".animation-summary-graph"
+ );
+
+ info(`Check tooltip for the animation of .${targetClass}`);
+ assertTooltip(summaryGraphEl, expectedTooltip);
+
+ if (expectedDelayPath) {
+ info(`Check delay path for the animation of .${targetClass}`);
+ assertDelayPath(summaryGraphEl, expectedDelayPath);
+ }
+
+ if (expectedDelaySign) {
+ info(`Check delay sign for the animation of .${targetClass}`);
+ assertDelaySign(summaryGraphEl, expectedDelaySign);
+ }
+
+ info(`Check iteration path for the animation of .${targetClass}`);
+ assertIterationPath(summaryGraphEl, expectedIterationPath);
+ }
+});
+
+function assertDelayPath(summaryGraphEl, expectedPath) {
+ assertPath(
+ summaryGraphEl,
+ ".animation-computed-timing-path .animation-delay-path",
+ expectedPath
+ );
+}
+
+function assertDelaySign(summaryGraphEl, expectedSign) {
+ const signEl = summaryGraphEl.querySelector(".animation-delay-sign");
+
+ is(
+ signEl.style.marginInlineStart,
+ expectedSign.marginInlineStart,
+ `marginInlineStart position should be ${expectedSign.marginInlineStart}`
+ );
+ is(
+ signEl.style.width,
+ expectedSign.width,
+ `Width should be ${expectedSign.width}`
+ );
+}
+
+function assertIterationPath(summaryGraphEl, expectedPath) {
+ assertPath(
+ summaryGraphEl,
+ ".animation-computed-timing-path .animation-iteration-path",
+ expectedPath
+ );
+}
+
+function assertPath(summaryGraphEl, pathSelector, expectedPath) {
+ const pathEl = summaryGraphEl.querySelector(pathSelector);
+ assertPathSegments(pathEl, true, expectedPath);
+}
+
+function assertTooltip(summaryGraphEl, expectedTooltip) {
+ const tooltip = summaryGraphEl.getAttribute("title");
+ const { delay, duration, iterationStart } = expectedTooltip;
+
+ if (delay) {
+ const expected = `Delay: ${delay}`;
+ ok(tooltip.includes(expected), `Tooltip should include '${expected}'`);
+ }
+
+ if (duration) {
+ const expected = `Duration: ${duration}`;
+ ok(tooltip.includes(expected), `Tooltip should include '${expected}'`);
+ }
+
+ if (iterationStart) {
+ const expected = `Iteration start: ${iterationStart}`;
+ ok(tooltip.includes(expected), `Tooltip should include '${expected}'`);
+ }
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_infinity-duration_tick-label.js b/devtools/client/inspector/animation/test/browser_animation_infinity-duration_tick-label.js
new file mode 100644
index 0000000000..2a554267c4
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_infinity-duration_tick-label.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test of the content of tick label on timeline header
+// with the animation which has infinity duration.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_infinity_duration.html");
+ const { inspector, panel } = await openAnimationInspector();
+
+ info("Check the tick label content with limited duration animation");
+ isnot(
+ panel.querySelector(".animation-list-container .tick-label:last-child")
+ .textContent,
+ "\u221E",
+ "The content should not be \u221E"
+ );
+
+ info("Check the tick label content with infinity duration animation only");
+ await selectNode(".infinity", inspector);
+ await waitUntil(
+ () =>
+ panel.querySelector(".animation-list-container .tick-label:last-child")
+ .textContent === "\u221E"
+ );
+ ok(true, "The content should be \u221E");
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-01.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-01.js
new file mode 100644
index 0000000000..b893626bda
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-01.js
@@ -0,0 +1,164 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for ComputedValuePath of animations that consist by multi types of animated
+// properties.
+
+requestLongerTimeout(2);
+
+const TEST_DATA = [
+ {
+ targetClass: "multi-types",
+ properties: [
+ {
+ name: "background-color",
+ computedValuePathClass: "color-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 0, y: 100 },
+ { x: 1000, y: 100 },
+ ],
+ expectedStopColors: [
+ { offset: 0, color: "rgb(255, 0, 0)" },
+ { offset: 1, color: "rgb(0, 255, 0)" },
+ ],
+ },
+ {
+ name: "background-repeat",
+ computedValuePathClass: "discrete-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 499.999, y: 0 },
+ { x: 500, y: 100 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ {
+ name: "font-size",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 500, y: 50 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ {
+ name: "margin-left",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 500, y: 50 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ {
+ name: "opacity",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 500, y: 50 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ {
+ name: "text-align",
+ computedValuePathClass: "discrete-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 499.999, y: 0 },
+ { x: 500, y: 100 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ {
+ name: "transform",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 500, y: 50 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "multi-types-reverse",
+ properties: [
+ {
+ name: "background-color",
+ computedValuePathClass: "color-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 0, y: 100 },
+ { x: 1000, y: 100 },
+ ],
+ expectedStopColors: [
+ { offset: 0, color: "rgb(0, 255, 0)" },
+ { offset: 1, color: "rgb(255, 0, 0)" },
+ ],
+ },
+ {
+ name: "background-repeat",
+ computedValuePathClass: "discrete-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 499.999, y: 0 },
+ { x: 500, y: 100 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ {
+ name: "font-size",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 100 },
+ { x: 500, y: 50 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ {
+ name: "margin-left",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 100 },
+ { x: 500, y: 50 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ {
+ name: "opacity",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 100 },
+ { x: 500, y: 50 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ {
+ name: "text-align",
+ computedValuePathClass: "discrete-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 499.999, y: 0 },
+ { x: 500, y: 100 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ {
+ name: "transform",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 100 },
+ { x: 500, y: 50 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ ],
+ },
+];
+
+add_task(async function () {
+ await testKeyframesGraphComputedValuePath(TEST_DATA);
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-02.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-02.js
new file mode 100644
index 0000000000..c36bd22628
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-02.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for ComputedValuePath of animations that consist by one animated property
+// on complexed keyframes.
+
+requestLongerTimeout(2);
+
+const TEST_DATA = [
+ {
+ targetClass: "steps-effect",
+ properties: [
+ {
+ name: "opacity",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 250, y: 25 },
+ { x: 500, y: 50 },
+ { x: 750, y: 75 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "steps-jump-none-keyframe",
+ properties: [
+ {
+ name: "opacity",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 199, y: 0 },
+ { x: 200, y: 25 },
+ { x: 399, y: 25 },
+ { x: 400, y: 50 },
+ { x: 599, y: 50 },
+ { x: 600, y: 75 },
+ { x: 799, y: 75 },
+ { x: 800, y: 100 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "narrow-offsets",
+ properties: [
+ {
+ name: "opacity",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 100, y: 100 },
+ { x: 110, y: 100 },
+ { x: 114.9, y: 100 },
+ { x: 115, y: 50 },
+ { x: 129.9, y: 50 },
+ { x: 130, y: 0 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "duplicate-offsets",
+ properties: [
+ {
+ name: "opacity",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 100 },
+ { x: 250, y: 100 },
+ { x: 499, y: 100 },
+ { x: 500, y: 100 },
+ { x: 500, y: 0 },
+ { x: 750, y: 50 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ ],
+ },
+];
+
+add_task(async function () {
+ await testKeyframesGraphComputedValuePath(TEST_DATA);
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-03.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-03.js
new file mode 100644
index 0000000000..957a693a31
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-03.js
@@ -0,0 +1,190 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for ComputedValuePath of animations that consist by multi types of animated
+// properties on complexed keyframes.
+
+requestLongerTimeout(2);
+
+const TEST_DATA = [
+ {
+ targetClass: "middle-keyframe",
+ properties: [
+ {
+ name: "background-color",
+ computedValuePathClass: "color-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 0, y: 100 },
+ { x: 500, y: 100 },
+ { x: 1000, y: 100 },
+ ],
+ expectedStopColors: [
+ { offset: 0, color: "rgb(255, 0, 0)" },
+ { offset: 0.5, color: "rgb(0, 0, 255)" },
+ { offset: 1, color: "rgb(0, 255, 0)" },
+ ],
+ },
+ {
+ name: "background-repeat",
+ computedValuePathClass: "discrete-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 249.999, y: 0 },
+ { x: 250, y: 100 },
+ { x: 749.999, y: 100 },
+ { x: 750, y: 0 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ {
+ name: "font-size",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 250, y: 50 },
+ { x: 500, y: 100 },
+ { x: 750, y: 50 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ {
+ name: "margin-left",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 250, y: 50 },
+ { x: 500, y: 100 },
+ { x: 750, y: 50 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ {
+ name: "opacity",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 250, y: 50 },
+ { x: 500, y: 100 },
+ { x: 750, y: 50 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ {
+ name: "text-align",
+ computedValuePathClass: "discrete-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 249.999, y: 0 },
+ { x: 250, y: 100 },
+ { x: 749.999, y: 100 },
+ { x: 750, y: 0 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ {
+ name: "transform",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 250, y: 50 },
+ { x: 500, y: 100 },
+ { x: 750, y: 50 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "steps-keyframe",
+ properties: [
+ {
+ name: "background-color",
+ computedValuePathClass: "color-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 0, y: 100 },
+ { x: 500, y: 100 },
+ { x: 1000, y: 100 },
+ ],
+ expectedStopColors: [
+ { offset: 0, color: "rgb(255, 0, 0)" },
+ { offset: 0.499, color: "rgb(255, 0, 0)" },
+ { offset: 0.5, color: "rgb(128, 128, 0)" },
+ { offset: 0.999, color: "rgb(128, 128, 0)" },
+ { offset: 1, color: "rgb(0, 255, 0)" },
+ ],
+ },
+ {
+ name: "background-repeat",
+ computedValuePathClass: "discrete-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 499.999, y: 0 },
+ { x: 500, y: 100 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ {
+ name: "font-size",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 500, y: 0 },
+ { x: 500, y: 50 },
+ { x: 1000, y: 50 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ {
+ name: "margin-left",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 499.999, y: 0 },
+ { x: 500, y: 50 },
+ { x: 999.999, y: 50 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ {
+ name: "opacity",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 499.999, y: 0 },
+ { x: 500, y: 50 },
+ { x: 999.999, y: 50 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ {
+ name: "text-align",
+ computedValuePathClass: "discrete-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 499.999, y: 0 },
+ { x: 500, y: 100 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ {
+ name: "transform",
+ computedValuePathClass: "distance-path",
+ expectedPathSegments: [
+ { x: 0, y: 0 },
+ { x: 500, y: 0 },
+ { x: 500, y: 50 },
+ { x: 1000, y: 50 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ ],
+ },
+];
+
+add_task(async function () {
+ await testKeyframesGraphComputedValuePath(TEST_DATA);
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path_easing-hint.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path_easing-hint.js
new file mode 100644
index 0000000000..b95c8d5fe4
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path_easing-hint.js
@@ -0,0 +1,380 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following easing hint in ComputedValuePath.
+// * element existence
+// * path segments
+// * hint text
+
+const TEST_DATA = [
+ {
+ targetClass: "no-easing",
+ properties: [
+ {
+ name: "opacity",
+ expectedHints: [
+ {
+ hint: "linear",
+ path: [
+ { x: 0, y: 100 },
+ { x: 500, y: 50 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "effect-easing",
+ properties: [
+ {
+ name: "opacity",
+ expectedHints: [
+ {
+ hint: "linear",
+ path: [
+ { x: 0, y: 100 },
+ { x: 199, y: 81 },
+ { x: 200, y: 80 },
+ { x: 399, y: 61 },
+ { x: 400, y: 60 },
+ { x: 599, y: 41 },
+ { x: 600, y: 40 },
+ { x: 799, y: 21 },
+ { x: 800, y: 20 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "keyframe-easing",
+ properties: [
+ {
+ name: "opacity",
+ expectedHints: [
+ {
+ hint: "steps(2)",
+ path: [
+ { x: 0, y: 100 },
+ { x: 499, y: 100 },
+ { x: 500, y: 50 },
+ { x: 999, y: 50 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "both-easing",
+ properties: [
+ {
+ name: "margin-left",
+ expectedHints: [
+ {
+ hint: "steps(1)",
+ path: [
+ { x: 0, y: 0 },
+ { x: 999, y: 0 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ ],
+ },
+ {
+ name: "opacity",
+ expectedHints: [
+ {
+ hint: "steps(2)",
+ path: [
+ { x: 0, y: 100 },
+ { x: 499, y: 100 },
+ { x: 500, y: 50 },
+ { x: 999, y: 50 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "narrow-keyframes",
+ properties: [
+ {
+ name: "opacity",
+ expectedHints: [
+ {
+ hint: "linear",
+ path: [
+ { x: 0, y: 0 },
+ { x: 100, y: 100 },
+ ],
+ },
+ {
+ hint: "steps(1)",
+ path: [
+ { x: 129, y: 100 },
+ { x: 130, y: 0 },
+ ],
+ },
+ {
+ hint: "linear",
+ path: [
+ { x: 130, y: 0 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "duplicate-keyframes",
+ properties: [
+ {
+ name: "opacity",
+ expectedHints: [
+ {
+ hint: "linear",
+ path: [
+ { x: 0, y: 0 },
+ { x: 500, y: 100 },
+ ],
+ },
+ {
+ hint: "",
+ path: [
+ { x: 500, y: 100 },
+ { x: 500, y: 0 },
+ ],
+ },
+ {
+ hint: "steps(1)",
+ path: [
+ { x: 500, y: 0 },
+ { x: 999, y: 0 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "color-keyframes",
+ properties: [
+ {
+ name: "color",
+ expectedHints: [
+ {
+ hint: "ease-in",
+ rect: {
+ x: 0,
+ height: 100,
+ width: 400,
+ },
+ },
+ {
+ hint: "ease-out",
+ rect: {
+ x: 400,
+ height: 100,
+ width: 600,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "jump-start",
+ properties: [
+ {
+ name: "opacity",
+ expectedHints: [
+ {
+ hint: "steps(2, jump-start)",
+ path: [
+ { x: 0, y: 50 },
+ { x: 499, y: 50 },
+ { x: 500, y: 0 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "jump-end",
+ properties: [
+ {
+ name: "opacity",
+ expectedHints: [
+ {
+ hint: "steps(2)",
+ path: [
+ { x: 0, y: 100 },
+ { x: 499, y: 100 },
+ { x: 500, y: 50 },
+ { x: 999, y: 50 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "jump-both",
+ properties: [
+ {
+ name: "opacity",
+ expectedHints: [
+ {
+ hint: "steps(3, jump-both)",
+ path: [
+ { x: 0, y: 75 },
+ { x: 330, y: 75 },
+ { x: 340, y: 50 },
+ { x: 660, y: 50 },
+ { x: 670, y: 25 },
+ { x: 999, y: 25 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+];
+
+// Prevent test timeout's on windows code coverage: Bug 1470757
+requestLongerTimeout(2);
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_multi_easings.html");
+ await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`));
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ for (const { properties, targetClass } of TEST_DATA) {
+ info(`Checking keyframes graph for ${targetClass}`);
+ const onDetailRendered = animationInspector.once(
+ "animation-keyframes-rendered"
+ );
+ await clickOnAnimationByTargetSelector(
+ animationInspector,
+ panel,
+ `.${targetClass}`
+ );
+ await onDetailRendered;
+
+ for (const { name, expectedHints } of properties) {
+ const testTarget = `${name} in ${targetClass}`;
+ info(`Checking easing hint for ${testTarget}`);
+ info(`Checking easing hint existence for ${testTarget}`);
+ const hintEls = panel.querySelectorAll(`.${name} .hint`);
+ is(
+ hintEls.length,
+ expectedHints.length,
+ `Count of easing hint elements of ${testTarget} ` +
+ `should be ${expectedHints.length}`
+ );
+
+ for (let i = 0; i < expectedHints.length; i++) {
+ const hintTarget = `hint[${i}] of ${testTarget}`;
+
+ info(`Checking ${hintTarget}`);
+ const hintEl = hintEls[i];
+ const expectedHint = expectedHints[i];
+
+ info(`Checking <title> in ${hintTarget}`);
+ const titleEl = hintEl.querySelector("title");
+ ok(titleEl, `<title> element in ${hintTarget} should be existence`);
+ is(
+ titleEl.textContent,
+ expectedHint.hint,
+ `Content of <title> in ${hintTarget} should be ${expectedHint.hint}`
+ );
+
+ let interactionEl = null;
+ let displayedEl = null;
+ if (expectedHint.path) {
+ info(`Checking <path> in ${hintTarget}`);
+ interactionEl = hintEl.querySelector("path");
+ displayedEl = interactionEl;
+ ok(
+ interactionEl,
+ `The <path> element in ${hintTarget} should be existence`
+ );
+ assertPathSegments(interactionEl, false, expectedHint.path);
+ } else {
+ info(`Checking <rect> in ${hintTarget}`);
+ interactionEl = hintEl.querySelector("rect");
+ displayedEl = hintEl.querySelector("line");
+ ok(
+ interactionEl,
+ `The <rect> element in ${hintTarget} should be existence`
+ );
+ is(
+ parseInt(interactionEl.getAttribute("x"), 10),
+ expectedHint.rect.x,
+ `x of <rect> in ${hintTarget} should be ${expectedHint.rect.x}`
+ );
+ is(
+ parseInt(interactionEl.getAttribute("width"), 10),
+ expectedHint.rect.width,
+ `width of <rect> in ${hintTarget} should be ${expectedHint.rect.width}`
+ );
+ }
+
+ info(`Checking interaction for ${hintTarget}`);
+ interactionEl.scrollIntoView(false);
+ const win = hintEl.ownerGlobal;
+ // Mouse over the pathEl.
+ ok(
+ isStrokeChangedByMouseOver(interactionEl, displayedEl, win),
+ `stroke-opacity of hintEl for ${hintTarget} should be 1 ` +
+ "while mouse is over the element"
+ );
+ // Mouse out from pathEl.
+ EventUtils.synthesizeMouse(
+ panel.querySelector(".animation-toolbar"),
+ 0,
+ 0,
+ { type: "mouseover" },
+ win
+ );
+ is(
+ parseInt(win.getComputedStyle(displayedEl).strokeOpacity, 10),
+ 0,
+ `stroke-opacity of hintEl for ${hintTarget} should be 0 ` +
+ "while mouse is out from the element"
+ );
+ }
+ }
+ }
+});
+
+function isStrokeChangedByMouseOver(mouseoverEl, displayedEl, win) {
+ const boundingBox = mouseoverEl.getBoundingClientRect();
+ const x = boundingBox.width / 2;
+
+ for (let y = 0; y < boundingBox.height; y++) {
+ EventUtils.synthesizeMouse(mouseoverEl, x, y, { type: "mouseover" }, win);
+
+ if (win.getComputedStyle(displayedEl).strokeOpacity == 1) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_keyframe-marker-rtl.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_keyframe-marker-rtl.js
new file mode 100644
index 0000000000..c90b231f0a
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_keyframe-marker-rtl.js
@@ -0,0 +1,14 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function () {
+ Services.scriptloader.loadSubScript(
+ CHROME_URL_ROOT + "keyframes-graph_keyframe-marker_head.js",
+ this
+ );
+ await pushPref("intl.l10n.pseudo", "bidi");
+ // eslint-disable-next-line no-undef
+ await testKeyframesGraphKeyframesMarker();
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_keyframe-marker.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_keyframe-marker.js
new file mode 100644
index 0000000000..a19c2993f2
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_keyframe-marker.js
@@ -0,0 +1,13 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function () {
+ Services.scriptloader.loadSubScript(
+ CHROME_URL_ROOT + "keyframes-graph_keyframe-marker_head.js",
+ this
+ );
+ // eslint-disable-next-line no-undef
+ await testKeyframesGraphKeyframesMarker();
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_special-colors.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_special-colors.js
new file mode 100644
index 0000000000..6f46b44332
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_special-colors.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_DATA = [
+ {
+ propertyName: "caret-color",
+ expectedMarkers: ["auto", "rgb(0, 255, 0)"],
+ },
+ {
+ propertyName: "scrollbar-color",
+ expectedMarkers: ["rgb(0, 255, 0) rgb(255, 0, 0)", "auto"],
+ },
+];
+
+// Test for animatable property which can specify the non standard CSS color value.
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_special_colors.html");
+ const { panel } = await openAnimationInspector();
+
+ for (const { propertyName, expectedMarkers } of TEST_DATA) {
+ const animatedPropertyEl = panel.querySelector(`.${propertyName}`);
+ ok(animatedPropertyEl, `Animated property ${propertyName} exists`);
+
+ const markerEls = animatedPropertyEl.querySelectorAll(
+ ".keyframe-marker-item"
+ );
+ is(
+ markerEls.length,
+ expectedMarkers.length,
+ `The length of keyframe markers should ${expectedMarkers.length}`
+ );
+ for (let i = 0; i < expectedMarkers.length; i++) {
+ const actualTitle = markerEls[i].title;
+ const expectedTitle = expectedMarkers[i];
+ is(actualTitle, expectedTitle, `Value of keyframes[${i}] is correct`);
+ }
+ }
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-progress-bar.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-progress-bar.js
new file mode 100644
index 0000000000..a7051d9a01
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-progress-bar.js
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following KeyframesProgressBar:
+// * element existence
+// * progress bar position in multi effect timings
+// * progress bar position after changing playback rate
+// * progress bar position when select another animation
+
+requestLongerTimeout(3);
+
+const TEST_DATA = [
+ {
+ targetClass: "cssanimation-linear",
+ scrubberPositions: [0, 0.25, 0.5, 0.75, 1],
+ expectedPositions: [0, 0.25, 0.5, 0.75, 0],
+ },
+ {
+ targetClass: "easing-step",
+ scrubberPositions: [0, 0.49, 0.5, 0.99],
+ expectedPositions: [0, 0, 0.5, 0.5],
+ },
+ {
+ targetClass: "delay-positive",
+ scrubberPositions: [0, 0.33, 0.5],
+ expectedPositions: [0, 0, 0.25],
+ },
+ {
+ targetClass: "delay-negative",
+ scrubberPositions: [0, 0.49, 0.5, 0.75],
+ expectedPositions: [0, 0, 0.5, 0.75],
+ },
+ {
+ targetClass: "enddelay-positive",
+ scrubberPositions: [0, 0.66, 0.67, 0.99],
+ expectedPositions: [0, 0.99, 0, 0],
+ },
+ {
+ targetClass: "enddelay-negative",
+ scrubberPositions: [0, 0.49, 0.5, 0.99],
+ expectedPositions: [0, 0.49, 0, 0],
+ },
+ {
+ targetClass: "direction-reverse-with-iterations-infinity",
+ scrubberPositions: [0, 0.25, 0.5, 0.75, 1],
+ expectedPositions: [1, 0.75, 0.5, 0.25, 1],
+ },
+ {
+ targetClass: "fill-both-width-delay-iterationstart",
+ scrubberPositions: [0, 0.33, 0.66, 0.833, 1],
+ expectedPositions: [0.5, 0.5, 0.99, 0.25, 0.5],
+ },
+];
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_multi_timings.html");
+ await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`));
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+
+ info("Checking progress bar position in multi effect timings");
+
+ for (const testdata of TEST_DATA) {
+ const { targetClass, scrubberPositions, expectedPositions } = testdata;
+
+ info(`Checking progress bar position for ${targetClass}`);
+ const onDetailRendered = animationInspector.once(
+ "animation-keyframes-rendered"
+ );
+ await selectNode(`.${targetClass}`, inspector);
+ await onDetailRendered;
+
+ info("Checking progress bar existence");
+ const areaEl = panel.querySelector(".keyframes-progress-bar-area");
+ ok(areaEl, "progress bar area should exist");
+ const barEl = areaEl.querySelector(".keyframes-progress-bar");
+ ok(barEl, "progress bar should exist");
+
+ for (let i = 0; i < scrubberPositions.length; i++) {
+ info(`Scrubber position is ${scrubberPositions[i]}`);
+ clickOnCurrentTimeScrubberController(
+ animationInspector,
+ panel,
+ scrubberPositions[i]
+ );
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ assertPosition(barEl, areaEl, expectedPositions[i], animationInspector);
+ }
+ }
+});
+
+function assertPosition(barEl, areaEl, expectedRate, animationInspector) {
+ const controllerBounds = areaEl.getBoundingClientRect();
+ const barBounds = barEl.getBoundingClientRect();
+ const barX = barBounds.x + barBounds.width / 2 - controllerBounds.x;
+ const expected = controllerBounds.width * expectedRate;
+ ok(
+ expected - 1 < barX && barX < expected + 1,
+ `Position should apploximately be ${expected} (x of bar is ${barX})`
+ );
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-progress-bar_after-resuming.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-progress-bar_after-resuming.js
new file mode 100644
index 0000000000..8b91026799
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-progress-bar_after-resuming.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether keyframes progress bar moves correctly after resuming the animation.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".animated"]);
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ const scrubberPositions = [0, 0.25, 0.5, 0.75];
+ const expectedPositions = [0, 0.25, 0.5, 0.75];
+
+ info("Check whether the keyframes progress bar position was correct");
+ await assertPosition(
+ panel,
+ scrubberPositions,
+ expectedPositions,
+ animationInspector
+ );
+
+ info(
+ "Check whether the keyframes progress bar position was correct " +
+ "after a bit time passed and resuming"
+ );
+ await wait(500);
+ clickOnPauseResumeButton(animationInspector, panel);
+ await assertPosition(
+ panel,
+ scrubberPositions,
+ expectedPositions,
+ animationInspector
+ );
+});
+
+async function assertPosition(
+ panel,
+ scrubberPositions,
+ expectedPositions,
+ animationInspector
+) {
+ const areaEl = panel.querySelector(".keyframes-progress-bar-area");
+ const barEl = areaEl.querySelector(".keyframes-progress-bar");
+ const controllerBounds = areaEl.getBoundingClientRect();
+
+ for (let i = 0; i < scrubberPositions.length; i++) {
+ info(`Scrubber position is ${scrubberPositions[i]}`);
+ clickOnCurrentTimeScrubberController(
+ animationInspector,
+ panel,
+ scrubberPositions[i]
+ );
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ const barBounds = barEl.getBoundingClientRect();
+ const barX = barBounds.x + barBounds.width / 2 - controllerBounds.x;
+ const expected = controllerBounds.width * expectedPositions[i];
+ ok(
+ expected - 1 < barX && barX < expected + 1,
+ `Position should apploximately be ${expected} (x of bar is ${barX})`
+ );
+ }
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_adjust-time-with-playback-rate.js b/devtools/client/inspector/animation/test/browser_animation_logic_adjust-time-with-playback-rate.js
new file mode 100644
index 0000000000..ad356cadd2
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_logic_adjust-time-with-playback-rate.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test adjusting the created time with different playback rate of animation.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_custom_playback_rate.html");
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+
+ info(
+ "Pause the all animation and set current time to middle in order to check " +
+ "the adjusting time"
+ );
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5);
+
+ info("Check the created times of all animation are same");
+ checkAdjustingTheTime(
+ animationInspector.state.animations[0].state,
+ animationInspector.state.animations[1].state
+ );
+
+ info("Change the playback rate to x10 after selecting '.div2'");
+ await selectNode(".div2", inspector);
+ await waitUntil(() => panel.querySelectorAll(".animation-item").length === 1);
+ await changePlaybackRateSelector(animationInspector, panel, 10);
+
+ info("Check each adjusted result of animations after selecting 'body' again");
+ await selectNode("body", inspector);
+ await waitUntil(() => panel.querySelectorAll(".animation-item").length === 2);
+
+ checkAdjustingTheTime(
+ animationInspector.state.animations[0].state,
+ animationInspector.state.animations[1].state
+ );
+
+ await waitUntil(
+ () => animationInspector.state.animations[0].state.currentTime === 50000
+ );
+ ok(true, "The current time of '.div1' animation is 50%");
+
+ await waitUntil(
+ () => animationInspector.state.animations[1].state.currentTime === 50000
+ );
+ ok(true, "The current time of '.div2' animation is 50%");
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_adjust-time.js b/devtools/client/inspector/animation/test/browser_animation_logic_adjust-time.js
new file mode 100644
index 0000000000..44769ea055
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_logic_adjust-time.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test adjusting the created time with different current times of animation.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_custom_playback_rate.html");
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+
+ info(
+ "Pause the all animation and set current time to middle time in order to " +
+ "check the adjusting time"
+ );
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5);
+
+ info("Check the created times of all animation are same");
+ checkAdjustingTheTime(
+ animationInspector.state.animations[0].state,
+ animationInspector.state.animations[1].state
+ );
+
+ info("Change the current time to 75% after selecting '.div2'");
+ await selectNode(".div2", inspector);
+ await waitUntil(() => panel.querySelectorAll(".animation-item").length === 1);
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0.75);
+
+ info("Check each adjusted result of animations after selecting 'body' again");
+ await selectNode("body", inspector);
+ await waitUntil(() => panel.querySelectorAll(".animation-item").length === 2);
+
+ checkAdjustingTheTime(
+ animationInspector.state.animations[0].state,
+ animationInspector.state.animations[1].state
+ );
+ is(
+ animationInspector.state.animations[0].state.currentTime,
+ 50000,
+ "The current time of '.div1' animation is 50%"
+ );
+ is(
+ animationInspector.state.animations[1].state.currentTime,
+ 75000,
+ "The current time of '.div2' animation is 75%"
+ );
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_auto-stop.js b/devtools/client/inspector/animation/test/browser_animation_logic_auto-stop.js
new file mode 100644
index 0000000000..fdf1867ffa
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_logic_auto-stop.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Animation inspector makes the current time to stop
+// after end of animation duration except iterations infinity.
+// Test followings:
+// * state of animations and UI components after end of animation duration
+// * state of animations and UI components after end of animation duration
+// but iteration count is infinity
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".compositor-all", ".long"]);
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+
+ info("Checking state after end of animation duration");
+ await selectNode(".long", inspector);
+ await waitUntil(() => panel.querySelectorAll(".animation-item").length === 1);
+ const pixelsData = getDurationAndRate(animationInspector, panel, 5);
+ clickOnCurrentTimeScrubberController(
+ animationInspector,
+ panel,
+ 1 - pixelsData.rate
+ );
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ clickOnPauseResumeButton(animationInspector, panel);
+ await assertStates(animationInspector, panel, false);
+
+ info(
+ "Checking state after end of animation duration and infinity iterations"
+ );
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ await selectNode(".compositor-all", inspector);
+ await waitUntil(() => panel.querySelectorAll(".animation-item").length === 1);
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 1);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ clickOnPauseResumeButton(animationInspector, panel);
+ await assertStates(animationInspector, panel, true);
+});
+
+async function assertStates(animationInspector, panel, shouldRunning) {
+ const buttonEl = panel.querySelector(".pause-resume-button");
+ const labelEl = panel.querySelector(".current-time-label");
+ const scrubberEl = panel.querySelector(".current-time-scrubber");
+
+ const previousLabelContent = labelEl.textContent;
+ const previousScrubberX = scrubberEl.getBoundingClientRect().x;
+
+ await waitUntilAnimationsPlayState(
+ animationInspector,
+ shouldRunning ? "running" : "paused"
+ );
+
+ const currentLabelContent = labelEl.textContent;
+ const currentScrubberX = scrubberEl.getBoundingClientRect().x;
+
+ if (shouldRunning) {
+ isnot(
+ previousLabelContent,
+ currentLabelContent,
+ "Current time label content should change"
+ );
+ isnot(
+ previousScrubberX,
+ currentScrubberX,
+ "Current time scrubber position should change"
+ );
+ ok(
+ !buttonEl.classList.contains("paused"),
+ "State of button should be running"
+ );
+ assertAnimationsRunning(animationInspector);
+ } else {
+ is(
+ previousLabelContent,
+ currentLabelContent,
+ "Current time label Content should not change"
+ );
+ is(
+ previousScrubberX,
+ currentScrubberX,
+ "Current time scrubber position should not change"
+ );
+ ok(
+ buttonEl.classList.contains("paused"),
+ "State of button should be paused"
+ );
+ assertAnimationsPausing(animationInspector);
+ }
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_avoid-updating-during-hiding.js b/devtools/client/inspector/animation/test/browser_animation_logic_avoid-updating-during-hiding.js
new file mode 100644
index 0000000000..cfdd111ec9
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_logic_avoid-updating-during-hiding.js
@@ -0,0 +1,92 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Animation inspector should not update when hidden.
+// Test for followings:
+// * whether the UIs update after selecting another inspector
+// * whether the UIs update after selecting another tool
+// * whether the UIs update after selecting animation inspector again
+
+add_task(async function () {
+ info(
+ "Switch to 2 pane inspector to see if the animation only refreshes when visible"
+ );
+ await pushPref("devtools.inspector.three-pane-enabled", false);
+ await addTab(URL_ROOT + "doc_custom_playback_rate.html");
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+
+ info("Checking the UIs update after selecting another inspector");
+ await selectNode("head", inspector);
+ inspector.sidebar.select("ruleview");
+ await selectNode("div", inspector);
+ await waitUntil(() => !animationInspector.state.animations.length);
+ ok(true, "Should not update after selecting another inspector");
+
+ await selectAnimationInspector(inspector);
+ await waitUntil(() => animationInspector.state.animations.length);
+ ok(true, "Should update after selecting animation inspector");
+
+ await assertCurrentTimeUpdated(animationInspector, panel, true);
+ inspector.sidebar.select("ruleview");
+ is(
+ animationInspector.state.animations.length,
+ 1,
+ "Should not update after selecting another inspector again"
+ );
+ await assertCurrentTimeUpdated(animationInspector, panel, false);
+
+ info("Checking the UIs update after selecting another tool");
+ await selectAnimationInspector(inspector);
+ await selectNode("head", inspector);
+ await waitUntil(() => !animationInspector.state.animations.length);
+ await inspector.toolbox.selectTool("webconsole");
+ await selectNode("div", inspector);
+ is(
+ animationInspector.state.animations.length,
+ 0,
+ "Should not update after selecting another tool"
+ );
+ await selectAnimationInspector(inspector);
+ await waitUntil(() => animationInspector.state.animations.length);
+ is(
+ animationInspector.state.animations.length,
+ 1,
+ "Should update after selecting animation inspector"
+ );
+ await assertCurrentTimeUpdated(animationInspector, panel, true);
+ await inspector.toolbox.selectTool("webconsole");
+ await waitUntil(() => animationInspector.state.animations.length);
+ is(
+ animationInspector.state.animations.length,
+ 1,
+ "Should not update after selecting another tool again"
+ );
+ await assertCurrentTimeUpdated(animationInspector, panel, false);
+});
+
+async function assertCurrentTimeUpdated(
+ animationInspector,
+ panel,
+ shouldRunning
+) {
+ let count = 0;
+
+ const listener = () => {
+ count++;
+ };
+
+ animationInspector.addAnimationsCurrentTimeListener(listener);
+ await new Promise(resolve =>
+ panel.ownerGlobal.requestAnimationFrame(resolve)
+ );
+ animationInspector.removeAnimationsCurrentTimeListener(listener);
+
+ if (shouldRunning) {
+ isnot(count, 0, "Should forward current time");
+ } else {
+ is(count, 0, "Should not forward current time");
+ }
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_created-time.js b/devtools/client/inspector/animation/test/browser_animation_logic_created-time.js
new file mode 100644
index 0000000000..59e0f4df52
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_logic_created-time.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether the created time of animation unchanged even if change node.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_custom_playback_rate.html");
+ const { animationInspector, inspector } = await openAnimationInspector();
+
+ info("Check both the created time of animation are same");
+ const baseCreatedTime =
+ animationInspector.state.animations[0].state.createdTime;
+ is(
+ animationInspector.state.animations[1].state.createdTime,
+ baseCreatedTime,
+ "Both created time of animations should be same"
+ );
+
+ info("Check created time after selecting '.div1'");
+ await selectNode(".div1", inspector);
+ await waitUntil(
+ () =>
+ animationInspector.state.animations[0].state.createdTime ===
+ baseCreatedTime
+ );
+ ok(
+ true,
+ "The created time of animation on element of .div1 should unchanged"
+ );
+
+ info("Check created time after selecting '.div2'");
+ await selectNode(".div2", inspector);
+ await waitUntil(
+ () =>
+ animationInspector.state.animations[0].state.createdTime ===
+ baseCreatedTime
+ );
+ ok(
+ true,
+ "The created time of animation on element of .div2 should unchanged"
+ );
+
+ info("Check created time after selecting 'body' again");
+ await selectNode("body", inspector);
+ is(
+ animationInspector.state.animations[0].state.createdTime,
+ baseCreatedTime,
+ "The created time of animation[0] should unchanged"
+ );
+ is(
+ animationInspector.state.animations[1].state.createdTime,
+ baseCreatedTime,
+ "The created time of animation[1] should unchanged"
+ );
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_mutations.js b/devtools/client/inspector/animation/test/browser_animation_logic_mutations.js
new file mode 100644
index 0000000000..56228a60c2
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_logic_mutations.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following mutations:
+// * add animation
+// * remove animation
+// * modify animation
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([
+ ".compositor-all",
+ ".compositor-notall",
+ ".no-compositor",
+ ".still",
+ ]);
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+
+ info("Checking the mutation for add an animation");
+ const originalAnimationCount = animationInspector.state.animations.length;
+ await setClassAttribute(animationInspector, ".still", "ball no-compositor");
+ await waitUntil(
+ () =>
+ animationInspector.state.animations.length === originalAnimationCount + 1
+ );
+ ok(true, "Count of animation should be plus one to original count");
+
+ info(
+ "Checking added animation existence even the animation name is duplicated"
+ );
+ is(
+ getAnimationNameCount(panel, "no-compositor"),
+ 2,
+ "Count of animation should be plus one to original count"
+ );
+
+ info("Checking the mutation for remove an animation");
+ await setClassAttribute(
+ animationInspector,
+ ".compositor-notall",
+ "ball still"
+ );
+ await waitUntil(
+ () => animationInspector.state.animations.length === originalAnimationCount
+ );
+ ok(
+ true,
+ "Count of animation should be same to original count since we remove an animation"
+ );
+
+ info("Checking the mutation for modify an animation");
+ await selectNode(".compositor-all", inspector);
+ await setStyle(
+ animationInspector,
+ ".compositor-all",
+ "animationDuration",
+ "100s"
+ );
+ await setStyle(
+ animationInspector,
+ ".compositor-all",
+ "animationIterationCount",
+ 1
+ );
+ const summaryGraphPathEl = getSummaryGraphPathElement(
+ panel,
+ "compositor-all"
+ );
+ await waitUntil(() => summaryGraphPathEl.viewBox.baseVal.width === 100000);
+ ok(
+ true,
+ "Width of summary graph path should be 100000 " +
+ "after modifing the duration and iteration count"
+ );
+ await setStyle(
+ animationInspector,
+ ".compositor-all",
+ "animationDelay",
+ "100s"
+ );
+ await waitUntil(() => summaryGraphPathEl.viewBox.baseVal.width === 200000);
+ ok(
+ true,
+ "Width of summary graph path should be 200000 after modifing the delay"
+ );
+ ok(
+ summaryGraphPathEl.parentElement.querySelector(".animation-delay-sign"),
+ "Delay sign element shoud exist"
+ );
+});
+
+function getAnimationNameCount(panel, animationName) {
+ return [...panel.querySelectorAll(".animation-name")].reduce(
+ (count, element) =>
+ element.textContent === animationName ? count + 1 : count,
+ 0
+ );
+}
+
+function getSummaryGraphPathElement(panel, animationName) {
+ for (const animationNameEl of panel.querySelectorAll(".animation-name")) {
+ if (animationNameEl.textContent === animationName) {
+ return animationNameEl
+ .closest(".animation-summary-graph")
+ .querySelector(".animation-summary-graph-path");
+ }
+ }
+
+ return null;
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_mutations_add_remove_immediately.js b/devtools/client/inspector/animation/test/browser_animation_logic_mutations_add_remove_immediately.js
new file mode 100644
index 0000000000..c57f3c7b3b
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_logic_mutations_add_remove_immediately.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether the animation inspector will not crash when add animation then remove
+// immediately.
+
+add_task(async function () {
+ const tab = await addTab(
+ URL_ROOT + "doc_mutations_add_remove_immediately.html"
+ );
+ const { inspector, panel } = await openAnimationInspector();
+
+ info("Check state of the animation inspector after fast mutations");
+ const onDispatch = waitForDispatch(inspector.store, "UPDATE_ANIMATIONS");
+ await startMutation(tab);
+ await onDispatch;
+ ok(
+ panel.querySelector(".animation-error-message"),
+ "No animations message should display"
+ );
+});
+
+async function startMutation(tab) {
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ await content.wrappedJSObject.startMutation();
+ });
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_mutations_fast.js b/devtools/client/inspector/animation/test/browser_animation_logic_mutations_fast.js
new file mode 100644
index 0000000000..516a150e42
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_logic_mutations_fast.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether the animation inspector will not crash when remove/add animations faster.
+
+add_task(async function () {
+ const tab = await addTab(URL_ROOT + "doc_mutations_fast.html");
+ const { inspector } = await openAnimationInspector();
+
+ info("Check state of the animation inspector after fast mutations");
+ await startFastMutations(tab);
+ ok(
+ inspector.panelWin.document.getElementById("animation-container"),
+ "Animation inspector should be live"
+ );
+});
+
+async function startFastMutations(tab) {
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ await content.wrappedJSObject.startFastMutations();
+ });
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_mutations_properties.js b/devtools/client/inspector/animation/test/browser_animation_logic_mutations_properties.js
new file mode 100644
index 0000000000..9ec3d58be9
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_logic_mutations_properties.js
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether animation was changed after altering following properties.
+// * delay
+// * direction
+// * duration
+// * easing (animationTimingFunction in case of CSS Animationns)
+// * fill
+// * iterations
+// * endDelay (script animation only)
+// * iterationStart (script animation only)
+// * playbackRate (script animation only)
+
+const SEC = 1000;
+const TEST_EFFECT_TIMING = {
+ delay: 20 * SEC,
+ direction: "reverse",
+ duration: 20 * SEC,
+ easing: "steps(1)",
+ endDelay: 20 * SEC,
+ fill: "backwards",
+ iterations: 20,
+ iterationStart: 20 * SEC,
+};
+const TEST_PLAYBACK_RATE = 0.1;
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".animated", ".end-delay"]);
+ const { animationInspector } = await openAnimationInspector();
+ await setCSSAnimationProperties(animationInspector);
+ await assertProperties(animationInspector.state.animations[0], false);
+ await setScriptAnimationProperties(animationInspector);
+ await assertProperties(animationInspector.state.animations[1], true);
+});
+
+async function setCSSAnimationProperties(animationInspector) {
+ const properties = {
+ animationDelay: `${TEST_EFFECT_TIMING.delay}ms`,
+ animationDirection: TEST_EFFECT_TIMING.direction,
+ animationDuration: `${TEST_EFFECT_TIMING.duration}ms`,
+ animationFillMode: TEST_EFFECT_TIMING.fill,
+ animationIterationCount: TEST_EFFECT_TIMING.iterations,
+ animationTimingFunction: TEST_EFFECT_TIMING.easing,
+ };
+
+ await setStyles(animationInspector, ".animated", properties);
+}
+
+async function setScriptAnimationProperties(animationInspector) {
+ await setEffectTimingAndPlayback(
+ animationInspector,
+ ".end-delay",
+ TEST_EFFECT_TIMING,
+ TEST_PLAYBACK_RATE
+ );
+}
+
+async function assertProperties(animation, isScriptAnimation) {
+ await waitUntil(() => animation.state.delay === TEST_EFFECT_TIMING.delay);
+ ok(true, `Delay should be ${TEST_EFFECT_TIMING.delay}`);
+
+ await waitUntil(
+ () => animation.state.direction === TEST_EFFECT_TIMING.direction
+ );
+ ok(true, `Direction should be ${TEST_EFFECT_TIMING.direction}`);
+
+ await waitUntil(
+ () => animation.state.duration === TEST_EFFECT_TIMING.duration
+ );
+ ok(true, `Duration should be ${TEST_EFFECT_TIMING.duration}`);
+
+ await waitUntil(() => animation.state.fill === TEST_EFFECT_TIMING.fill);
+ ok(true, `Fill should be ${TEST_EFFECT_TIMING.fill}`);
+
+ await waitUntil(
+ () => animation.state.iterationCount === TEST_EFFECT_TIMING.iterations
+ );
+ ok(true, `Iterations should be ${TEST_EFFECT_TIMING.iterations}`);
+
+ if (isScriptAnimation) {
+ await waitUntil(() => animation.state.easing === TEST_EFFECT_TIMING.easing);
+ ok(true, `Easing should be ${TEST_EFFECT_TIMING.easing}`);
+
+ await waitUntil(
+ () => animation.state.iterationStart === TEST_EFFECT_TIMING.iterationStart
+ );
+ ok(true, `IterationStart should be ${TEST_EFFECT_TIMING.iterationStart}`);
+
+ await waitUntil(() => animation.state.playbackRate === TEST_PLAYBACK_RATE);
+ ok(true, `PlaybackRate should be ${TEST_PLAYBACK_RATE}`);
+ } else {
+ await waitUntil(
+ () =>
+ animation.state.animationTimingFunction === TEST_EFFECT_TIMING.easing
+ );
+
+ ok(true, `AnimationTimingFunction should be ${TEST_EFFECT_TIMING.easing}`);
+ }
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_overflowed_delay_end-delay.js b/devtools/client/inspector/animation/test/browser_animation_logic_overflowed_delay_end-delay.js
new file mode 100644
index 0000000000..3d1c71b6c3
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_logic_overflowed_delay_end-delay.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that animations with an overflowed delay and end delay are not displayed.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_overflowed_delay_end_delay.html");
+ const { panel } = await openAnimationInspector();
+
+ info("Check the number of animation item");
+ const animationItemEls = panel.querySelectorAll(
+ ".animation-list .animation-item"
+ );
+ is(
+ animationItemEls.length,
+ 1,
+ "The number of animations displayed should be 1"
+ );
+
+ info("Check the id of animation displayed");
+ const animationNameEl = animationItemEls[0].querySelector(".animation-name");
+ is(
+ animationNameEl.textContent,
+ "big-iteration-start",
+ "The animation name should be 'big-iteration-start'"
+ );
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_scroll-amount.js b/devtools/client/inspector/animation/test/browser_animation_logic_scroll-amount.js
new file mode 100644
index 0000000000..11835cd880
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_logic_scroll-amount.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether the scroll amount of animation and animated property re-calculate after
+// changing selected node.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([
+ ".animated",
+ ".multi",
+ ".longhand",
+ ".negative-delay",
+ ]);
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+
+ info(
+ "Set the scroll amount of animation and animated property to the bottom"
+ );
+ const onDetailRendered = animationInspector.once(
+ "animation-keyframes-rendered"
+ );
+ await clickOnAnimationByTargetSelector(
+ animationInspector,
+ panel,
+ ".longhand"
+ );
+ await onDetailRendered;
+
+ await waitUntil(() => panel.querySelectorAll(".animation-item").length === 5);
+ const bottomAnimationEl = await findAnimationItemByIndex(panel, 4);
+ const bottomAnimatedPropertyEl = panel.querySelector(
+ ".animated-property-item:last-child"
+ );
+ bottomAnimationEl.scrollIntoView(false);
+ bottomAnimatedPropertyEl.scrollIntoView(false);
+
+ info("Hold the scroll amount");
+ const animationInspectionPanel = bottomAnimationEl.closest(
+ ".progress-inspection-panel"
+ );
+ const animatedPropertyInspectionPanel = bottomAnimatedPropertyEl.closest(
+ ".progress-inspection-panel"
+ );
+ const initialScrollTopOfAnimation = animationInspectionPanel.scrollTop;
+ const initialScrollTopOfAnimatedProperty =
+ animatedPropertyInspectionPanel.scrollTop;
+
+ info(
+ "Check whether the scroll amount re-calculate after changing the count of items"
+ );
+ await selectNode(".negative-delay", inspector);
+ await waitUntil(
+ () =>
+ initialScrollTopOfAnimation > animationInspectionPanel.scrollTop &&
+ initialScrollTopOfAnimatedProperty >
+ animatedPropertyInspectionPanel.scrollTop
+ );
+ ok(
+ true,
+ "Scroll amount for animation list should be less than previous state"
+ );
+ ok(
+ true,
+ "Scroll amount for animated property list should be less than previous state"
+ );
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_pause-resume-button.js b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button.js
new file mode 100644
index 0000000000..8a8d9f848b
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following PauseResumeButton component:
+// * element existence
+// * state during running animations
+// * state during pausing animations
+// * make animations to pause by push button
+// * make animations to resume by push button
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_custom_playback_rate.html");
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Checking pause/resume button existence");
+ const buttonEl = panel.querySelector(".pause-resume-button");
+ ok(buttonEl, "pause/resume button should exist");
+
+ info("Checking state during running animations");
+ ok(
+ !buttonEl.classList.contains("paused"),
+ "State of button should be running"
+ );
+
+ info("Checking button makes animations to pause");
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ ok(true, "All of animtion are paused");
+ ok(buttonEl.classList.contains("paused"), "State of button should be paused");
+
+ info("Checking button makes animations to resume");
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntilAnimationsPlayState(animationInspector, "running");
+ ok(true, "All of animtion are running");
+ ok(
+ !buttonEl.classList.contains("paused"),
+ "State of button should be resumed"
+ );
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_end-time.js b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_end-time.js
new file mode 100644
index 0000000000..896ae5d2ef
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_end-time.js
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether the animation can rewind if the current time is over end time when
+// the resume button clicked.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([
+ ".animated",
+ ".end-delay",
+ ".long",
+ ".negative-delay",
+ ]);
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Check animations state after resuming with infinite animation");
+ info("Make the current time of animation to be over its end time");
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 1);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ info("Resume animations");
+ clickOnPauseResumeButton(animationInspector, panel);
+ await wait(1000);
+ assertPlayState(animationInspector.state.animations, [
+ "running",
+ "finished",
+ "finished",
+ "finished",
+ ]);
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+
+ info("Check animations state after resuming without infinite animation");
+ info("Remove infinite animation");
+ await setClassAttribute(animationInspector, ".animated", "ball still");
+ await waitUntil(() => panel.querySelectorAll(".animation-item").length === 3);
+
+ info("Make the current time of animation to be over its end time");
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 1.1);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ await changePlaybackRateSelector(animationInspector, panel, 0.1);
+ info("Resume animations");
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntilAnimationsPlayState(animationInspector, "running");
+ assertCurrentTimeLessThanDuration(animationInspector.state.animations);
+ assertScrubberPosition(panel);
+});
+
+function assertPlayState(animations, expectedState) {
+ animations.forEach((animation, index) => {
+ is(
+ animation.state.playState,
+ expectedState[index],
+ `The playState of animation [${index}] should be ${expectedState[index]}`
+ );
+ });
+}
+
+function assertCurrentTimeLessThanDuration(animations) {
+ animations.forEach((animation, index) => {
+ Assert.less(
+ animation.state.currentTime,
+ animation.state.duration,
+ `The current time of animation[${index}] should be less than its duration`
+ );
+ });
+}
+
+function assertScrubberPosition(panel) {
+ const scrubberEl = panel.querySelector(".current-time-scrubber");
+ const marginInlineStart = parseFloat(scrubberEl.style.marginInlineStart);
+ Assert.greaterOrEqual(
+ marginInlineStart,
+ 0,
+ "The translateX of scrubber position should be zero or more"
+ );
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_respectively.js b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_respectively.js
new file mode 100644
index 0000000000..ad84e4c257
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_respectively.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether pausing/resuming the each animations correctly.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".animated", ".compositor-all"]);
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+ const buttonEl = panel.querySelector(".pause-resume-button");
+
+ info(
+ "Check '.compositor-all' animation is still running " +
+ "after even pausing '.animated' animation"
+ );
+ await selectNode(".animated", inspector);
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ ok(buttonEl.classList.contains("paused"), "State of button should be paused");
+ await selectNode("body", inspector);
+ await assertStatus(
+ animationInspector.state.animations,
+ buttonEl,
+ ["paused", "running"],
+ false
+ );
+
+ info(
+ "Check both animations are paused after clicking pause/resume " +
+ "while displaying both animations"
+ );
+ clickOnPauseResumeButton(animationInspector, panel);
+ await assertStatus(
+ animationInspector.state.animations,
+ buttonEl,
+ ["paused", "paused"],
+ true
+ );
+
+ info(
+ "Check '.animated' animation is still paused " +
+ "after even resuming '.compositor-all' animation"
+ );
+ await selectNode(".compositor-all", inspector);
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntil(() =>
+ animationInspector.state.animations.some(
+ a => a.state.playState === "running"
+ )
+ );
+ ok(
+ !buttonEl.classList.contains("paused"),
+ "State of button should be running"
+ );
+ await selectNode("body", inspector);
+ await assertStatus(
+ animationInspector.state.animations,
+ buttonEl,
+ ["paused", "running"],
+ false
+ );
+});
+
+async function assertStatus(
+ animations,
+ buttonEl,
+ expectedAnimationStates,
+ shouldButtonPaused
+) {
+ await waitUntil(() => {
+ for (let i = 0; i < expectedAnimationStates.length; i++) {
+ const animation = animations[i];
+ const state = expectedAnimationStates[i];
+ if (animation.state.playState !== state) {
+ return false;
+ }
+ }
+ return true;
+ });
+ expectedAnimationStates.forEach((state, index) => {
+ is(
+ animations[index].state.playState,
+ state,
+ `Animation ${index} should be ${state}`
+ );
+ });
+
+ is(
+ buttonEl.classList.contains("paused"),
+ shouldButtonPaused,
+ "State of button is correct"
+ );
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_spacebar.js b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_spacebar.js
new file mode 100644
index 0000000000..7a27b1bd07
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_spacebar.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following PauseResumeButton component with spacebar:
+// * make animations to pause/resume by spacebar
+// * combination with other UI components
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_custom_playback_rate.html");
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Checking spacebar makes animations to pause");
+ await testPauseAndResumeBySpacebar(animationInspector, panel);
+
+ info(
+ "Checking spacebar makes animations to pause when the button has the focus"
+ );
+ const pauseResumeButton = panel.querySelector(".pause-resume-button");
+ await testPauseAndResumeBySpacebar(animationInspector, pauseResumeButton);
+
+ info("Checking spacebar works with other UI components");
+ // To pause
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ // To resume
+ sendSpaceKeyEvent(animationInspector, panel);
+ await waitUntilAnimationsPlayState(animationInspector, "running");
+ // To pause
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ // To resume
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntilAnimationsPlayState(animationInspector, "running");
+ // To pause
+ sendSpaceKeyEvent(animationInspector, panel);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ ok(true, "All components that can make animations pause/resume works fine");
+});
+
+async function testPauseAndResumeBySpacebar(animationInspector, element) {
+ await sendSpaceKeyEvent(animationInspector, element);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ ok(true, "Space key can pause animations");
+ await sendSpaceKeyEvent(animationInspector, element);
+ await waitUntilAnimationsPlayState(animationInspector, "running");
+ ok(true, "Space key can resume animations");
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_playback-rate-selector.js b/devtools/client/inspector/animation/test/browser_animation_playback-rate-selector.js
new file mode 100644
index 0000000000..8552eae138
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_playback-rate-selector.js
@@ -0,0 +1,81 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following PlaybackRateSelector component:
+// * element existence
+// * make playback rate of animations by the selector
+// * in case of animations have mixed playback rate
+// * in case of animations have playback rate which is not default selectable value
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_custom_playback_rate.html");
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+
+ info("Checking playback rate selector existence");
+ const selectEl = panel.querySelector(".playback-rate-selector");
+ ok(selectEl, "scrubber controller should exist");
+
+ info(
+ "Checking playback rate existence which includes custom rate of animations"
+ );
+ const expectedPlaybackRates = [0.1, 0.25, 0.5, 1, 1.5, 2, 5, 10];
+ await assertPlaybackRateOptions(selectEl, expectedPlaybackRates);
+
+ info("Checking selected playback rate");
+ is(Number(selectEl.value), 1.5, "Selected option should be 1.5");
+
+ info("Checking playback rate of animations");
+ await changePlaybackRateSelector(animationInspector, panel, 0.5);
+ await assertPlaybackRate(animationInspector, 0.5);
+
+ info("Checking mixed playback rate");
+ await selectNode("div", inspector);
+ await waitUntil(() => panel.querySelectorAll(".animation-item").length === 1);
+ await changePlaybackRateSelector(animationInspector, panel, 2);
+ await assertPlaybackRate(animationInspector, 2);
+ await selectNode("body", inspector);
+ await waitUntil(() => panel.querySelectorAll(".animation-item").length === 2);
+ await waitUntil(() => selectEl.value === "");
+ ok(true, "Selected option should be empty");
+
+ info("Checking playback rate after re-setting");
+ await changePlaybackRateSelector(animationInspector, panel, 1);
+ await assertPlaybackRate(animationInspector, 1);
+
+ info(
+ "Checking whether custom playback rate exist " +
+ "after selecting another playback rate"
+ );
+ await assertPlaybackRateOptions(selectEl, expectedPlaybackRates);
+});
+
+async function assertPlaybackRate(animationInspector, rate) {
+ await waitUntil(() =>
+ animationInspector.state?.animations.every(
+ ({ state }) => state.playbackRate === rate
+ )
+ );
+ ok(true, `Playback rate of animations should be ${rate}`);
+}
+
+async function assertPlaybackRateOptions(selectEl, expectedPlaybackRates) {
+ await waitUntil(() => {
+ if (selectEl.options.length !== expectedPlaybackRates.length) {
+ return false;
+ }
+
+ for (let i = 0; i < selectEl.options.length; i++) {
+ const optionEl = selectEl.options[i];
+ const expectedPlaybackRate = expectedPlaybackRates[i];
+ if (Number(optionEl.value) !== expectedPlaybackRate) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+ ok(true, "Content of playback rate options are correct");
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_pseudo-element.js b/devtools/client/inspector/animation/test/browser_animation_pseudo-element.js
new file mode 100644
index 0000000000..00e267f9f8
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_pseudo-element.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for pseudo element.
+
+const TEST_DATA = [
+ {
+ expectedTargetLabel: "::before",
+ expectedAnimationNameLabel: "body",
+ expectedKeyframsGraphPathSegments: [
+ { x: 0, y: 0 },
+ { x: 1000, y: 100 },
+ ],
+ },
+ {
+ expectedTargetLabel: "::before",
+ expectedAnimationNameLabel: "div-before",
+ expectedKeyframsGraphPathSegments: [
+ { x: 0, y: 100 },
+ { x: 1000, y: 0 },
+ ],
+ },
+ {
+ expectedTargetLabel: "::after",
+ expectedAnimationNameLabel: "div-after",
+ },
+ {
+ expectedTargetLabel: "::marker",
+ expectedAnimationNameLabel: "div-marker",
+ },
+];
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_pseudo.html");
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+
+ info("Checking count of animation item for pseudo elements");
+ is(
+ panel.querySelectorAll(".animation-list .animation-item").length,
+ TEST_DATA.length,
+ `Count of animation item should be ${TEST_DATA.length}`
+ );
+
+ info("Checking content of each animation item");
+ for (let i = 0; i < TEST_DATA.length; i++) {
+ const testData = TEST_DATA[i];
+ info(`Checking pseudo element for ${testData.expectedTargetLabel}`);
+ const animationItemEl = await findAnimationItemByIndex(panel, i);
+
+ info("Checking text content of animation target");
+ const animationTargetEl = animationItemEl.querySelector(
+ ".animation-list .animation-item .animation-target"
+ );
+ is(
+ animationTargetEl.textContent,
+ testData.expectedTargetLabel,
+ `Text content of animation target[${i}] should be ${testData.expectedTarget}`
+ );
+
+ info("Checking text content of animation name");
+ const animationNameEl = animationItemEl.querySelector(".animation-name");
+ is(
+ animationNameEl.textContent,
+ testData.expectedAnimationNameLabel,
+ `The animation name should be ${testData.expectedAnimationNameLabel}`
+ );
+ }
+
+ info(
+ "Checking whether node is selected correctly " +
+ "when click on the first inspector icon on Reps component"
+ );
+ let onDetailRendered = animationInspector.once(
+ "animation-keyframes-rendered"
+ );
+ await clickOnTargetNode(animationInspector, panel, 0);
+ await onDetailRendered;
+ assertAnimationCount(panel, 1);
+ assertAnimationNameLabel(panel, TEST_DATA[0].expectedAnimationNameLabel);
+ assertKeyframesGraphPathSegments(
+ panel,
+ TEST_DATA[0].expectedKeyframsGraphPathSegments
+ );
+
+ info("Select <body> again to reset the animation list");
+ await selectNode("body", inspector);
+
+ info(
+ "Checking whether node is selected correctly " +
+ "when click on the second inspector icon on Reps component"
+ );
+ onDetailRendered = animationInspector.once("animation-keyframes-rendered");
+ await clickOnTargetNode(animationInspector, panel, 1);
+ await onDetailRendered;
+ assertAnimationCount(panel, 1);
+ assertAnimationNameLabel(panel, TEST_DATA[1].expectedAnimationNameLabel);
+ assertKeyframesGraphPathSegments(
+ panel,
+ TEST_DATA[1].expectedKeyframsGraphPathSegments
+ );
+});
+
+function assertAnimationCount(panel, expectedCount) {
+ info("Checking count of animation item");
+ is(
+ panel.querySelectorAll(".animation-list .animation-item").length,
+ expectedCount,
+ `Count of animation item should be ${expectedCount}`
+ );
+}
+
+function assertAnimationNameLabel(panel, expectedAnimationNameLabel) {
+ info("Checking the animation name label");
+ is(
+ panel.querySelector(".animation-list .animation-item .animation-name")
+ .textContent,
+ expectedAnimationNameLabel,
+ `The animation name should be ${expectedAnimationNameLabel}`
+ );
+}
+
+function assertKeyframesGraphPathSegments(panel, expectedPathSegments) {
+ info("Checking the keyframes graph path segments");
+ const pathEl = panel.querySelector(".keyframes-graph-path path");
+ assertPathSegments(pathEl, true, expectedPathSegments);
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_rewind-button.js b/devtools/client/inspector/animation/test/browser_animation_rewind-button.js
new file mode 100644
index 0000000000..f74518095b
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_rewind-button.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+// Test for following RewindButton component:
+// * element existence
+// * make animations to rewind to zero
+// * the state should be always paused after rewinding
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_multi_timings.html");
+ await removeAnimatedElementsExcept([".delay-negative", ".delay-positive"]);
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Checking button existence");
+ ok(panel.querySelector(".rewind-button"), "Rewind button should exist");
+
+ info("Checking rewind button makes animations to rewind to zero");
+ clickOnRewindButton(animationInspector, panel);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ await waitUntilCurrentTimeChangedAt(animationInspector, 0);
+ ok(true, "Rewind button make current time 0");
+
+ info("Checking rewind button makes animations after clicking scrubber");
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5);
+ clickOnRewindButton(animationInspector, panel);
+ await waitUntilCurrentTimeChangedAt(animationInspector, 0);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ ok(true, "Rewind button make current time 0 even after clicking scrubber");
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_short-duration.js b/devtools/client/inspector/animation/test/browser_animation_short-duration.js
new file mode 100644
index 0000000000..c953d886ff
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_short-duration.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test tooltips and iteration path of summary graph with short duration animation.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_short_duration.html");
+ const { panel } = await openAnimationInspector();
+
+ const animationItemEl = await findAnimationItemByTargetSelector(
+ panel,
+ ".short"
+ );
+ const summaryGraphEl = animationItemEl.querySelector(
+ ".animation-summary-graph"
+ );
+
+ info("Check tooltip");
+ assertTooltip(summaryGraphEl);
+
+ info("Check iteration path");
+ assertIterationPath(summaryGraphEl);
+});
+
+function assertTooltip(summaryGraphEl) {
+ const tooltip = summaryGraphEl.getAttribute("title");
+ const expected = "Duration: 0s";
+ ok(tooltip.includes(expected), `Tooltip should include '${expected}'`);
+}
+
+function assertIterationPath(summaryGraphEl) {
+ const pathEl = summaryGraphEl.querySelector(
+ ".animation-computed-timing-path .animation-iteration-path"
+ );
+ const expected = [
+ { x: 0, y: 0 },
+ { x: 0.999, y: 99.9 },
+ { x: 1, y: 0 },
+ ];
+ assertPathSegments(pathEl, true, expected);
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_animation-name.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_animation-name.js
new file mode 100644
index 0000000000..0e9c52449d
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_animation-name.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following AnimationName component works.
+// * element existance
+// * name text
+
+const TEST_DATA = [
+ {
+ targetClass: "cssanimation-normal",
+ expectedLabel: "cssanimation",
+ },
+ {
+ targetClass: "cssanimation-linear",
+ expectedLabel: "cssanimation",
+ },
+ {
+ targetClass: "delay-positive",
+ expectedLabel: "test-delay-animation",
+ },
+ {
+ targetClass: "delay-negative",
+ expectedLabel: "test-negative-delay-animation",
+ },
+ {
+ targetClass: "easing-step",
+ },
+];
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_multi_timings.html");
+ await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`));
+ const { panel } = await openAnimationInspector();
+
+ for (const { targetClass, expectedLabel } of TEST_DATA) {
+ const animationItemEl = await findAnimationItemByTargetSelector(
+ panel,
+ `.${targetClass}`
+ );
+
+ info(`Checking animation name element existance for ${targetClass}`);
+ const animationNameEl = animationItemEl.querySelector(".animation-name");
+
+ if (expectedLabel) {
+ ok(
+ animationNameEl,
+ "The animation name element should be in animation item element"
+ );
+ is(
+ animationNameEl.textContent,
+ expectedLabel,
+ `The animation name should be ${expectedLabel}`
+ );
+ } else {
+ ok(
+ !animationNameEl,
+ "The animation name element should not be in animation item element"
+ );
+ }
+ }
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_compositor.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_compositor.js
new file mode 100644
index 0000000000..186c54cba6
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_compositor.js
@@ -0,0 +1,121 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that when animations displayed in the timeline are running on the
+// compositor, they get a special icon and information in the tooltip.
+
+requestLongerTimeout(2);
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([
+ ".compositor-all",
+ ".compositor-notall",
+ ".no-compositor",
+ ]);
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+
+ info("Check animation whose all properties are running on compositor");
+ const summaryGraphAllEl = await findSummaryGraph(".compositor-all", panel);
+ ok(
+ summaryGraphAllEl.classList.contains("compositor"),
+ "The element has the compositor css class"
+ );
+ ok(
+ hasTooltip(
+ summaryGraphAllEl,
+ ANIMATION_L10N.getStr("player.allPropertiesOnCompositorTooltip")
+ ),
+ "The element has the right tooltip content"
+ );
+
+ info("Check animation is not running on compositor");
+ const summaryGraphNoEl = await findSummaryGraph(".no-compositor", panel);
+ ok(
+ !summaryGraphNoEl.classList.contains("compositor"),
+ "The element does not have the compositor css class"
+ );
+ ok(
+ !hasTooltip(
+ summaryGraphNoEl,
+ ANIMATION_L10N.getStr("player.allPropertiesOnCompositorTooltip")
+ ),
+ "The element does not have oncompositor tooltip content"
+ );
+ ok(
+ !hasTooltip(
+ summaryGraphNoEl,
+ ANIMATION_L10N.getStr("player.somePropertiesOnCompositorTooltip")
+ ),
+ "The element does not have oncompositor tooltip content"
+ );
+
+ info(
+ "Select a node has animation whose some properties are running on compositor"
+ );
+ await selectNode(".compositor-notall", inspector);
+ const summaryGraphEl = await findSummaryGraph(".compositor-notall", panel);
+ ok(
+ summaryGraphEl.classList.contains("compositor"),
+ "The element has the compositor css class"
+ );
+ ok(
+ hasTooltip(
+ summaryGraphEl,
+ ANIMATION_L10N.getStr("player.somePropertiesOnCompositorTooltip")
+ ),
+ "The element has the right tooltip content"
+ );
+
+ info("Check compositor sign after pausing");
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntil(() => !summaryGraphEl.classList.contains("compositor"));
+ ok(
+ true,
+ "The element should not have the compositor css class after pausing"
+ );
+
+ info("Check compositor sign after resuming");
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntil(() => summaryGraphEl.classList.contains("compositor"));
+ ok(true, "The element should have the compositor css class after resuming");
+
+ info("Check compositor sign after rewind");
+ clickOnRewindButton(animationInspector, panel);
+ await waitUntil(() => !summaryGraphEl.classList.contains("compositor"));
+ ok(
+ true,
+ "The element should not have the compositor css class after rewinding"
+ );
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntil(() => summaryGraphEl.classList.contains("compositor"));
+ ok(true, "The element should have the compositor css class after resuming");
+
+ info("Check compositor sign after setting the current time");
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5);
+ await waitUntil(() => !summaryGraphEl.classList.contains("compositor"));
+ ok(
+ true,
+ "The element should not have the compositor css class " +
+ "after setting the current time"
+ );
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntil(() => summaryGraphEl.classList.contains("compositor"));
+ ok(true, "The element should have the compositor css class after resuming");
+});
+
+async function findSummaryGraph(selector, panel) {
+ const animationItemEl = await findAnimationItemByTargetSelector(
+ panel,
+ selector
+ );
+ return animationItemEl.querySelector(".animation-summary-graph");
+}
+
+function hasTooltip(summaryGraphEl, expected) {
+ const tooltip = summaryGraphEl.getAttribute("title");
+ return tooltip.includes(expected);
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_1.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_1.js
new file mode 100644
index 0000000000..a81b971559
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_1.js
@@ -0,0 +1,208 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following ComputedTimingPath component works.
+// * element existance
+// * iterations: path, count
+// * delay: path
+// * fill: path
+// * endDelay: path
+
+/* import-globals-from summary-graph_computed-timing-path_head.js */
+Services.scriptloader.loadSubScript(
+ CHROME_URL_ROOT + "summary-graph_computed-timing-path_head.js",
+ this
+);
+
+const TEST_DATA = [
+ {
+ targetClass: "cssanimation-normal",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 250000, y: 40.851 },
+ { x: 500000, y: 80.24 },
+ { x: 750000, y: 96.05 },
+ { x: 1000000, y: 100 },
+ { x: 1000000, y: 0 },
+ ],
+ ],
+ },
+ {
+ targetClass: "cssanimation-linear",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 250000, y: 25 },
+ { x: 500000, y: 50 },
+ { x: 750000, y: 75 },
+ { x: 1000000, y: 100 },
+ { x: 1000000, y: 0 },
+ ],
+ ],
+ },
+ {
+ targetClass: "delay-positive",
+ expectedDelayPath: [
+ { x: 0, y: 0 },
+ { x: 500000, y: 0 },
+ ],
+ expectedIterationPathList: [
+ [
+ { x: 500000, y: 0 },
+ { x: 750000, y: 25 },
+ { x: 1000000, y: 50 },
+ { x: 1250000, y: 75 },
+ { x: 1500000, y: 100 },
+ { x: 1500000, y: 0 },
+ ],
+ ],
+ },
+ {
+ targetClass: "easing-step",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 499999, y: 0 },
+ { x: 500000, y: 50 },
+ { x: 999999, y: 50 },
+ { x: 1000000, y: 0 },
+ ],
+ ],
+ },
+ {
+ targetClass: "enddelay-positive",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 250000, y: 25 },
+ { x: 500000, y: 50 },
+ { x: 750000, y: 75 },
+ { x: 1000000, y: 100 },
+ { x: 1000000, y: 0 },
+ ],
+ ],
+ expectedEndDelayPath: [
+ { x: 1000000, y: 0 },
+ { x: 1500000, y: 0 },
+ ],
+ },
+ {
+ targetClass: "enddelay-negative",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 250000, y: 25 },
+ { x: 500000, y: 50 },
+ { x: 500000, y: 0 },
+ ],
+ ],
+ },
+ {
+ targetClass: "enddelay-with-fill-forwards",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 250000, y: 25 },
+ { x: 500000, y: 50 },
+ { x: 750000, y: 75 },
+ { x: 1000000, y: 100 },
+ { x: 1000000, y: 0 },
+ ],
+ ],
+ expectedEndDelayPath: [
+ { x: 1000000, y: 0 },
+ { x: 1000000, y: 100 },
+ { x: 1500000, y: 100 },
+ { x: 1500000, y: 0 },
+ ],
+ expectedForwardsPath: [
+ { x: 1500000, y: 0 },
+ { x: 1500000, y: 100 },
+ ],
+ },
+ {
+ targetClass: "enddelay-with-iterations-infinity",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 250000, y: 25 },
+ { x: 500000, y: 50 },
+ { x: 750000, y: 75 },
+ { x: 1000000, y: 100 },
+ { x: 1000000, y: 0 },
+ ],
+ [
+ { x: 1000000, y: 0 },
+ { x: 1250000, y: 25 },
+ { x: 1500000, y: 50 },
+ ],
+ ],
+ isInfinity: true,
+ },
+ {
+ targetClass: "direction-alternate-with-iterations-infinity",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 250000, y: 25 },
+ { x: 500000, y: 50 },
+ { x: 750000, y: 75 },
+ { x: 1000000, y: 100 },
+ { x: 1000000, y: 0 },
+ ],
+ [
+ { x: 1000000, y: 0 },
+ { x: 1000000, y: 100 },
+ { x: 1250000, y: 75 },
+ { x: 1500000, y: 50 },
+ ],
+ ],
+ isInfinity: true,
+ },
+ {
+ targetClass: "direction-alternate-reverse-with-iterations-infinity",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 0, y: 100 },
+ { x: 250000, y: 75 },
+ { x: 500000, y: 50 },
+ { x: 750000, y: 25 },
+ { x: 1000000, y: 0 },
+ ],
+ [
+ { x: 1000000, y: 0 },
+ { x: 1250000, y: 25 },
+ { x: 1500000, y: 50 },
+ ],
+ ],
+ isInfinity: true,
+ },
+ {
+ targetClass: "direction-reverse-with-iterations-infinity",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 0, y: 100 },
+ { x: 250000, y: 75 },
+ { x: 500000, y: 50 },
+ { x: 750000, y: 25 },
+ { x: 1000000, y: 0 },
+ ],
+ [
+ { x: 1000000, y: 0 },
+ { x: 1000000, y: 100 },
+ { x: 1250000, y: 75 },
+ { x: 1500000, y: 50 },
+ ],
+ ],
+ isInfinity: true,
+ },
+];
+
+add_task(async function () {
+ await testComputedTimingPath(TEST_DATA);
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_2.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_2.js
new file mode 100644
index 0000000000..e1e4c52ba6
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_2.js
@@ -0,0 +1,192 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following ComputedTimingPath component works.
+// * element existance
+// * iterations: path, count
+// * delay: path
+// * fill: path
+// * endDelay: path
+
+/* import-globals-from summary-graph_computed-timing-path_head.js */
+Services.scriptloader.loadSubScript(
+ CHROME_URL_ROOT + "summary-graph_computed-timing-path_head.js",
+ this
+);
+
+const TEST_DATA = [
+ {
+ targetClass: "fill-backwards",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 250000, y: 25 },
+ { x: 500000, y: 50 },
+ { x: 750000, y: 75 },
+ { x: 1000000, y: 100 },
+ { x: 1000000, y: 0 },
+ ],
+ ],
+ },
+ {
+ targetClass: "fill-backwards-with-delay-iterationstart",
+ expectedDelayPath: [
+ { x: 0, y: 0 },
+ { x: 0, y: 50 },
+ { x: 500000, y: 50 },
+ { x: 500000, y: 0 },
+ ],
+ expectedIterationPathList: [
+ [
+ { x: 500000, y: 50 },
+ { x: 750000, y: 75 },
+ { x: 1000000, y: 100 },
+ { x: 1000000, y: 0 },
+ ],
+ [
+ { x: 1000000, y: 0 },
+ { x: 1250000, y: 25 },
+ { x: 1500000, y: 50 },
+ { x: 1500000, y: 0 },
+ ],
+ ],
+ },
+ {
+ targetClass: "fill-both",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 250000, y: 25 },
+ { x: 500000, y: 50 },
+ { x: 750000, y: 75 },
+ { x: 1000000, y: 100 },
+ { x: 1000000, y: 0 },
+ ],
+ ],
+ expectedForwardsPath: [
+ { x: 1000000, y: 0 },
+ { x: 1000000, y: 100 },
+ { x: 1500000, y: 100 },
+ { x: 1500000, y: 0 },
+ ],
+ },
+ {
+ targetClass: "fill-both-width-delay-iterationstart",
+ expectedDelayPath: [
+ { x: 0, y: 0 },
+ { x: 0, y: 50 },
+ { x: 500000, y: 50 },
+ { x: 500000, y: 0 },
+ ],
+ expectedIterationPathList: [
+ [
+ { x: 500000, y: 50 },
+ { x: 750000, y: 75 },
+ { x: 1000000, y: 100 },
+ { x: 1000000, y: 0 },
+ ],
+ [
+ { x: 1000000, y: 0 },
+ { x: 1250000, y: 25 },
+ { x: 1500000, y: 50 },
+ { x: 1500000, y: 0 },
+ ],
+ ],
+ expectedForwardsPath: [
+ { x: 1500000, y: 0 },
+ { x: 1500000, y: 50 },
+ ],
+ },
+ {
+ targetClass: "fill-forwards",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 250000, y: 25 },
+ { x: 500000, y: 50 },
+ { x: 750000, y: 75 },
+ { x: 1000000, y: 100 },
+ { x: 1000000, y: 0 },
+ ],
+ ],
+ expectedForwardsPath: [
+ { x: 1000000, y: 0 },
+ { x: 1000000, y: 100 },
+ { x: 1500000, y: 100 },
+ { x: 1500000, y: 0 },
+ ],
+ },
+ {
+ targetClass: "iterationstart",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 50 },
+ { x: 250000, y: 75 },
+ { x: 500000, y: 100 },
+ { x: 500000, y: 0 },
+ ],
+ [
+ { x: 500000, y: 0 },
+ { x: 750000, y: 25 },
+ { x: 1000000, y: 50 },
+ { x: 1000000, y: 0 },
+ ],
+ ],
+ },
+ {
+ targetClass: "no-compositor",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 250000, y: 25 },
+ { x: 500000, y: 50 },
+ { x: 750000, y: 75 },
+ { x: 1000000, y: 100 },
+ { x: 1000000, y: 0 },
+ ],
+ ],
+ },
+ {
+ targetClass: "keyframes-easing-step",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 499999, y: 0 },
+ { x: 500000, y: 50 },
+ { x: 999999, y: 50 },
+ { x: 1000000, y: 0 },
+ ],
+ ],
+ },
+ {
+ targetClass: "narrow-keyframes",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 100000, y: 10 },
+ { x: 110000, y: 10 },
+ { x: 115000, y: 10 },
+ { x: 129999, y: 10 },
+ { x: 130000, y: 13 },
+ { x: 135000, y: 13.5 },
+ ],
+ ],
+ },
+ {
+ targetClass: "duplicate-offsets",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 250000, y: 25 },
+ { x: 500000, y: 50 },
+ { x: 999999, y: 50 },
+ ],
+ ],
+ },
+];
+
+add_task(async function () {
+ await testComputedTimingPath(TEST_DATA);
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_different-timescale.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_different-timescale.js
new file mode 100644
index 0000000000..0b9bc79def
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_different-timescale.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the Computed Timing Path component for different time scales.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".animated", ".end-delay"]);
+ const { animationInspector, inspector, panel } =
+ await openAnimationInspector();
+
+ info("Checking the path for different time scale");
+ let onDetailRendered = animationInspector.once(
+ "animation-keyframes-rendered"
+ );
+ await selectNode(".animated", inspector);
+ await onDetailRendered;
+ const itemA = await findAnimationItemByTargetSelector(panel, ".animated");
+ const pathStringA = itemA
+ .querySelector(".animation-iteration-path")
+ .getAttribute("d");
+
+ info("Select animation which has different time scale from no-compositor");
+ onDetailRendered = animationInspector.once("animation-keyframes-rendered");
+ await selectNode(".end-delay", inspector);
+ await onDetailRendered;
+
+ info("Select no-compositor again");
+ onDetailRendered = animationInspector.once("animation-keyframes-rendered");
+ await selectNode(".animated", inspector);
+ await onDetailRendered;
+ const itemB = await findAnimationItemByTargetSelector(panel, ".animated");
+ const pathStringB = itemB
+ .querySelector(".animation-iteration-path")
+ .getAttribute("d");
+ is(
+ pathStringA,
+ pathStringB,
+ "Path string should be same even change the time scale"
+ );
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_delay-sign-rtl.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_delay-sign-rtl.js
new file mode 100644
index 0000000000..591fc5f3fe
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_delay-sign-rtl.js
@@ -0,0 +1,14 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function () {
+ Services.scriptloader.loadSubScript(
+ CHROME_URL_ROOT + "summary-graph_delay-sign_head.js",
+ this
+ );
+ await pushPref("intl.l10n.pseudo", "bidi");
+ // eslint-disable-next-line no-undef
+ await testSummaryGraphDelaySign();
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_delay-sign.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_delay-sign.js
new file mode 100644
index 0000000000..891d9fd90e
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_delay-sign.js
@@ -0,0 +1,13 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function () {
+ Services.scriptloader.loadSubScript(
+ CHROME_URL_ROOT + "summary-graph_delay-sign_head.js",
+ this
+ );
+ // eslint-disable-next-line no-undef
+ await testSummaryGraphDelaySign();
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_effect-timing-path.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_effect-timing-path.js
new file mode 100644
index 0000000000..6974eab6c6
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_effect-timing-path.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following EffectTimingPath component works.
+// * element existance
+// * path
+
+const TEST_DATA = [
+ {
+ targetClass: "cssanimation-linear",
+ },
+ {
+ targetClass: "delay-negative",
+ },
+ {
+ targetClass: "easing-step",
+ expectedPath: [
+ { x: 0, y: 0 },
+ { x: 499999, y: 0 },
+ { x: 500000, y: 50 },
+ { x: 999999, y: 50 },
+ { x: 1000000, y: 0 },
+ ],
+ },
+ {
+ targetClass: "keyframes-easing-step",
+ },
+];
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_multi_timings.html");
+ await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`));
+ const { panel } = await openAnimationInspector();
+
+ for (const { targetClass, expectedPath } of TEST_DATA) {
+ const animationItemEl = await findAnimationItemByTargetSelector(
+ panel,
+ `.${targetClass}`
+ );
+
+ info(`Checking effect timing path existance for ${targetClass}`);
+ const effectTimingPathEl = animationItemEl.querySelector(
+ ".animation-effect-timing-path"
+ );
+
+ if (expectedPath) {
+ ok(
+ effectTimingPathEl,
+ "The effect timing path element should be in animation item element"
+ );
+ const pathEl = effectTimingPathEl.querySelector(
+ ".animation-iteration-path"
+ );
+ assertPathSegments(pathEl, false, expectedPath);
+ } else {
+ ok(
+ !effectTimingPathEl,
+ "The effect timing path element should not be in animation item element"
+ );
+ }
+ }
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_end-delay-sign-rtl.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_end-delay-sign-rtl.js
new file mode 100644
index 0000000000..084e4acf1d
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_end-delay-sign-rtl.js
@@ -0,0 +1,15 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* import-globals-from summary-graph_end-delay-sign_head.js */
+
+add_task(async function () {
+ Services.scriptloader.loadSubScript(
+ CHROME_URL_ROOT + "summary-graph_end-delay-sign_head.js",
+ this
+ );
+ await pushPref("intl.l10n.pseudo", "bidi");
+ await testSummaryGraphEndDelaySign();
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_end-delay-sign.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_end-delay-sign.js
new file mode 100644
index 0000000000..4382ed4c2d
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_end-delay-sign.js
@@ -0,0 +1,13 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function () {
+ Services.scriptloader.loadSubScript(
+ CHROME_URL_ROOT + "summary-graph_end-delay-sign_head.js",
+ this
+ );
+ // eslint-disable-next-line no-undef
+ await testSummaryGraphEndDelaySign();
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_layout-by-seek.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_layout-by-seek.js
new file mode 100644
index 0000000000..5f1c808728
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_layout-by-seek.js
@@ -0,0 +1,150 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether the layout of graphs were broken by seek and resume.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_multi_timings.html");
+ await removeAnimatedElementsExcept([
+ ".delay-positive",
+ ".delay-negative",
+ ".enddelay-positive",
+ ".enddelay-negative",
+ ]);
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Get initial coordinates result as test data");
+ const initialCoordinatesResult = [];
+
+ for (let i = 0; i < animationInspector.state.animations.length; i++) {
+ const itemEl = await findAnimationItemByIndex(panel, i);
+ const svgEl = itemEl.querySelector("svg");
+ const svgViewBoxX = svgEl.viewBox.baseVal.x;
+ const svgViewBoxWidth = svgEl.viewBox.baseVal.width;
+
+ const pathEl = svgEl.querySelector(".animation-computed-timing-path");
+ const pathX = pathEl.transform.baseVal[0].matrix.e;
+
+ const delayEl = itemEl.querySelector(".animation-delay-sign");
+ let delayX = null;
+ let delayWidth = null;
+
+ if (delayEl) {
+ const computedStyle = delayEl.ownerGlobal.getComputedStyle(delayEl);
+ delayX = computedStyle.left;
+ delayWidth = computedStyle.width;
+ }
+
+ const endDelayEl = itemEl.querySelector(".animation-end-delay-sign");
+ let endDelayX = null;
+ let endDelayWidth = null;
+
+ if (endDelayEl) {
+ const computedStyle = endDelayEl.ownerGlobal.getComputedStyle(endDelayEl);
+ endDelayX = computedStyle.left;
+ endDelayWidth = computedStyle.width;
+ }
+
+ const coordinates = {
+ svgViewBoxX,
+ svgViewBoxWidth,
+ pathX,
+ delayX,
+ delayWidth,
+ endDelayX,
+ endDelayWidth,
+ };
+ initialCoordinatesResult.push(coordinates);
+ }
+
+ info("Set currentTime to rear of the end of animation of .delay-negative.");
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0.75);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ info("Resume animations");
+ clickOnPauseResumeButton(animationInspector, panel);
+ // As some animations may be finished, we check if some animations will be running.
+ await waitUntil(() =>
+ animationInspector.state.animations.some(
+ a => a.state.playState === "running"
+ )
+ );
+
+ info("Check the layout");
+ const itemEls = panel.querySelectorAll(".animation-item");
+ is(
+ itemEls.length,
+ initialCoordinatesResult.length,
+ "Count of animation item should be same to initial items"
+ );
+
+ info("Check the coordinates");
+ checkExpectedCoordinates(itemEls, initialCoordinatesResult);
+});
+
+function checkExpectedCoordinates(itemEls, initialCoordinatesResult) {
+ for (let i = 0; i < itemEls.length; i++) {
+ const expectedCoordinates = initialCoordinatesResult[i];
+ const itemEl = itemEls[i];
+ const svgEl = itemEl.querySelector("svg");
+ is(
+ svgEl.viewBox.baseVal.x,
+ expectedCoordinates.svgViewBoxX,
+ "X of viewBox of svg should be same"
+ );
+ is(
+ svgEl.viewBox.baseVal.width,
+ expectedCoordinates.svgViewBoxWidth,
+ "Width of viewBox of svg should be same"
+ );
+
+ const pathEl = svgEl.querySelector(".animation-computed-timing-path");
+ is(
+ pathEl.transform.baseVal[0].matrix.e,
+ expectedCoordinates.pathX,
+ "X of tansform of path element should be same"
+ );
+
+ const delayEl = itemEl.querySelector(".animation-delay-sign");
+
+ if (delayEl) {
+ const computedStyle = delayEl.ownerGlobal.getComputedStyle(delayEl);
+ is(
+ computedStyle.left,
+ expectedCoordinates.delayX,
+ "X of delay sign should be same"
+ );
+ is(
+ computedStyle.width,
+ expectedCoordinates.delayWidth,
+ "Width of delay sign should be same"
+ );
+ } else {
+ ok(!expectedCoordinates.delayX, "X of delay sign should exist");
+ ok(!expectedCoordinates.delayWidth, "Width of delay sign should exist");
+ }
+
+ const endDelayEl = itemEl.querySelector(".animation-end-delay-sign");
+
+ if (endDelayEl) {
+ const computedStyle = endDelayEl.ownerGlobal.getComputedStyle(endDelayEl);
+ is(
+ computedStyle.left,
+ expectedCoordinates.endDelayX,
+ "X of endDelay sign should be same"
+ );
+ is(
+ computedStyle.width,
+ expectedCoordinates.endDelayWidth,
+ "Width of endDelay sign should be same"
+ );
+ } else {
+ ok(!expectedCoordinates.endDelayX, "X of endDelay sign should exist");
+ ok(
+ !expectedCoordinates.endDelayWidth,
+ "Width of endDelay sign should exist"
+ );
+ }
+ }
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_negative-delay-path.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_negative-delay-path.js
new file mode 100644
index 0000000000..8ed638c443
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_negative-delay-path.js
@@ -0,0 +1,128 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following NegativeDelayPath component works.
+// * element existance
+// * path
+
+const TEST_DATA = [
+ {
+ targetClass: "delay-positive",
+ },
+ {
+ targetClass: "delay-negative",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 0, y: 50 },
+ { x: 250000, y: 75 },
+ { x: 500000, y: 100 },
+ { x: 500000, y: 0 },
+ ],
+ ],
+ expectedNegativePath: [
+ { x: -500000, y: 0 },
+ { x: -250000, y: 25 },
+ { x: 0, y: 50 },
+ { x: 0, y: 0 },
+ ],
+ },
+ {
+ targetClass: "delay-negative-25",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 0, y: 25 },
+ { x: 750000, y: 100 },
+ { x: 750000, y: 0 },
+ ],
+ ],
+ expectedNegativePath: [
+ { x: -250000, y: 0 },
+ { x: 0, y: 25 },
+ { x: 0, y: 0 },
+ ],
+ },
+ {
+ targetClass: "delay-negative-75",
+ expectedIterationPathList: [
+ [
+ { x: 0, y: 0 },
+ { x: 0, y: 75 },
+ { x: 250000, y: 100 },
+ { x: 250000, y: 0 },
+ ],
+ ],
+ expectedNegativePath: [
+ { x: -750000, y: 0 },
+ { x: 0, y: 75 },
+ { x: 0, y: 0 },
+ ],
+ },
+];
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_multi_timings.html");
+ await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`));
+ const { panel } = await openAnimationInspector();
+
+ for (const {
+ targetClass,
+ expectedIterationPathList,
+ expectedNegativePath,
+ } of TEST_DATA) {
+ const animationItemEl = await findAnimationItemByTargetSelector(
+ panel,
+ `.${targetClass}`
+ );
+
+ info(`Checking negative delay path existence for ${targetClass}`);
+ const negativeDelayPathEl = animationItemEl.querySelector(
+ ".animation-negative-delay-path"
+ );
+
+ if (expectedNegativePath) {
+ ok(
+ negativeDelayPathEl,
+ "The negative delay path element should be in animation item element"
+ );
+ const pathEl = negativeDelayPathEl.querySelector("path");
+ assertPathSegments(pathEl, true, expectedNegativePath);
+ } else {
+ ok(
+ !negativeDelayPathEl,
+ "The negative delay path element should not be in animation item element"
+ );
+ }
+
+ if (!expectedIterationPathList) {
+ // We don't need to test for iteration path.
+ continue;
+ }
+
+ info(`Checking computed timing path existance for ${targetClass}`);
+ const computedTimingPathEl = animationItemEl.querySelector(
+ ".animation-computed-timing-path"
+ );
+ ok(
+ computedTimingPathEl,
+ "The computed timing path element should be in each animation item element"
+ );
+
+ info(`Checking iteration path list for ${targetClass}`);
+ const iterationPathEls = computedTimingPathEl.querySelectorAll(
+ ".animation-iteration-path"
+ );
+ is(
+ iterationPathEls.length,
+ expectedIterationPathList.length,
+ `Number of iteration path should be ${expectedIterationPathList.length}`
+ );
+
+ for (const [j, iterationPathEl] of iterationPathEls.entries()) {
+ assertPathSegments(iterationPathEl, true, expectedIterationPathList[j]);
+ }
+ }
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_negative-end-delay-path.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_negative-end-delay-path.js
new file mode 100644
index 0000000000..69ce5007b5
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_negative-end-delay-path.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following NegativeEndDelayPath component works.
+// * element existance
+// * path
+
+const TEST_DATA = [
+ {
+ targetClass: "enddelay-positive",
+ },
+ {
+ targetClass: "enddelay-negative",
+ expectedPath: [
+ { x: 500000, y: 0 },
+ { x: 500000, y: 50 },
+ { x: 750000, y: 75 },
+ { x: 1000000, y: 100 },
+ { x: 1000000, y: 0 },
+ ],
+ },
+];
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_multi_timings.html");
+ await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`));
+ const { panel } = await openAnimationInspector();
+
+ for (const { targetClass, expectedPath } of TEST_DATA) {
+ const animationItemEl = await findAnimationItemByTargetSelector(
+ panel,
+ `.${targetClass}`
+ );
+
+ info(`Checking negative endDelay path existance for ${targetClass}`);
+ const negativeEndDelayPathEl = animationItemEl.querySelector(
+ ".animation-negative-end-delay-path"
+ );
+
+ if (expectedPath) {
+ ok(
+ negativeEndDelayPathEl,
+ "The negative endDelay path element should be in animation item element"
+ );
+ const pathEl = negativeEndDelayPathEl.querySelector("path");
+ assertPathSegments(pathEl, true, expectedPath);
+ } else {
+ ok(
+ !negativeEndDelayPathEl,
+ "The negative endDelay path element should not be in animation item element"
+ );
+ }
+ }
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_tooltip.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_tooltip.js
new file mode 100644
index 0000000000..1be3c92f4f
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_tooltip.js
@@ -0,0 +1,294 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for existance and content of tooltip on summary graph element.
+
+const TEST_DATA = [
+ {
+ targetClass: "cssanimation-normal",
+ expectedResult: {
+ nameAndType: "cssanimation — CSS Animation",
+ duration: "1,000s",
+ },
+ },
+ {
+ targetClass: "cssanimation-linear",
+ expectedResult: {
+ nameAndType: "cssanimation — CSS Animation",
+ duration: "1,000s",
+ animationTimingFunction: "linear",
+ },
+ },
+ {
+ targetClass: "delay-positive",
+ expectedResult: {
+ nameAndType: "test-delay-animation — Script Animation",
+ delay: "500s",
+ duration: "1,000s",
+ },
+ },
+ {
+ targetClass: "delay-negative",
+ expectedResult: {
+ nameAndType: "test-negative-delay-animation — Script Animation",
+ delay: "-500s",
+ duration: "1,000s",
+ },
+ },
+ {
+ targetClass: "easing-step",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ duration: "1,000s",
+ easing: "steps(2)",
+ },
+ },
+ {
+ targetClass: "enddelay-positive",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ duration: "1,000s",
+ endDelay: "500s",
+ },
+ },
+ {
+ targetClass: "enddelay-negative",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ duration: "1,000s",
+ endDelay: "-500s",
+ },
+ },
+ {
+ targetClass: "enddelay-with-fill-forwards",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ duration: "1,000s",
+ endDelay: "500s",
+ fill: "forwards",
+ },
+ },
+ {
+ targetClass: "enddelay-with-iterations-infinity",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ duration: "1,000s",
+ endDelay: "500s",
+ iterations: "\u221E",
+ },
+ },
+ {
+ targetClass: "direction-alternate-with-iterations-infinity",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ duration: "1,000s",
+ direction: "alternate",
+ iterations: "\u221E",
+ },
+ },
+ {
+ targetClass: "direction-alternate-reverse-with-iterations-infinity",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ duration: "1,000s",
+ direction: "alternate-reverse",
+ iterations: "\u221E",
+ },
+ },
+ {
+ targetClass: "direction-reverse-with-iterations-infinity",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ duration: "1,000s",
+ direction: "reverse",
+ iterations: "\u221E",
+ },
+ },
+ {
+ targetClass: "fill-backwards",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ duration: "1,000s",
+ fill: "backwards",
+ },
+ },
+ {
+ targetClass: "fill-backwards-with-delay-iterationstart",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ delay: "500s",
+ duration: "1,000s",
+ fill: "backwards",
+ iterationStart: "0.5",
+ },
+ },
+ {
+ targetClass: "fill-both",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ duration: "1,000s",
+ fill: "both",
+ },
+ },
+ {
+ targetClass: "fill-both-width-delay-iterationstart",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ delay: "500s",
+ duration: "1,000s",
+ fill: "both",
+ iterationStart: "0.5",
+ },
+ },
+ {
+ targetClass: "fill-forwards",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ duration: "1,000s",
+ fill: "forwards",
+ },
+ },
+ {
+ targetClass: "iterationstart",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ duration: "1,000s",
+ iterationStart: "0.5",
+ },
+ },
+ {
+ targetClass: "no-compositor",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ duration: "1,000s",
+ },
+ },
+ {
+ targetClass: "keyframes-easing-step",
+ expectedResult: {
+ nameAndType: "Script Animation",
+ duration: "1,000s",
+ },
+ },
+];
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_multi_timings.html");
+ await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`));
+ const { panel } = await openAnimationInspector();
+
+ for (const { targetClass, expectedResult } of TEST_DATA) {
+ const animationItemEl = await findAnimationItemByTargetSelector(
+ panel,
+ `.${targetClass}`
+ );
+ const summaryGraphEl = animationItemEl.querySelector(
+ ".animation-summary-graph"
+ );
+
+ info(`Checking tooltip for ${targetClass}`);
+ ok(
+ summaryGraphEl.hasAttribute("title"),
+ "Summary graph should have 'title' attribute"
+ );
+
+ const tooltip = summaryGraphEl.getAttribute("title");
+ const {
+ animationTimingFunction,
+ delay,
+ easing,
+ endDelay,
+ direction,
+ duration,
+ fill,
+ iterations,
+ iterationStart,
+ nameAndType,
+ } = expectedResult;
+
+ ok(
+ tooltip.startsWith(nameAndType),
+ "Tooltip should start with name and type"
+ );
+
+ if (animationTimingFunction) {
+ const expected = `Animation timing function: ${animationTimingFunction}`;
+ ok(tooltip.includes(expected), `Tooltip should include '${expected}'`);
+ } else {
+ ok(
+ !tooltip.includes("Animation timing function:"),
+ "Tooltip should not include animation timing function"
+ );
+ }
+
+ if (delay) {
+ const expected = `Delay: ${delay}`;
+ ok(tooltip.includes(expected), `Tooltip should include '${expected}'`);
+ } else {
+ ok(!tooltip.includes("Delay:"), "Tooltip should not include delay");
+ }
+
+ if (direction) {
+ const expected = `Direction: ${direction}`;
+ ok(tooltip.includes(expected), `Tooltip should include '${expected}'`);
+ } else {
+ ok(!tooltip.includes("Direction:"), "Tooltip should not include delay");
+ }
+
+ if (duration) {
+ const expected = `Duration: ${duration}`;
+ ok(tooltip.includes(expected), `Tooltip should include '${expected}'`);
+ } else {
+ ok(!tooltip.includes("Duration:"), "Tooltip should not include delay");
+ }
+
+ if (easing) {
+ const expected = `Overall easing: ${easing}`;
+ ok(tooltip.includes(expected), `Tooltip should include '${expected}'`);
+ } else {
+ ok(
+ !tooltip.includes("Overall easing:"),
+ "Tooltip should not include easing"
+ );
+ }
+
+ if (endDelay) {
+ const expected = `End delay: ${endDelay}`;
+ ok(tooltip.includes(expected), `Tooltip should include '${expected}'`);
+ } else {
+ ok(
+ !tooltip.includes("End delay:"),
+ "Tooltip should not include endDelay"
+ );
+ }
+
+ if (fill) {
+ const expected = `Fill: ${fill}`;
+ ok(tooltip.includes(expected), `Tooltip should include '${expected}'`);
+ } else {
+ ok(!tooltip.includes("Fill:"), "Tooltip should not include fill");
+ }
+
+ if (iterations) {
+ const expected = `Repeats: ${iterations}`;
+ ok(tooltip.includes(expected), `Tooltip should include '${expected}'`);
+ } else {
+ ok(
+ !tooltip.includes("Repeats:"),
+ "Tooltip should not include iterations"
+ );
+ }
+
+ if (iterationStart) {
+ const expected = `Iteration start: ${iterationStart}`;
+ ok(tooltip.includes(expected), `Tooltip should include '${expected}'`);
+ } else {
+ ok(
+ !tooltip.includes("Iteration start:"),
+ "Tooltip should not include iterationStart"
+ );
+ }
+ }
+});
diff --git a/devtools/client/inspector/animation/test/browser_animation_timing_negative-playback-rate_current-time-scrubber.js b/devtools/client/inspector/animation/test/browser_animation_timing_negative-playback-rate_current-time-scrubber.js
new file mode 100644
index 0000000000..e5f05d4f18
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_timing_negative-playback-rate_current-time-scrubber.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether the scrubber was working in case of negative playback rate.
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_negative_playback_rate.html");
+ await removeAnimatedElementsExcept([".normal"]);
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Set initial state");
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ const initialCurrentTime =
+ animationInspector.state.animations[0].state.currentTime;
+ const initialProgressBarX = getProgressBarX(panel);
+
+ info("Check whether the animation currentTime was decreased");
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5);
+ await waitUntilCurrentTimeChangedAt(
+ animationInspector,
+ animationInspector.state.timeScale.getDuration() * 0.5
+ );
+ Assert.greater(
+ initialCurrentTime,
+ animationInspector.state.animations[0].state.currentTime,
+ "currentTime should be decreased"
+ );
+
+ info("Check whether the progress bar was moved to left");
+ Assert.greater(
+ initialProgressBarX,
+ getProgressBarX(panel),
+ "Progress bar should be moved to left"
+ );
+});
+
+function getProgressBarX(panel) {
+ const areaEl = panel.querySelector(".keyframes-progress-bar-area");
+ const barEl = areaEl.querySelector(".keyframes-progress-bar");
+ const controllerBounds = areaEl.getBoundingClientRect();
+ const barBounds = barEl.getBoundingClientRect();
+ const barX = barBounds.x + barBounds.width / 2 - controllerBounds.x;
+ return barX;
+}
diff --git a/devtools/client/inspector/animation/test/browser_animation_timing_negative-playback-rate_summary-graph.js b/devtools/client/inspector/animation/test/browser_animation_timing_negative-playback-rate_summary-graph.js
new file mode 100644
index 0000000000..ef326f5eb2
--- /dev/null
+++ b/devtools/client/inspector/animation/test/browser_animation_timing_negative-playback-rate_summary-graph.js
@@ -0,0 +1,235 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for following summary graph with the animation which is negative playback rate.
+// * Tooltips
+// * Graph path
+// * Delay sign
+// * End delay sign
+
+const TEST_DATA = [
+ {
+ targetSelector: ".normal",
+ expectedPathList: [
+ {
+ selector: ".animation-iteration-path",
+ path: [
+ { x: 0, y: 100 },
+ { x: 50000, y: 50 },
+ { x: 100000, y: 0 },
+ ],
+ },
+ ],
+ expectedTooltip: "Playback rate: -1",
+ expectedViewboxWidth: 200000,
+ },
+ {
+ targetSelector: ".normal-playbackrate-2",
+ expectedPathList: [
+ {
+ selector: ".animation-iteration-path",
+ path: [
+ { x: 0, y: 100 },
+ { x: 50000, y: 50 },
+ { x: 100000, y: 0 },
+ ],
+ },
+ ],
+ expectedTooltip: "Playback rate: -2",
+ expectedViewboxWidth: 400000,
+ },
+ {
+ targetSelector: ".positive-delay",
+ expectedSignList: [
+ {
+ selector: ".animation-end-delay-sign",
+ sign: {
+ marginInlineStart: "75%",
+ width: "25%",
+ },
+ },
+ ],
+ expectedPathList: [
+ {
+ selector: ".animation-iteration-path",
+ path: [
+ { x: 0, y: 100 },
+ { x: 50000, y: 50 },
+ { x: 100000, y: 0 },
+ ],
+ },
+ ],
+ expectedTooltip: "Playback rate: -1",
+ },
+ {
+ targetSelector: ".negative-delay",
+ expectedSignList: [
+ {
+ selector: ".animation-end-delay-sign",
+ sign: {
+ marginInlineStart: "50%",
+ width: "25%",
+ },
+ },
+ ],
+ expectedPathList: [
+ {
+ selector: ".animation-iteration-path",
+ path: [
+ { x: 0, y: 100 },
+ { x: 50000, y: 50 },
+ { x: 50000, y: 0 },
+ ],
+ },
+ {
+ selector: ".animation-negative-delay-path path",
+ path: [
+ { x: 50000, y: 50 },
+ { x: 100000, y: 0 },
+ ],
+ },
+ ],
+ expectedTooltip: "Playback rate: -1",
+ },
+ {
+ targetSelector: ".positive-end-delay",
+ expectedSignList: [
+ {
+ selector: ".animation-delay-sign",
+ sign: {
+ isFilled: true,
+ marginInlineStart: "25%",
+ width: "25%",
+ },
+ },
+ ],
+ expectedPathList: [
+ {
+ selector: ".animation-iteration-path",
+ path: [
+ { x: 50000, y: 100 },
+ { x: 100000, y: 50 },
+ { x: 150000, y: 0 },
+ ],
+ },
+ ],
+ expectedTooltip: "Playback rate: -1",
+ },
+ {
+ targetSelector: ".negative-end-delay",
+ expectedSignList: [
+ {
+ selector: ".animation-delay-sign",
+ sign: {
+ isFilled: true,
+ marginInlineStart: "0%",
+ width: "25%",
+ },
+ },
+ ],
+ expectedPathList: [
+ {
+ selector: ".animation-iteration-path",
+ path: [
+ { x: 0, y: 50 },
+ { x: 50000, y: 0 },
+ ],
+ },
+ {
+ selector: ".animation-negative-end-delay-path path",
+ path: [
+ { x: -50000, y: 100 },
+ { x: 0, y: 0 },
+ ],
+ },
+ ],
+ expectedTooltip: "Playback rate: -1",
+ },
+];
+
+add_task(async function () {
+ await addTab(URL_ROOT + "doc_negative_playback_rate.html");
+ const { panel } = await openAnimationInspector();
+
+ for (const testData of TEST_DATA) {
+ const {
+ targetSelector,
+ expectedPathList,
+ expectedSignList,
+ expectedTooltip,
+ expectedViewboxWidth,
+ } = testData;
+
+ const animationItemEl = await findAnimationItemByTargetSelector(
+ panel,
+ targetSelector
+ );
+ const summaryGraphEl = animationItemEl.querySelector(
+ ".animation-summary-graph"
+ );
+
+ info(`Check tooltip for the animation of ${targetSelector}`);
+ assertTooltip(summaryGraphEl, expectedTooltip);
+
+ if (expectedPathList) {
+ for (const { selector, path } of expectedPathList) {
+ info(`Check path for ${selector}`);
+ assertPath(summaryGraphEl, selector, path);
+ }
+ }
+
+ if (expectedSignList) {
+ for (const { selector, sign } of expectedSignList) {
+ info(`Check sign for ${selector}`);
+ assertSign(summaryGraphEl, selector, sign);
+ }
+ }
+
+ if (expectedViewboxWidth) {
+ info("Check width of viewbox of SVG");
+ const svgEl = summaryGraphEl.querySelector(
+ ".animation-summary-graph-path"
+ );
+ is(
+ svgEl.viewBox.baseVal.width,
+ expectedViewboxWidth,
+ `width of viewbox should be ${expectedViewboxWidth}`
+ );
+ }
+ }
+});
+
+function assertPath(summaryGraphEl, pathSelector, expectedPath) {
+ const pathEl = summaryGraphEl.querySelector(pathSelector);
+ assertPathSegments(pathEl, true, expectedPath);
+}
+
+function assertSign(summaryGraphEl, selector, expectedSign) {
+ const signEl = summaryGraphEl.querySelector(selector);
+
+ is(
+ signEl.style.marginInlineStart,
+ expectedSign.marginInlineStart,
+ `marginInlineStart position should be ${expectedSign.marginInlineStart}`
+ );
+ is(
+ signEl.style.width,
+ expectedSign.width,
+ `Width should be ${expectedSign.width}`
+ );
+ is(
+ signEl.classList.contains("fill"),
+ expectedSign.isFilled || false,
+ "signEl should be correct"
+ );
+}
+
+function assertTooltip(summaryGraphEl, expectedTooltip) {
+ const tooltip = summaryGraphEl.getAttribute("title");
+ ok(
+ tooltip.includes(expectedTooltip),
+ `Tooltip should include '${expectedTooltip}'`
+ );
+}
diff --git a/devtools/client/inspector/animation/test/current-time-scrubber_head.js b/devtools/client/inspector/animation/test/current-time-scrubber_head.js
new file mode 100644
index 0000000000..1e94a7562c
--- /dev/null
+++ b/devtools/client/inspector/animation/test/current-time-scrubber_head.js
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* import-globals-from head.js */
+
+// Test for following CurrentTimeScrubber and CurrentTimeScrubberController components:
+// * element existence
+// * scrubber position validity
+// * make animations currentTime to change by click on the controller
+// * mouse drag on the scrubber
+
+// eslint-disable-next-line no-unused-vars
+async function testCurrentTimeScrubber(isRTL) {
+ await addTab(URL_ROOT + "doc_simple_animation.html");
+ await removeAnimatedElementsExcept([".long"]);
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ info("Checking scrubber controller existence");
+ const controllerEl = panel.querySelector(".current-time-scrubber-area");
+ ok(controllerEl, "scrubber controller should exist");
+
+ info("Checking scrubber existence");
+ const scrubberEl = controllerEl.querySelector(".current-time-scrubber");
+ ok(scrubberEl, "scrubber should exist");
+
+ info("Checking scrubber changes current time of animation and the position");
+ const duration = animationInspector.state.timeScale.getDuration();
+ clickOnCurrentTimeScrubberController(
+ animationInspector,
+ panel,
+ isRTL ? 1 : 0
+ );
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ await waitUntilCurrentTimeChangedAt(animationInspector, 0);
+ assertPosition(
+ scrubberEl,
+ controllerEl,
+ isRTL ? duration : 0,
+ animationInspector
+ );
+
+ clickOnCurrentTimeScrubberController(
+ animationInspector,
+ panel,
+ isRTL ? 0 : 1
+ );
+ await waitUntilCurrentTimeChangedAt(animationInspector, duration);
+ assertPosition(
+ scrubberEl,
+ controllerEl,
+ isRTL ? 0 : duration,
+ animationInspector
+ );
+
+ clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5);
+ await waitUntilCurrentTimeChangedAt(animationInspector, duration * 0.5);
+ assertPosition(scrubberEl, controllerEl, duration * 0.5, animationInspector);
+
+ info("Checking current time scrubber position during running");
+ // Running again
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntilAnimationsPlayState(animationInspector, "running");
+ let previousX = scrubberEl.getBoundingClientRect().x;
+ await wait(1000);
+ let currentX = scrubberEl.getBoundingClientRect().x;
+ isnot(previousX, currentX, "Scrubber should be moved");
+
+ info("Checking draggable on scrubber over animation list");
+ clickOnPauseResumeButton(animationInspector, panel);
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+ previousX = scrubberEl.getBoundingClientRect().x;
+ await dragOnCurrentTimeScrubber(animationInspector, panel, 5, 30);
+ currentX = scrubberEl.getBoundingClientRect().x;
+ isnot(previousX, currentX, "Scrubber should be draggable");
+
+ info(
+ "Checking a behavior which mouse out from animation inspector area " +
+ "during dragging from controller"
+ );
+ await dragOnCurrentTimeScrubberController(animationInspector, panel, 0.5, 2);
+ ok(
+ !panel
+ .querySelector(".animation-list-container")
+ .classList.contains("active-scrubber"),
+ "Click and DnD should be inactive"
+ );
+}
+
+function assertPosition(scrubberEl, controllerEl, time, animationInspector) {
+ const controllerBounds = controllerEl.getBoundingClientRect();
+ const scrubberBounds = scrubberEl.getBoundingClientRect();
+ const scrubberX =
+ scrubberBounds.x + scrubberBounds.width / 2 - controllerBounds.x;
+ const timeScale = animationInspector.state.timeScale;
+ const expected = Math.round(
+ (time / timeScale.getDuration()) * controllerBounds.width
+ );
+ is(scrubberX, expected, `Position should be ${expected} at ${time}ms`);
+}
diff --git a/devtools/client/inspector/animation/test/doc_custom_playback_rate.html b/devtools/client/inspector/animation/test/doc_custom_playback_rate.html
new file mode 100644
index 0000000000..9adee99884
--- /dev/null
+++ b/devtools/client/inspector/animation/test/doc_custom_playback_rate.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <style>
+ div {
+ background-color: lime;
+ height: 100px;
+ }
+ </style>
+ </head>
+ <body>
+ <script>
+ "use strict";
+
+ const duration = 100000;
+
+ function createAnimation(cls) {
+ const div = document.createElement("div");
+ div.classList.add(cls);
+ document.body.appendChild(div);
+ const animation = div.animate([{ opacity: 0 }], duration);
+ animation.playbackRate = 1.5;
+ }
+
+ createAnimation("div1");
+ createAnimation("div2");
+ </script>
+ </body>
+</html>
diff --git a/devtools/client/inspector/animation/test/doc_infinity_duration.html b/devtools/client/inspector/animation/test/doc_infinity_duration.html
new file mode 100644
index 0000000000..10d19fc3cf
--- /dev/null
+++ b/devtools/client/inspector/animation/test/doc_infinity_duration.html
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <style>
+ div {
+ background-color: lime;
+ height: 100px;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="infinity"></div>
+ <div class="infinity-delay-iteration-start"></div>
+ <div class="limited"></div>
+ <script>
+ "use strict";
+
+ document.querySelector(".infinity").animate(
+ { opacity: [1, 0] },
+ { duration: Infinity }
+ );
+
+ document.querySelector(".infinity-delay-iteration-start").animate(
+ { opacity: [1, 0] },
+ {
+ delay: 100000,
+ duration: Infinity,
+ iterationStart: 0.5,
+ }
+ );
+
+ document.querySelector(".limited").animate(
+ { opacity: [1, 0] },
+ {
+ duration: 100000,
+ }
+ );
+ </script>
+ </body>
+</html>
diff --git a/devtools/client/inspector/animation/test/doc_multi_easings.html b/devtools/client/inspector/animation/test/doc_multi_easings.html
new file mode 100644
index 0000000000..cedcb027fe
--- /dev/null
+++ b/devtools/client/inspector/animation/test/doc_multi_easings.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <style>
+ div {
+ background-color: lime;
+ height: 50px;
+ }
+ </style>
+ </head>
+ <body>
+ <script>
+ "use strict";
+
+ function createAnimation(name, keyframes, effectEasing) {
+ const div = document.createElement("div");
+ div.classList.add(name);
+ document.body.appendChild(div);
+
+ const effect = {
+ duration: 100000,
+ fill: "forwards",
+ };
+
+ if (effectEasing) {
+ effect.easing = effectEasing;
+ }
+
+ div.animate(keyframes, effect);
+ }
+
+ createAnimation(
+ "no-easing",
+ [
+ { opacity: 1 },
+ { opacity: 0 },
+ ]
+ );
+
+ createAnimation(
+ "effect-easing",
+ [
+ { opacity: 1 },
+ { opacity: 0 },
+ ],
+ "steps(5, jump-none)"
+ );
+
+ createAnimation(
+ "keyframe-easing",
+ [
+ { opacity: 1, easing: "steps(2)" },
+ { opacity: 0 },
+ ]
+ );
+
+ createAnimation(
+ "both-easing",
+ [
+ { offset: 0, opacity: 1, easing: "steps(2)" },
+ { offset: 0, marginLeft: "0px", easing: "steps(1)" },
+ { marginLeft: "100px", opacity: 0 },
+ ],
+ "steps(10)"
+ );
+
+ createAnimation(
+ "narrow-keyframes",
+ [
+ { opacity: 0 },
+ { offset: 0.1, opacity: 1, easing: "steps(1)" },
+ { offset: 0.13, opacity: 0 },
+ ]
+ );
+
+ createAnimation(
+ "duplicate-keyframes",
+ [
+ { opacity: 0 },
+ { offset: 0.5, opacity: 1 },
+ { offset: 0.5, opacity: 0, easing: "steps(1)" },
+ { opacity: 1 },
+ ]
+ );
+
+ createAnimation(
+ "color-keyframes",
+ [
+ { color: "red", easing: "ease-in" },
+ { offset: 0.4, color: "blue", easing: "ease-out" },
+ { color: "lime" },
+ ]
+ );
+
+ createAnimation(
+ "jump-start",
+ [
+ { opacity: 1, easing: "steps(2, jump-start)" },
+ { opacity: 0 },
+ ],
+ );
+
+ createAnimation(
+ "jump-end",
+ [
+ { opacity: 1, easing: "steps(2, jump-end)" },
+ { opacity: 0 },
+ ],
+ );
+
+ createAnimation(
+ "jump-both",
+ [
+ { opacity: 1, easing: "steps(3, jump-both)" },
+ { opacity: 0 },
+ ],
+ );
+ </script>
+ </body>
+</html>
diff --git a/devtools/client/inspector/animation/test/doc_multi_keyframes.html b/devtools/client/inspector/animation/test/doc_multi_keyframes.html
new file mode 100644
index 0000000000..8977f77dde
--- /dev/null
+++ b/devtools/client/inspector/animation/test/doc_multi_keyframes.html
@@ -0,0 +1,229 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <style>
+ div {
+ width: 50px;
+ height: 50px;
+ }
+ </style>
+ </head>
+ <body>
+ <script>
+ "use strict";
+
+ function createAnimation(name, keyframes, effectEasing) {
+ const div = document.createElement("div");
+ div.classList.add(name);
+ document.body.appendChild(div);
+
+ const effect = {
+ duration: 100000,
+ fill: "forwards",
+ };
+
+ if (effectEasing) {
+ effect.easing = effectEasing;
+ }
+
+ div.animate(keyframes, effect);
+ }
+
+ createAnimation(
+ "multi-types",
+ [
+ {
+ backgroundColor: "red",
+ backgroundRepeat: "space round",
+ fontSize: "10px",
+ marginLeft: "0px",
+ opacity: 0,
+ textAlign: "right",
+ transform: "translate(0px)",
+ },
+ {
+ backgroundColor: "lime",
+ backgroundRepeat: "round space",
+ fontSize: "20px",
+ marginLeft: "100px",
+ opacity: 1,
+ textAlign: "center",
+ transform: "translate(100px)",
+ },
+ ]
+ );
+
+ createAnimation(
+ "multi-types-reverse",
+ [
+ {
+ backgroundColor: "lime",
+ backgroundRepeat: "space",
+ fontSize: "20px",
+ marginLeft: "100px",
+ opacity: 1,
+ textAlign: "center",
+ transform: "translate(100px)",
+ },
+ {
+ backgroundColor: "red",
+ backgroundRepeat: "round",
+ fontSize: "10px",
+ marginLeft: "0px",
+ opacity: 0,
+ textAlign: "right",
+ transform: "translate(0px)",
+ },
+ ]
+ );
+
+ createAnimation(
+ "middle-keyframe",
+ [
+ {
+ backgroundColor: "red",
+ backgroundRepeat: "space",
+ fontSize: "10px",
+ marginLeft: "0px",
+ opacity: 0,
+ textAlign: "right",
+ transform: "translate(0px)",
+ },
+ {
+ backgroundColor: "blue",
+ backgroundRepeat: "round",
+ fontSize: "20px",
+ marginLeft: "100px",
+ opacity: 1,
+ textAlign: "center",
+ transform: "translate(100px)",
+ },
+ {
+ backgroundColor: "lime",
+ backgroundRepeat: "space",
+ fontSize: "10px",
+ marginLeft: "0px",
+ opacity: 0,
+ textAlign: "right",
+ transform: "translate(0px)",
+ },
+ ]
+ );
+
+ createAnimation(
+ "steps-keyframe",
+ [
+ {
+ backgroundColor: "red",
+ backgroundRepeat: "space",
+ fontSize: "10px",
+ marginLeft: "0px",
+ opacity: 0,
+ textAlign: "right",
+ transform: "translate(0px)",
+ easing: "steps(2)",
+ },
+ {
+ backgroundColor: "lime",
+ backgroundRepeat: "round",
+ fontSize: "20px",
+ marginLeft: "100px",
+ opacity: 1,
+ textAlign: "center",
+ transform: "translate(100px)",
+ },
+ ]
+ );
+
+ createAnimation(
+ "steps-effect",
+ [
+ {
+ opacity: 0,
+ },
+ {
+ opacity: 1,
+ },
+ ],
+ "steps(2)"
+ );
+
+ createAnimation(
+ "steps-jump-none-keyframe",
+ [
+ {
+ easing: "steps(5, jump-none)",
+ opacity: 0,
+ },
+ {
+ opacity: 1,
+ },
+ ]
+ );
+
+ createAnimation(
+ "narrow-offsets",
+ [
+ {
+ opacity: 0,
+ },
+ {
+ opacity: 1,
+ easing: "steps(2)",
+ offset: 0.1,
+ },
+ {
+ opacity: 0,
+ offset: 0.13,
+ },
+ ]
+ );
+
+ createAnimation(
+ "duplicate-offsets",
+ [
+ {
+ opacity: 1,
+ },
+ {
+ opacity: 1,
+ offset: 0.5,
+ },
+ {
+ opacity: 0,
+ offset: 0.5,
+ },
+ {
+ opacity: 1,
+ offset: 1,
+ },
+ ]
+ );
+
+ createAnimation(
+ "same-color",
+ [
+ {
+ backgroundColor: "lime",
+ },
+ {
+ backgroundColor: "lime",
+ },
+ ]
+ );
+
+ createAnimation(
+ "currentcolor",
+ [
+ {
+ backgroundColor: "currentColor",
+ },
+ {
+ backgroundColor: "lime",
+ },
+ ]
+ );
+ </script>
+ </body>
+</html>
diff --git a/devtools/client/inspector/animation/test/doc_multi_timings.html b/devtools/client/inspector/animation/test/doc_multi_timings.html
new file mode 100644
index 0000000000..a999431917
--- /dev/null
+++ b/devtools/client/inspector/animation/test/doc_multi_timings.html
@@ -0,0 +1,169 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <style>
+ div {
+ background-color: lime;
+ height: 100px;
+ width: 100px;
+ }
+
+ .cssanimation-normal {
+ animation: cssanimation 1000s;
+ }
+
+ .cssanimation-linear {
+ animation: cssanimation 1000s linear;
+ }
+
+ @keyframes cssanimation {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+ </style>
+ </head>
+ <body>
+ <div class="cssanimation-normal"></div>
+ <div class="cssanimation-linear"></div>
+ <script>
+ "use strict";
+
+ const duration = 1000000;
+
+ function createAnimation(keyframes, effect, className) {
+ const div = document.createElement("div");
+ div.classList.add(className);
+ document.body.appendChild(div);
+ effect.duration = duration;
+ div.animate(keyframes, effect);
+ }
+
+ createAnimation({ opacity: [0, 1] },
+ { delay: 500000, id: "test-delay-animation" },
+ "delay-positive");
+
+ createAnimation({ opacity: [0, 1] },
+ { delay: -500000, id: "test-negative-delay-animation" },
+ "delay-negative");
+
+ createAnimation({ opacity: [0, 1] },
+ { easing: "steps(2)" },
+ "easing-step");
+
+ createAnimation({ opacity: [0, 1] },
+ { endDelay: 500000 },
+ "enddelay-positive");
+
+ createAnimation({ opacity: [0, 1] },
+ { endDelay: -500000 },
+ "enddelay-negative");
+
+ createAnimation({ opacity: [0, 1] },
+ { endDelay: 500000, fill: "forwards" },
+ "enddelay-with-fill-forwards");
+
+ createAnimation({ opacity: [0, 1] },
+ { endDelay: 500000, iterations: Infinity },
+ "enddelay-with-iterations-infinity");
+
+ createAnimation({ opacity: [0, 1] },
+ { direction: "alternate", iterations: Infinity },
+ "direction-alternate-with-iterations-infinity");
+
+ createAnimation({ opacity: [0, 1] },
+ { direction: "alternate-reverse", iterations: Infinity },
+ "direction-alternate-reverse-with-iterations-infinity");
+
+ createAnimation({ opacity: [0, 1] },
+ { direction: "reverse", iterations: Infinity },
+ "direction-reverse-with-iterations-infinity");
+
+ createAnimation({ opacity: [0, 1] },
+ { fill: "backwards" },
+ "fill-backwards");
+
+ createAnimation({ opacity: [0, 1] },
+ { fill: "backwards", delay: 500000, iterationStart: 0.5 },
+ "fill-backwards-with-delay-iterationstart");
+
+ createAnimation({ opacity: [0, 1] },
+ { fill: "both" },
+ "fill-both");
+
+ createAnimation({ opacity: [0, 1] },
+ { fill: "both", delay: 500000, iterationStart: 0.5 },
+ "fill-both-width-delay-iterationstart");
+
+ createAnimation({ opacity: [0, 1] },
+ { fill: "forwards" },
+ "fill-forwards");
+
+ createAnimation({ opacity: [0, 1] },
+ { iterationStart: 0.5 },
+ "iterationstart");
+
+ createAnimation({ width: ["100px", "150px"] },
+ {},
+ "no-compositor");
+
+ createAnimation([{ opacity: 0, easing: "steps(2)" }, { opacity: 1 }],
+ {},
+ "keyframes-easing-step");
+
+ createAnimation(
+ [
+ {
+ opacity: 0,
+ offset: 0,
+ },
+ {
+ opacity: 1,
+ offset: 0.1,
+ easing: "steps(1)",
+ },
+ {
+ opacity: 0,
+ offset: 0.13,
+ },
+ ],
+ {},
+ "narrow-keyframes");
+
+ createAnimation(
+ [
+ {
+ offset: 0,
+ opacity: 1,
+ },
+ {
+ offset: 0.5,
+ opacity: 1,
+ },
+ {
+ offset: 0.5,
+ easing: "steps(1)",
+ opacity: 0,
+ },
+ {
+ offset: 1,
+ opacity: 1,
+ },
+ ],
+ {},
+ "duplicate-offsets");
+
+ createAnimation({ opacity: [0, 1] },
+ { delay: -250000 },
+ "delay-negative-25");
+
+ createAnimation({ opacity: [0, 1] },
+ { delay: -750000 },
+ "delay-negative-75");
+ </script>
+ </body>
+</html>
diff --git a/devtools/client/inspector/animation/test/doc_mutations_add_remove_immediately.html b/devtools/client/inspector/animation/test/doc_mutations_add_remove_immediately.html
new file mode 100644
index 0000000000..c8b3db749b
--- /dev/null
+++ b/devtools/client/inspector/animation/test/doc_mutations_add_remove_immediately.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ </head>
+ <body>
+ <div></div>
+
+ <script>
+ "use strict";
+
+ // This function is called from test.
+ // eslint-disable-next-line
+ function startMutation() {
+ const target = document.querySelector("div");
+ const animation = target.animate({ opacity: [1, 0] }, 100000);
+ animation.currentTime = 1;
+ animation.cancel();
+ }
+ </script>
+ </body>
+</html>
diff --git a/devtools/client/inspector/animation/test/doc_mutations_fast.html b/devtools/client/inspector/animation/test/doc_mutations_fast.html
new file mode 100644
index 0000000000..3622846953
--- /dev/null
+++ b/devtools/client/inspector/animation/test/doc_mutations_fast.html
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <style>
+ div {
+ background-color: lime;
+ height: 20px;
+ opacity: 1;
+ transition: 0.5s opacity;
+ }
+
+ .transition {
+ opacity: 0;
+ }
+ </style>
+ </head>
+ <body>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+
+ <script>
+ "use strict";
+
+ // This function is called from test.
+ // eslint-disable-next-line
+ async function startFastMutations() {
+ const targets = document.querySelectorAll("div");
+
+ for (let i = 0; i < 10; i++) {
+ for (const target of targets) {
+ target.classList.toggle("transition");
+ await wait(15);
+ }
+ }
+ }
+
+ async function wait(ms) {
+ return new Promise(resolve => {
+ setTimeout(resolve, ms);
+ });
+ }
+ </script>
+ </body>
+</html>
diff --git a/devtools/client/inspector/animation/test/doc_negative_playback_rate.html b/devtools/client/inspector/animation/test/doc_negative_playback_rate.html
new file mode 100644
index 0000000000..a98700712d
--- /dev/null
+++ b/devtools/client/inspector/animation/test/doc_negative_playback_rate.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <style>
+ div {
+ background-color: lime;
+ height: 100px;
+ }
+ </style>
+ </head>
+ <body>
+ <script>
+ "use strict";
+
+ const DURATION = 100000;
+ const KEYFRAMES = { backgroundColor: ["lime", "red"] };
+
+ function createAnimation(effect, className, playbackRate = -1) {
+ const div = document.createElement("div");
+ div.classList.add(className);
+ document.body.appendChild(div);
+ effect.duration = DURATION;
+ effect.fill = "forwards";
+ const animation = div.animate(KEYFRAMES, effect);
+ animation.updatePlaybackRate(playbackRate);
+ animation.play();
+ }
+
+ createAnimation({}, "normal");
+ createAnimation({}, "normal-playbackrate-2", -2);
+ createAnimation({ delay: 50000 }, "positive-delay");
+ createAnimation({ delay: -50000 }, "negative-delay");
+ createAnimation({ endDelay: 50000 }, "positive-end-delay");
+ createAnimation({ endDelay: -50000 }, "negative-end-delay");
+ </script>
+ </body>
+</html>
diff --git a/devtools/client/inspector/animation/test/doc_overflowed_delay_end_delay.html b/devtools/client/inspector/animation/test/doc_overflowed_delay_end_delay.html
new file mode 100644
index 0000000000..a4d91ae4ef
--- /dev/null
+++ b/devtools/client/inspector/animation/test/doc_overflowed_delay_end_delay.html
@@ -0,0 +1,75 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <style>
+ div {
+ width: 100px;
+ height: 100px;
+ outline: 1px solid lime;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="target"></div>
+ <script>
+ "use strict";
+
+ const target = document.getElementById("target");
+ target.animate(
+ {
+ color: ["red", "lime"],
+ },
+ {
+ id: "big-delay",
+ duration: 1000,
+ delay: Number.MAX_VALUE,
+ iterations: Infinity,
+ });
+
+ target.animate(
+ {
+ opacity: [1, 0],
+ },
+ {
+ id: "big-end-delay",
+ duration: 1000,
+ endDelay: Number.MAX_VALUE,
+ iterations: Infinity,
+ });
+
+ target.animate(
+ {
+ marginLeft: ["0px", "100px"],
+ },
+ {
+ id: "negative-big-delay",
+ duration: 1000,
+ delay: -Number.MAX_VALUE,
+ iterations: Infinity,
+ });
+
+ target.animate(
+ {
+ paddingLeft: ["0px", "100px"],
+ },
+ {
+ id: "negative-big-end-delay",
+ duration: 1000,
+ endDelay: -Number.MAX_VALUE,
+ iterations: Infinity,
+ });
+
+ target.animate(
+ {
+ backgroundColor: ["lime", "white"],
+ },
+ {
+ id: "big-iteration-start",
+ duration: 1000,
+ iterations: Infinity,
+ iterationStart: Number.MAX_VALUE,
+ });
+ </script>
+ </body>
+</html>
diff --git a/devtools/client/inspector/animation/test/doc_pseudo.html b/devtools/client/inspector/animation/test/doc_pseudo.html
new file mode 100644
index 0000000000..3cc0c93470
--- /dev/null
+++ b/devtools/client/inspector/animation/test/doc_pseudo.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <style>
+ body::before {
+ animation: body 10s infinite;
+ background-color: lime;
+ content: "body-before";
+ width: 100px;
+ }
+
+ .div-before::before {
+ animation: div-before 10s infinite;
+ background-color: lime;
+ content: "div-before";
+ width: 100px;
+ }
+
+ .div-after::after {
+ animation: div-after 10s infinite;
+ background-color: lime;
+ content: "div-after";
+ width: 100px;
+ }
+
+ .div-marker {
+ display: list-item;
+ list-style-position: inside;
+ }
+
+ .div-marker::marker {
+ content: "div-marker";
+ }
+
+ @keyframes body {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+
+ @keyframes div-before {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+ }
+
+ @keyframes div-after {
+ from {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.9;
+ }
+ to {
+ opacity: 0;
+ }
+ }
+ </style>
+ </head>
+ <body>
+ <div class="div-before"></div>
+ <div class="div-after"></div>
+ <div class="div-marker"></div>
+
+ <script>
+ "use strict";
+
+ // The reason why we currently run the animation on `::marker` with Web Animations API
+ // instead of CSS Animations is because it requires `layout.css.marker.restricted`
+ // pref change.
+ document.querySelector(".div-marker").animate(
+ {
+ color: ["black", "lime"],
+ },
+ {
+ id: "div-marker",
+ duration: 10000,
+ iterations: Infinity,
+ pseudoElement: "::marker",
+ }
+ );
+ </script>
+ </body>
+</html>
diff --git a/devtools/client/inspector/animation/test/doc_short_duration.html b/devtools/client/inspector/animation/test/doc_short_duration.html
new file mode 100644
index 0000000000..ed9b2d94dc
--- /dev/null
+++ b/devtools/client/inspector/animation/test/doc_short_duration.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <style>
+ div {
+ background-color: lime;
+ height: 100px;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="short"></div>
+ <script>
+ "use strict";
+
+ document.querySelector(".short").animate(
+ { opacity: [1, 0] },
+ {
+ duration: 1,
+ iterations: Infinity,
+ }
+ );
+ </script>
+ </body>
+</html>
diff --git a/devtools/client/inspector/animation/test/doc_simple_animation.html b/devtools/client/inspector/animation/test/doc_simple_animation.html
new file mode 100644
index 0000000000..7e145166fe
--- /dev/null
+++ b/devtools/client/inspector/animation/test/doc_simple_animation.html
@@ -0,0 +1,193 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <style>
+ @property --ball-color {
+ syntax: "<color>";
+ inherits: true;
+ initial-value: #f06;
+ }
+
+ .ball {
+ width: 80px;
+ height: 80px;
+ /* Add a border here to avoid layout warnings in Linux debug builds: Bug 1329784 */
+ border: 1px solid transparent;
+ border-radius: 50%;
+ background: var(--ball-color);
+ 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 normal;
+ }
+
+ .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-all {
+ animation: compositor-all 2s infinite;
+ }
+
+ .compositor-notall {
+ animation: compositor-notall 2s infinite;
+ }
+
+ .compositor-warning {
+ animation: compositor-all 2s infinite;
+ }
+
+ .warning-observer {
+ width: 10px;
+ height: 10px;
+ background-image: -moz-element(#warning);
+ }
+
+ .longhand {
+ animation: longhand 10s infinite;
+ }
+
+ @keyframes simple-animation {
+ 100% {
+ transform: translateX(300px);
+ }
+ }
+
+ @keyframes other-animation {
+ 100% {
+ background: blue;
+ }
+ }
+
+ @keyframes no-compositor {
+ 100% {
+ margin-right: 600px;
+ }
+ }
+
+ @keyframes compositor-all {
+ to { opacity: 0.5 }
+ }
+
+ @keyframes compositor-notall {
+ from {
+ --ball-color: tomato;
+ opacity: 0;
+ width: 0px;
+ transform: translate(0px);
+ }
+ to {
+ --ball-color: gold;
+ opacity: 1;
+ width: 100px;
+ transform: translate(100px);
+ }
+ }
+
+ @keyframes longhand {
+ from {
+ background: red;
+ padding: 0 0 0 10px;
+ }
+ to {
+ background: lime;
+ padding: 0 0 0 20px;
+ }
+ }
+ </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 end-delay"></div>
+ <div class="ball compositor-all"></div>
+ <div class="ball compositor-notall"></div>
+ <div class="ball compositor-warning" id="warning"></div>
+ <div class="ball longhand"></div>
+ <div class="warning-observer"></div>
+ <script>
+ /* globals KeyframeEffect, Animation */
+ "use strict";
+
+ const el = document.querySelector(".end-delay");
+ const effect = new KeyframeEffect(el, [
+ { opacity: 0, offset: 0 },
+ { opacity: 1, offset: 1 },
+ ], { duration: 1000000, endDelay: 500000, fill: "none" });
+ const animation = new Animation(effect, document.timeline);
+ animation.play();
+ </script>
+</body>
+</html>
diff --git a/devtools/client/inspector/animation/test/doc_special_colors.html b/devtools/client/inspector/animation/test/doc_special_colors.html
new file mode 100644
index 0000000000..2c71b2c963
--- /dev/null
+++ b/devtools/client/inspector/animation/test/doc_special_colors.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <style>
+ div {
+ animation: anim 5s infinite;
+ border: 1px solid lime;
+ height: 100px;
+ width: 100px;
+ }
+
+ @keyframes anim {
+ from {
+ caret-color: auto;
+ scrollbar-color: lime red;
+ }
+ to {
+ caret-color: lime;
+ scrollbar-color: auto;
+ }
+ }
+ </style>
+ </head>
+ <body>
+ <div></div>
+ </body>
+</html>
diff --git a/devtools/client/inspector/animation/test/head.js b/devtools/client/inspector/animation/test/head.js
new file mode 100644
index 0000000000..959ad270a5
--- /dev/null
+++ b/devtools/client/inspector/animation/test/head.js
@@ -0,0 +1,1028 @@
+/* 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 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 TAB_NAME = "animationinspector";
+
+const ANIMATION_L10N = new LocalizationHelper(
+ "devtools/client/locales/animationinspector.properties"
+);
+
+// Auto clean-up when a test ends.
+// 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.toolsidebar-width.inspector");
+});
+
+/**
+ * Open the toolbox, with the inspector tool visible and the animationinspector
+ * sidebar selected.
+ *
+ * @return {Promise} that resolves when the inspector is ready.
+ */
+const openAnimationInspector = async function () {
+ const { inspector, toolbox } = await openInspectorSidebarTab(TAB_NAME);
+ await inspector.once("inspector-updated");
+ const animationInspector = inspector.getPanel("animationinspector");
+ const panel = inspector.panelWin.document.getElementById(
+ "animation-container"
+ );
+
+ info("Wait for loading first content");
+ const count = getDisplayedGraphCount(animationInspector, panel);
+ await waitUntil(
+ () =>
+ panel.querySelectorAll(".animation-summary-graph-path").length >= count &&
+ panel.querySelectorAll(".animation-target .objectBox").length >= count
+ );
+
+ if (
+ animationInspector.state.selectedAnimation &&
+ animationInspector.state.detailVisibility
+ ) {
+ await waitUntil(() => panel.querySelector(".animated-property-list"));
+ }
+
+ return { animationInspector, toolbox, inspector, panel };
+};
+
+/**
+ * Close the toolbox.
+ *
+ * @return {Promise} that resolves when the toolbox has closed.
+ */
+const closeAnimationInspector = async function () {
+ return gDevTools.closeToolboxForTab(gBrowser.selectedTab);
+};
+
+/**
+ * Some animation features are not enabled by default in release/beta channels
+ * yet including parts of the Web Animations API.
+ */
+const enableAnimationFeatures = function () {
+ return SpecialPowers.pushPrefEnv({
+ set: [["dom.animations-api.timelines.enabled", true]],
+ });
+};
+
+/**
+ * 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
+ */
+const _addTab = addTab;
+addTab = async function (url) {
+ await enableAnimationFeatures();
+ return _addTab(url);
+};
+
+/**
+ * Remove animated elements from document except given selectors.
+ *
+ * @param {Array} selectors
+ * @return {Promise}
+ */
+const removeAnimatedElementsExcept = function (selectors) {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [selectors],
+ selectorsChild => {
+ function isRemovableElement(animation, selectorsInner) {
+ for (const selector of selectorsInner) {
+ if (animation.effect.target.matches(selector)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ for (const animation of content.document.getAnimations()) {
+ if (isRemovableElement(animation, selectorsChild)) {
+ animation.effect.target.remove();
+ }
+ }
+ }
+ );
+};
+
+/**
+ * Click on an animation in the timeline to select it.
+ *
+ * @param {AnimationInspector} animationInspector.
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {Number} index
+ * The index of the animation to click on.
+ */
+const clickOnAnimation = async function (animationInspector, panel, index) {
+ info("Click on animation " + index + " in the timeline");
+ const animationItemEl = await findAnimationItemByIndex(panel, index);
+ const summaryGraphEl = animationItemEl.querySelector(
+ ".animation-summary-graph"
+ );
+ clickOnSummaryGraph(animationInspector, panel, summaryGraphEl);
+};
+
+/**
+ * Click on an animation by given selector of node which is target element of animation.
+ *
+ * @param {AnimationInspector} animationInspector.
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {String} selector
+ * Selector of node which is target element of animation.
+ */
+const clickOnAnimationByTargetSelector = async function (
+ animationInspector,
+ panel,
+ selector
+) {
+ info(`Click on animation whose selector of target element is '${selector}'`);
+ const animationItemEl = await findAnimationItemByTargetSelector(
+ panel,
+ selector
+ );
+ const summaryGraphEl = animationItemEl.querySelector(
+ ".animation-summary-graph"
+ );
+ clickOnSummaryGraph(animationInspector, panel, summaryGraphEl);
+};
+
+/**
+ * Click on close button for animation detail pane.
+ *
+ * @param {DOMElement} panel
+ * #animation-container element.
+ */
+const clickOnDetailCloseButton = function (panel) {
+ info("Click on close button for animation detail pane");
+ const buttonEl = panel.querySelector(".animation-detail-close-button");
+ const bounds = buttonEl.getBoundingClientRect();
+ const x = bounds.width / 2;
+ const y = bounds.height / 2;
+ EventUtils.synthesizeMouse(buttonEl, x, y, {}, buttonEl.ownerGlobal);
+};
+
+/**
+ * Click on pause/resume button.
+ *
+ * @param {AnimationInspector} animationInspector
+ * @param {DOMElement} panel
+ * #animation-container element.
+ */
+const clickOnPauseResumeButton = function (animationInspector, panel) {
+ info("Click on pause/resume button");
+ const buttonEl = panel.querySelector(".pause-resume-button");
+ const bounds = buttonEl.getBoundingClientRect();
+ const x = bounds.width / 2;
+ const y = bounds.height / 2;
+ EventUtils.synthesizeMouse(buttonEl, x, y, {}, buttonEl.ownerGlobal);
+};
+
+/**
+ * Click on rewind button.
+ *
+ * @param {AnimationInspector} animationInspector
+ * @param {DOMElement} panel
+ * #animation-container element.
+ */
+const clickOnRewindButton = function (animationInspector, panel) {
+ info("Click on rewind button");
+ const buttonEl = panel.querySelector(".rewind-button");
+ const bounds = buttonEl.getBoundingClientRect();
+ const x = bounds.width / 2;
+ const y = bounds.height / 2;
+ EventUtils.synthesizeMouse(buttonEl, x, y, {}, buttonEl.ownerGlobal);
+};
+
+/**
+ * Click on the scrubber controller pane to update the animation current time.
+ *
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {Number} mouseDownPosition
+ * rate on scrubber controller pane.
+ * This method calculates
+ * `mouseDownPosition * offsetWidth + offsetLeft of scrubber controller pane`
+ * as the clientX of MouseEvent.
+ */
+const clickOnCurrentTimeScrubberController = function (
+ animationInspector,
+ panel,
+ mouseDownPosition
+) {
+ const controllerEl = panel.querySelector(".current-time-scrubber-area");
+ const bounds = controllerEl.getBoundingClientRect();
+ const mousedonwX = bounds.width * mouseDownPosition;
+
+ info(`Click ${mousedonwX} on scrubber controller`);
+ EventUtils.synthesizeMouse(
+ controllerEl,
+ mousedonwX,
+ 0,
+ {},
+ controllerEl.ownerGlobal
+ );
+};
+
+/**
+ * Click on the inspect icon for the given AnimationTargetComponent.
+ *
+ * @param {AnimationInspector} animationInspector.
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {Number} index
+ * The index of the AnimationTargetComponent to click on.
+ */
+const clickOnInspectIcon = async function (animationInspector, panel, index) {
+ info(`Click on an inspect icon in animation target component[${index}]`);
+ const animationItemEl = await findAnimationItemByIndex(panel, index);
+ const iconEl = animationItemEl.querySelector(
+ ".animation-target .objectBox .highlight-node"
+ );
+ iconEl.scrollIntoView(false);
+ EventUtils.synthesizeMouseAtCenter(iconEl, {}, iconEl.ownerGlobal);
+};
+
+/**
+ * Change playback rate selector to select given rate.
+ *
+ * @param {AnimationInspector} animationInspector
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {Number} rate
+ */
+const changePlaybackRateSelector = async function (
+ animationInspector,
+ panel,
+ rate
+) {
+ info(`Click on playback rate selector to select ${rate}`);
+ const selectEl = panel.querySelector(".playback-rate-selector");
+ const optionIndex = [...selectEl.options].findIndex(o => +o.value == rate);
+
+ if (optionIndex == -1) {
+ ok(
+ false,
+ `Could not find an option for rate ${rate} in the rate selector. ` +
+ `Values are: ${[...selectEl.options].map(o => o.value)}`
+ );
+ return;
+ }
+
+ selectEl.focus();
+
+ const win = selectEl.ownerGlobal;
+ while (selectEl.selectedIndex != optionIndex) {
+ const key = selectEl.selectedIndex > optionIndex ? "LEFT" : "RIGHT";
+ EventUtils.sendKey(key, win);
+ }
+};
+
+/**
+ * Click on given summary graph element.
+ *
+ * @param {AnimationInspector} animationInspector
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {Element} summaryGraphEl
+ */
+const clickOnSummaryGraph = function (
+ animationInspector,
+ panel,
+ summaryGraphEl
+) {
+ // Disable pointer-events of the scrubber in order to avoid to click accidently.
+ const scrubberEl = panel.querySelector(".current-time-scrubber");
+ scrubberEl.style.pointerEvents = "none";
+ // Scroll to show the timeBlock since the element may be out of displayed area.
+ summaryGraphEl.scrollIntoView(false);
+ EventUtils.synthesizeMouseAtCenter(
+ summaryGraphEl,
+ {},
+ summaryGraphEl.ownerGlobal
+ );
+ // Restore the scrubber style.
+ scrubberEl.style.pointerEvents = "unset";
+};
+
+/**
+ * Click on the target node for the given AnimationTargetComponent index.
+ *
+ * @param {AnimationInspector} animationInspector.
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {Number} index
+ * The index of the AnimationTargetComponent to click on.
+ */
+const clickOnTargetNode = async function (animationInspector, panel, index) {
+ const { inspector } = animationInspector;
+ const { waitForHighlighterTypeShown } = getHighlighterTestHelpers(inspector);
+ info(`Click on a target node in animation target component[${index}]`);
+
+ const animationItemEl = await findAnimationItemByIndex(panel, index);
+ const targetEl = animationItemEl.querySelector(
+ ".animation-target .objectBox"
+ );
+ const onHighlight = waitForHighlighterTypeShown(
+ inspector.highlighters.TYPES.BOXMODEL
+ );
+ EventUtils.synthesizeMouseAtCenter(targetEl, {}, targetEl.ownerGlobal);
+ await onHighlight;
+};
+
+/**
+ * Drag on the scrubber to update the animation current time.
+ *
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {Number} mouseMovePixel
+ * Dispatch mousemove event with mouseMovePosition after mousedown.
+ * @param {Number} mouseYPixel
+ * Y of mouse in pixel.
+ */
+const dragOnCurrentTimeScrubber = async function (
+ animationInspector,
+ panel,
+ mouseMovePixel,
+ mouseYPixel
+) {
+ const controllerEl = panel.querySelector(".current-time-scrubber");
+ info(`Drag scrubber to X ${mouseMovePixel}`);
+ EventUtils.synthesizeMouse(
+ controllerEl,
+ 0,
+ mouseYPixel,
+ { type: "mousedown" },
+ controllerEl.ownerGlobal
+ );
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+
+ const animation = animationInspector.state.animations[0];
+ let currentTime = animation.state.currentTime;
+ EventUtils.synthesizeMouse(
+ controllerEl,
+ mouseMovePixel,
+ mouseYPixel,
+ { type: "mousemove" },
+ controllerEl.ownerGlobal
+ );
+ await waitUntil(() => animation.state.currentTime !== currentTime);
+
+ currentTime = animation.state.currentTime;
+ EventUtils.synthesizeMouse(
+ controllerEl,
+ mouseMovePixel,
+ mouseYPixel,
+ { type: "mouseup" },
+ controllerEl.ownerGlobal
+ );
+ await waitUntil(() => animation.state.currentTime !== currentTime);
+};
+
+/**
+ * Drag on the scrubber controller pane to update the animation current time.
+ *
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {Number} mouseDownPosition
+ * rate on scrubber controller pane.
+ * This method calculates
+ * `mouseDownPosition * offsetWidth + offsetLeft of scrubber controller pane`
+ * as the clientX of MouseEvent.
+ * @param {Number} mouseMovePosition
+ * Dispatch mousemove event with mouseMovePosition after mousedown.
+ * Calculation for clinetX is same to above.
+ */
+const dragOnCurrentTimeScrubberController = async function (
+ animationInspector,
+ panel,
+ mouseDownPosition,
+ mouseMovePosition
+) {
+ const controllerEl = panel.querySelector(".current-time-scrubber-area");
+ const bounds = controllerEl.getBoundingClientRect();
+ const mousedonwX = bounds.width * mouseDownPosition;
+ const mousemoveX = bounds.width * mouseMovePosition;
+
+ info(`Drag on scrubber controller from ${mousedonwX} to ${mousemoveX}`);
+ EventUtils.synthesizeMouse(
+ controllerEl,
+ mousedonwX,
+ 0,
+ { type: "mousedown" },
+ controllerEl.ownerGlobal
+ );
+ await waitUntilAnimationsPlayState(animationInspector, "paused");
+
+ const animation = animationInspector.state.animations[0];
+ let currentTime = animation.state.currentTime;
+ EventUtils.synthesizeMouse(
+ controllerEl,
+ mousemoveX,
+ 0,
+ { type: "mousemove" },
+ controllerEl.ownerGlobal
+ );
+ await waitUntil(() => animation.state.currentTime !== currentTime);
+
+ currentTime = animation.state.currentTime;
+ EventUtils.synthesizeMouse(
+ controllerEl,
+ mousemoveX,
+ 0,
+ { type: "mouseup" },
+ controllerEl.ownerGlobal
+ );
+ await waitUntil(() => animation.state.currentTime !== currentTime);
+};
+
+/**
+ * Get current animation duration and rate of
+ * clickOrDragOnCurrentTimeScrubberController in given pixels.
+ *
+ * @param {AnimationInspector} animationInspector
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {Number} pixels
+ * @return {Object}
+ * {
+ * duration,
+ * rate,
+ * }
+ */
+const getDurationAndRate = function (animationInspector, panel, pixels) {
+ const controllerEl = panel.querySelector(".current-time-scrubber-area");
+ const bounds = controllerEl.getBoundingClientRect();
+ const duration =
+ (animationInspector.state.timeScale.getDuration() / bounds.width) * pixels;
+ const rate = (1 / bounds.width) * pixels;
+ return { duration, rate };
+};
+
+/**
+ * Mouse over the target node for the given AnimationTargetComponent index.
+ *
+ * @param {AnimationInspector} animationInspector.
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {Number} index
+ * The index of the AnimationTargetComponent to click on.
+ */
+const mouseOverOnTargetNode = function (animationInspector, panel, index) {
+ info(`Mouse over on a target node in animation target component[${index}]`);
+ const el = panel.querySelectorAll(".animation-target .objectBox")[index];
+ el.scrollIntoView(false);
+ EventUtils.synthesizeMouse(el, 10, 5, { type: "mouseover" }, el.ownerGlobal);
+};
+
+/**
+ * Mouse out of the target node for the given AnimationTargetComponent index.
+ *
+ * @param {AnimationInspector} animationInspector.
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {Number} index
+ * The index of the AnimationTargetComponent to click on.
+ */
+const mouseOutOnTargetNode = function (animationInspector, panel, index) {
+ info(`Mouse out on a target node in animation target component[${index}]`);
+ const el = panel.querySelectorAll(".animation-target .objectBox")[index];
+ el.scrollIntoView(false);
+ EventUtils.synthesizeMouse(el, -1, -1, { type: "mouseout" }, el.ownerGlobal);
+};
+
+/**
+ * Select animation inspector in sidebar and toolbar.
+ *
+ * @param {InspectorPanel} inspector
+ */
+const selectAnimationInspector = async function (inspector) {
+ await inspector.toolbox.selectTool("inspector");
+ const onDispatched = waitForDispatch(inspector.store, "UPDATE_ANIMATIONS");
+ inspector.sidebar.select("animationinspector");
+ await onDispatched;
+};
+
+/**
+ * Send keyboard event of space to given panel.
+ *
+ * @param {AnimationInspector} animationInspector
+ * @param {DOMElement} target element.
+ */
+const sendSpaceKeyEvent = function (animationInspector, element) {
+ element.focus();
+ EventUtils.sendKey("SPACE", element.ownerGlobal);
+};
+
+/**
+ * Set a node class attribute to the given selector.
+ *
+ * @param {AnimationInspector} animationInspector
+ * @param {String} selector
+ * @param {String} cls
+ * e.g. ".ball.still"
+ */
+const setClassAttribute = async function (animationInspector, selector, cls) {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [cls, selector],
+ (attributeValue, selectorChild) => {
+ const node = content.document.querySelector(selectorChild);
+ if (!node) {
+ return;
+ }
+
+ node.setAttribute("class", attributeValue);
+ }
+ );
+};
+
+/**
+ * Set a new style properties to the node for the given selector.
+ *
+ * @param {AnimationInspector} animationInspector
+ * @param {String} selector
+ * @param {Object} properties
+ * e.g. {
+ * animationDuration: "1000ms",
+ * animationTimingFunction: "linear",
+ * }
+ */
+const setEffectTimingAndPlayback = async function (
+ animationInspector,
+ selector,
+ effectTiming,
+ playbackRate
+) {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [selector, playbackRate, effectTiming],
+ (selectorChild, playbackRateChild, effectTimingChild) => {
+ let selectedAnimation = null;
+
+ for (const animation of content.document.getAnimations()) {
+ if (animation.effect.target.matches(selectorChild)) {
+ selectedAnimation = animation;
+ break;
+ }
+ }
+
+ if (!selectedAnimation) {
+ return;
+ }
+
+ selectedAnimation.playbackRate = playbackRateChild;
+ selectedAnimation.effect.updateTiming(effectTimingChild);
+ }
+ );
+};
+
+/**
+ * Set the sidebar width by given parameter.
+ *
+ * @param {String} width
+ * Change sidebar width by given parameter.
+ * @param {InspectorPanel} inspector
+ * The instance of InspectorPanel currently loaded in the toolbox
+ * @return {Promise} Resolves when the sidebar size changed.
+ */
+const setSidebarWidth = async function (width, inspector) {
+ const onUpdated = inspector.toolbox.once("inspector-sidebar-resized");
+ inspector.splitBox.setState({ width });
+ await onUpdated;
+};
+
+/**
+ * Set a new style property declaration to the node for the given selector.
+ *
+ * @param {AnimationInspector} animationInspector
+ * @param {String} selector
+ * @param {String} propertyName
+ * e.g. "animationDuration"
+ * @param {String} propertyValue
+ * e.g. "5.5s"
+ */
+const setStyle = async function (
+ animationInspector,
+ selector,
+ propertyName,
+ propertyValue
+) {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [selector, propertyName, propertyValue],
+ (selectorChild, propertyNameChild, propertyValueChild) => {
+ const node = content.document.querySelector(selectorChild);
+ if (!node) {
+ return;
+ }
+
+ node.style[propertyNameChild] = propertyValueChild;
+ }
+ );
+};
+
+/**
+ * Set a new style properties to the node for the given selector.
+ *
+ * @param {AnimationInspector} animationInspector
+ * @param {String} selector
+ * @param {Object} properties
+ * e.g. {
+ * animationDuration: "1000ms",
+ * animationTimingFunction: "linear",
+ * }
+ */
+const setStyles = async function (animationInspector, selector, properties) {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [properties, selector],
+ (propertiesChild, selectorChild) => {
+ const node = content.document.querySelector(selectorChild);
+ if (!node) {
+ return;
+ }
+
+ for (const propertyName in propertiesChild) {
+ const propertyValue = propertiesChild[propertyName];
+ node.style[propertyName] = propertyValue;
+ }
+ }
+ );
+};
+
+/**
+ * Wait until current time of animations will be changed to given current time.
+ *
+ * @param {AnimationInspector} animationInspector
+ * @param {Number} currentTime
+ */
+const waitUntilCurrentTimeChangedAt = async function (
+ animationInspector,
+ currentTime
+) {
+ info(`Wait until current time will be change to ${currentTime}`);
+ await waitUntil(() =>
+ animationInspector.state.animations.every(a => {
+ return a.state.currentTime === currentTime;
+ })
+ );
+};
+
+/**
+ * Wait until animations' play state will be changed to given state.
+ *
+ * @param {Array} animationInspector
+ * @param {String} state
+ */
+const waitUntilAnimationsPlayState = async function (
+ animationInspector,
+ state
+) {
+ info(`Wait until play state will be change to ${state}`);
+ await waitUntil(() =>
+ animationInspector.state.animations.every(a => a.state.playState === state)
+ );
+};
+
+/**
+ * Return count of graph that animation inspector is displaying.
+ *
+ * @param {AnimationInspector} animationInspector
+ * @param {DOMElement} panel
+ * @return {Number} count
+ */
+const getDisplayedGraphCount = (animationInspector, panel) => {
+ const animationLength = animationInspector.state.animations.length;
+ if (animationLength === 0) {
+ return 0;
+ }
+
+ const inspectionPanelEl = panel.querySelector(".progress-inspection-panel");
+ const itemEl = panel.querySelector(".animation-item");
+ const listEl = panel.querySelector(".animation-list");
+ const itemHeight = itemEl.offsetHeight;
+ // This calculation should be same as AnimationListContainer.updateDisplayableRange.
+ const count = Math.floor(listEl.offsetHeight / itemHeight) + 1;
+ const index = Math.floor(inspectionPanelEl.scrollTop / itemHeight);
+
+ return animationLength > index + count ? count : animationLength - index;
+};
+
+/**
+ * Check whether the animations are pausing.
+ *
+ * @param {AnimationInspector} animationInspector
+ */
+function assertAnimationsPausing(animationInspector) {
+ assertAnimationsPausingOrRunning(animationInspector, true);
+}
+
+/**
+ * Check whether the animations are pausing/running.
+ *
+ * @param {AnimationInspector} animationInspector
+ * @param {boolean} shouldPause
+ */
+function assertAnimationsPausingOrRunning(animationInspector, shouldPause) {
+ const hasRunningAnimation = animationInspector.state.animations.some(
+ ({ state }) => state.playState === "running"
+ );
+
+ if (shouldPause) {
+ is(hasRunningAnimation, false, "All animations should be paused");
+ } else {
+ is(hasRunningAnimation, true, "Animations should be running at least one");
+ }
+}
+
+/**
+ * Check whether the animations are running.
+ *
+ * @param {AnimationInspector} animationInspector
+ */
+function assertAnimationsRunning(animationInspector) {
+ assertAnimationsPausingOrRunning(animationInspector, false);
+}
+
+/**
+ * Check the <stop> element in the given linearGradientEl for the correct offset
+ * and color attributes.
+ *
+ * @param {Element} linearGradientEl
+ <linearGradient> element which has <stop> element.
+ * @param {Number} offset
+ * float which represents the "offset" attribute of <stop>.
+ * @param {String} expectedColor
+ * e.g. rgb(0, 0, 255)
+ */
+function assertLinearGradient(linearGradientEl, offset, expectedColor) {
+ const stopEl = findStopElement(linearGradientEl, offset);
+ ok(stopEl, `stop element at offset ${offset} should exist`);
+ is(
+ stopEl.getAttribute("stop-color"),
+ expectedColor,
+ `stop-color of stop element at offset ${offset} should be ${expectedColor}`
+ );
+}
+
+/**
+ * SummaryGraph is constructed by <path> element.
+ * This function checks the vertex of path segments.
+ *
+ * @param {Element} pathEl
+ * <path> element.
+ * @param {boolean} hasClosePath
+ * Set true if the path shoud be closing.
+ * @param {Object} expectedValues
+ * JSON object format. We can test the vertex and color.
+ * e.g.
+ * [
+ * { x: 0, y: 0 },
+ * { x: 0, y: 1 },
+ * ]
+ */
+function assertPathSegments(pathEl, hasClosePath, expectedValues) {
+ ok(
+ isExpectedPath(pathEl, hasClosePath, expectedValues),
+ "All of path segments are correct"
+ );
+}
+
+function isExpectedPath(pathEl, hasClosePath, expectedValues) {
+ const pathSegList = pathEl.pathSegList;
+ if (!pathSegList) {
+ return false;
+ }
+
+ if (
+ !expectedValues.every(value =>
+ isPassingThrough(pathSegList, value.x, value.y)
+ )
+ ) {
+ return false;
+ }
+
+ if (hasClosePath) {
+ const closePathSeg = pathSegList.getItem(pathSegList.numberOfItems - 1);
+ if (closePathSeg.pathSegType !== closePathSeg.PATHSEG_CLOSEPATH) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Check whether the given vertex is passing throug on the path.
+ *
+ * @param {pathSegList} pathSegList - pathSegList of <path> element.
+ * @param {float} x - x of vertex.
+ * @param {float} y - y of vertex.
+ * @return {boolean} true: passing through, false: no on the path.
+ */
+function isPassingThrough(pathSegList, x, y) {
+ let previousPathSeg = pathSegList.getItem(0);
+ for (let i = 0; i < pathSegList.numberOfItems; i++) {
+ const pathSeg = pathSegList.getItem(i);
+ if (pathSeg.x === undefined) {
+ continue;
+ }
+ const currentX = parseFloat(pathSeg.x.toFixed(3));
+ const currentY = parseFloat(pathSeg.y.toFixed(3));
+ if (currentX === x && currentY === y) {
+ return true;
+ }
+ const previousX = parseFloat(previousPathSeg.x.toFixed(3));
+ const previousY = parseFloat(previousPathSeg.y.toFixed(3));
+ if (
+ previousX <= x &&
+ x <= currentX &&
+ Math.min(previousY, currentY) <= y &&
+ y <= Math.max(previousY, currentY)
+ ) {
+ return true;
+ }
+ previousPathSeg = pathSeg;
+ }
+ return false;
+}
+
+/**
+ * Return animation item element by the index.
+ *
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {Number} index
+ * @return {DOMElement}
+ * Animation item element.
+ */
+async function findAnimationItemByIndex(panel, index) {
+ const itemEls = [...panel.querySelectorAll(".animation-item")];
+ const itemEl = itemEls[index];
+ itemEl.scrollIntoView(false);
+
+ await waitUntil(
+ () =>
+ itemEl.querySelector(".animation-target .attrName") &&
+ itemEl.querySelector(".animation-computed-timing-path")
+ );
+
+ return itemEl;
+}
+
+/**
+ * Return animation item element by target node selector.
+ * This function compares betweem animation-target textContent and given selector.
+ * Then returns matched first item.
+ *
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {String} selector
+ * Selector of tested element.
+ * @return {DOMElement}
+ * Animation item element.
+ */
+async function findAnimationItemByTargetSelector(panel, selector) {
+ for (const itemEl of panel.querySelectorAll(".animation-item")) {
+ itemEl.scrollIntoView(false);
+
+ await waitUntil(
+ () =>
+ itemEl.querySelector(".animation-target .attrName") &&
+ itemEl.querySelector(".animation-computed-timing-path")
+ );
+
+ const attrNameEl = itemEl.querySelector(".animation-target .attrName");
+ const regexp = new RegExp(`\\${selector}(\\.|$)`, "gi");
+ if (regexp.exec(attrNameEl.textContent)) {
+ return itemEl;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Find the <stop> element which has the given offset in the given linearGradientEl.
+ *
+ * @param {Element} linearGradientEl
+ * <linearGradient> element which has <stop> element.
+ * @param {Number} offset
+ * Float which represents the "offset" attribute of <stop>.
+ * @return {Element}
+ * If can't find suitable element, returns null.
+ */
+function findStopElement(linearGradientEl, offset) {
+ for (const stopEl of linearGradientEl.querySelectorAll("stop")) {
+ if (offset <= parseFloat(stopEl.getAttribute("offset"))) {
+ return stopEl;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Do test for keyframes-graph_computed-value-path-1/2.
+ *
+ * @param {Array} testData
+ */
+async function testKeyframesGraphComputedValuePath(testData) {
+ await addTab(URL_ROOT + "doc_multi_keyframes.html");
+ await removeAnimatedElementsExcept(testData.map(t => `.${t.targetClass}`));
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ for (const { properties, targetClass } of testData) {
+ info(`Checking keyframes graph for ${targetClass}`);
+ const onDetailRendered = animationInspector.once(
+ "animation-keyframes-rendered"
+ );
+ await clickOnAnimationByTargetSelector(
+ animationInspector,
+ panel,
+ `.${targetClass}`
+ );
+ await onDetailRendered;
+
+ for (const property of properties) {
+ const {
+ name,
+ computedValuePathClass,
+ expectedPathSegments,
+ expectedStopColors,
+ } = property;
+
+ const testTarget = `${name} in ${targetClass}`;
+ info(`Checking keyframes graph for ${testTarget}`);
+ info(`Checking keyframes graph path existence for ${testTarget}`);
+ const keyframesGraphPathEl = panel.querySelector(`.${name}`);
+ ok(
+ keyframesGraphPathEl,
+ `The keyframes graph path element of ${testTarget} should be existence`
+ );
+
+ info(`Checking computed value path existence for ${testTarget}`);
+ const computedValuePathEl = keyframesGraphPathEl.querySelector(
+ `.${computedValuePathClass}`
+ );
+ ok(
+ computedValuePathEl,
+ `The computed value path element of ${testTarget} should be existence`
+ );
+
+ info(`Checking path segments for ${testTarget}`);
+ const pathEl = computedValuePathEl.querySelector("path");
+ ok(pathEl, `The <path> element of ${testTarget} should be existence`);
+ assertPathSegments(pathEl, true, expectedPathSegments);
+
+ if (!expectedStopColors) {
+ continue;
+ }
+
+ info(`Checking linearGradient for ${testTarget}`);
+ const linearGradientEl =
+ computedValuePathEl.querySelector("linearGradient");
+ ok(
+ linearGradientEl,
+ `The <linearGradientEl> element of ${testTarget} should be existence`
+ );
+
+ for (const expectedStopColor of expectedStopColors) {
+ const { offset, color } = expectedStopColor;
+ assertLinearGradient(linearGradientEl, offset, color);
+ }
+ }
+ }
+}
+
+/**
+ * Check the adjusted current time and created time from specified two animations.
+ *
+ * @param {AnimationPlayerFront.state} animation1
+ * @param {AnimationPlayerFront.state} animation2
+ */
+function checkAdjustingTheTime(animation1, animation2) {
+ const adjustedCurrentTimeDiff =
+ animation2.currentTime / animation2.playbackRate -
+ animation1.currentTime / animation1.playbackRate;
+ const createdTimeDiff = animation1.createdTime - animation2.createdTime;
+ Assert.less(
+ Math.abs(adjustedCurrentTimeDiff - createdTimeDiff),
+ 0.1,
+ "Adjusted time is correct"
+ );
+}
diff --git a/devtools/client/inspector/animation/test/keyframes-graph_keyframe-marker_head.js b/devtools/client/inspector/animation/test/keyframes-graph_keyframe-marker_head.js
new file mode 100644
index 0000000000..97c7040553
--- /dev/null
+++ b/devtools/client/inspector/animation/test/keyframes-graph_keyframe-marker_head.js
@@ -0,0 +1,237 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* import-globals-from head.js */
+
+// Test for following keyframe marker.
+// * element existence
+// * title
+// * and marginInlineStart style
+
+const KEYFRAMES_TEST_DATA = [
+ {
+ targetClass: "multi-types",
+ properties: [
+ {
+ name: "background-color",
+ expectedValues: [
+ {
+ title: "rgb(255, 0, 0)",
+ marginInlineStart: "0%",
+ },
+ {
+ title: "rgb(0, 255, 0)",
+ marginInlineStart: "100%",
+ },
+ ],
+ },
+ {
+ name: "background-repeat",
+ expectedValues: [
+ {
+ title: "space round",
+ marginInlineStart: "0%",
+ },
+ {
+ title: "round space",
+ marginInlineStart: "100%",
+ },
+ ],
+ },
+ {
+ name: "font-size",
+ expectedValues: [
+ {
+ title: "10px",
+ marginInlineStart: "0%",
+ },
+ {
+ title: "20px",
+ marginInlineStart: "100%",
+ },
+ ],
+ },
+ {
+ name: "margin-left",
+ expectedValues: [
+ {
+ title: "0px",
+ marginInlineStart: "0%",
+ },
+ {
+ title: "100px",
+ marginInlineStart: "100%",
+ },
+ ],
+ },
+ {
+ name: "opacity",
+ expectedValues: [
+ {
+ title: "0",
+ marginInlineStart: "0%",
+ },
+ {
+ title: "1",
+ marginInlineStart: "100%",
+ },
+ ],
+ },
+ {
+ name: "text-align",
+ expectedValues: [
+ {
+ title: "right",
+ marginInlineStart: "0%",
+ },
+ {
+ title: "center",
+ marginInlineStart: "100%",
+ },
+ ],
+ },
+ {
+ name: "transform",
+ expectedValues: [
+ {
+ title: "translate(0px)",
+ marginInlineStart: "0%",
+ },
+ {
+ title: "translate(100px)",
+ marginInlineStart: "100%",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "narrow-offsets",
+ properties: [
+ {
+ name: "opacity",
+ expectedValues: [
+ {
+ title: "0",
+ marginInlineStart: "0%",
+ },
+ {
+ title: "1",
+ marginInlineStart: "10%",
+ },
+ {
+ title: "0",
+ marginInlineStart: "13%",
+ },
+ {
+ title: "1",
+ marginInlineStart: "100%",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "same-color",
+ properties: [
+ {
+ name: "background-color",
+ expectedValues: [
+ {
+ title: "rgb(0, 255, 0)",
+ marginInlineStart: "0%",
+ },
+ {
+ title: "rgb(0, 255, 0)",
+ marginInlineStart: "100%",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ targetClass: "currentcolor",
+ properties: [
+ {
+ name: "background-color",
+ expectedValues: [
+ {
+ title: "currentcolor",
+ marginInlineStart: "0%",
+ },
+ {
+ title: "rgb(0, 255, 0)",
+ marginInlineStart: "100%",
+ },
+ ],
+ },
+ ],
+ },
+];
+
+/**
+ * Do test for keyframes-graph_keyframe-marker-ltf/rtl.
+ *
+ * @param {Array} testData
+ */
+// eslint-disable-next-line no-unused-vars
+async function testKeyframesGraphKeyframesMarker() {
+ await addTab(URL_ROOT + "doc_multi_keyframes.html");
+ await removeAnimatedElementsExcept(
+ KEYFRAMES_TEST_DATA.map(t => `.${t.targetClass}`)
+ );
+ const { animationInspector, panel } = await openAnimationInspector();
+
+ for (const { properties, targetClass } of KEYFRAMES_TEST_DATA) {
+ info(`Checking keyframe marker for ${targetClass}`);
+ const onDetailRendered = animationInspector.once(
+ "animation-keyframes-rendered"
+ );
+ await clickOnAnimationByTargetSelector(
+ animationInspector,
+ panel,
+ `.${targetClass}`
+ );
+ await onDetailRendered;
+
+ for (const { name, expectedValues } of properties) {
+ const testTarget = `${name} in ${targetClass}`;
+ info(`Checking keyframe marker for ${testTarget}`);
+ info(`Checking keyframe marker existence for ${testTarget}`);
+ const markerEls = panel.querySelectorAll(
+ `.${name} .keyframe-marker-item`
+ );
+ is(
+ markerEls.length,
+ expectedValues.length,
+ `Count of keyframe marker elements of ${testTarget} ` +
+ `should be ${expectedValues.length}`
+ );
+
+ for (let i = 0; i < expectedValues.length; i++) {
+ const hintTarget = `.keyframe-marker-item[${i}] of ${testTarget}`;
+
+ info(`Checking ${hintTarget}`);
+ const markerEl = markerEls[i];
+ const expectedValue = expectedValues[i];
+
+ info(`Checking title in ${hintTarget}`);
+ is(
+ markerEl.getAttribute("title"),
+ expectedValue.title,
+ `title in ${hintTarget} should be ${expectedValue.title}`
+ );
+
+ info(`Checking marginInlineStart style in ${hintTarget}`);
+ is(
+ markerEl.style.marginInlineStart,
+ expectedValue.marginInlineStart,
+ `marginInlineStart in ${hintTarget} should be ` +
+ `${expectedValue.marginInlineStart}`
+ );
+ }
+ }
+ }
+}
diff --git a/devtools/client/inspector/animation/test/summary-graph_computed-timing-path_head.js b/devtools/client/inspector/animation/test/summary-graph_computed-timing-path_head.js
new file mode 100644
index 0000000000..8516e96fa3
--- /dev/null
+++ b/devtools/client/inspector/animation/test/summary-graph_computed-timing-path_head.js
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* import-globals-from head.js */
+
+/**
+ * Test for computed timing path on summary graph using given test data.
+ * @param {Array} testData
+ */
+// eslint-disable-next-line no-unused-vars
+async function testComputedTimingPath(testData) {
+ await addTab(URL_ROOT + "doc_multi_timings.html");
+ await removeAnimatedElementsExcept(testData.map(t => `.${t.targetClass}`));
+ const { panel } = await openAnimationInspector();
+
+ for (const {
+ expectedDelayPath,
+ expectedEndDelayPath,
+ expectedForwardsPath,
+ expectedIterationPathList,
+ isInfinity,
+ targetClass,
+ } of testData) {
+ const animationItemEl = await findAnimationItemByTargetSelector(
+ panel,
+ `.${targetClass}`
+ );
+
+ info(`Checking computed timing path existance for ${targetClass}`);
+ const computedTimingPathEl = animationItemEl.querySelector(
+ ".animation-computed-timing-path"
+ );
+ ok(
+ computedTimingPathEl,
+ "The computed timing path element should be in each animation item element"
+ );
+
+ info(`Checking delay path for ${targetClass}`);
+ const delayPathEl = computedTimingPathEl.querySelector(
+ ".animation-delay-path"
+ );
+
+ if (expectedDelayPath) {
+ ok(delayPathEl, "delay path should be existance");
+ assertPathSegments(delayPathEl, true, expectedDelayPath);
+ } else {
+ ok(!delayPathEl, "delay path should not be existance");
+ }
+
+ info(`Checking iteration path list for ${targetClass}`);
+ const iterationPathEls = computedTimingPathEl.querySelectorAll(
+ ".animation-iteration-path"
+ );
+ is(
+ iterationPathEls.length,
+ expectedIterationPathList.length,
+ `Number of iteration path should be ${expectedIterationPathList.length}`
+ );
+
+ for (const [j, iterationPathEl] of iterationPathEls.entries()) {
+ assertPathSegments(iterationPathEl, true, expectedIterationPathList[j]);
+
+ info(`Checking infinity ${targetClass}`);
+ if (isInfinity && j >= 1) {
+ ok(
+ iterationPathEl.classList.contains("infinity"),
+ "iteration path should have 'infinity' class"
+ );
+ } else {
+ ok(
+ !iterationPathEl.classList.contains("infinity"),
+ "iteration path should not have 'infinity' class"
+ );
+ }
+ }
+
+ info(`Checking endDelay path for ${targetClass}`);
+ const endDelayPathEl = computedTimingPathEl.querySelector(
+ ".animation-enddelay-path"
+ );
+
+ if (expectedEndDelayPath) {
+ ok(endDelayPathEl, "endDelay path should be existance");
+ assertPathSegments(endDelayPathEl, true, expectedEndDelayPath);
+ } else {
+ ok(!endDelayPathEl, "endDelay path should not be existance");
+ }
+
+ info(`Checking forwards fill path for ${targetClass}`);
+ const forwardsPathEl = computedTimingPathEl.querySelector(
+ ".animation-fill-forwards-path"
+ );
+
+ if (expectedForwardsPath) {
+ ok(forwardsPathEl, "forwards path should be existance");
+ assertPathSegments(forwardsPathEl, true, expectedForwardsPath);
+ } else {
+ ok(!forwardsPathEl, "forwards path should not be existance");
+ }
+ }
+}
diff --git a/devtools/client/inspector/animation/test/summary-graph_delay-sign_head.js b/devtools/client/inspector/animation/test/summary-graph_delay-sign_head.js
new file mode 100644
index 0000000000..fd601821b6
--- /dev/null
+++ b/devtools/client/inspector/animation/test/summary-graph_delay-sign_head.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* import-globals-from head.js */
+
+// Test for following DelaySign component works.
+// * element existance
+// * marginInlineStart position
+// * width
+// * additinal class
+
+const TEST_DATA = [
+ {
+ targetClass: "delay-positive",
+ expectedResult: {
+ marginInlineStart: "25%",
+ width: "25%",
+ },
+ },
+ {
+ targetClass: "delay-negative",
+ expectedResult: {
+ additionalClass: "negative",
+ marginInlineStart: "0%",
+ width: "25%",
+ },
+ },
+ {
+ targetClass: "fill-backwards-with-delay-iterationstart",
+ expectedResult: {
+ additionalClass: "fill",
+ marginInlineStart: "25%",
+ width: "25%",
+ },
+ },
+ {
+ targetClass: "fill-both",
+ },
+ {
+ targetClass: "fill-both-width-delay-iterationstart",
+ expectedResult: {
+ additionalClass: "fill",
+ marginInlineStart: "25%",
+ width: "25%",
+ },
+ },
+ {
+ targetClass: "keyframes-easing-step",
+ },
+];
+
+// eslint-disable-next-line no-unused-vars
+async function testSummaryGraphDelaySign() {
+ await addTab(URL_ROOT + "doc_multi_timings.html");
+ await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`));
+ const { panel } = await openAnimationInspector();
+
+ for (const { targetClass, expectedResult } of TEST_DATA) {
+ const animationItemEl = await findAnimationItemByTargetSelector(
+ panel,
+ `.${targetClass}`
+ );
+
+ info(`Checking delay sign existance for ${targetClass}`);
+ const delaySignEl = animationItemEl.querySelector(".animation-delay-sign");
+
+ if (expectedResult) {
+ ok(
+ delaySignEl,
+ "The delay sign element should be in animation item element"
+ );
+
+ function assertExpected(key) {
+ const actual = parseFloat(delaySignEl.style[key]);
+ const expected = parseFloat(expectedResult[key]);
+ ok(
+ Math.abs(actual - expected) < 0.01,
+ `${key} should be ${expected} (got ${actual})`
+ );
+ }
+
+ assertExpected(`marginInlineStart`);
+ assertExpected(`width`);
+
+ if (expectedResult.additionalClass) {
+ ok(
+ delaySignEl.classList.contains(expectedResult.additionalClass),
+ `delay sign element should have ${expectedResult.additionalClass} class`
+ );
+ } else {
+ ok(
+ !delaySignEl.classList.contains(expectedResult.additionalClass),
+ "delay sign element should not have " +
+ `${expectedResult.additionalClass} class`
+ );
+ }
+ } else {
+ ok(
+ !delaySignEl,
+ "The delay sign element should not be in animation item element"
+ );
+ }
+ }
+}
diff --git a/devtools/client/inspector/animation/test/summary-graph_end-delay-sign_head.js b/devtools/client/inspector/animation/test/summary-graph_end-delay-sign_head.js
new file mode 100644
index 0000000000..f87a554420
--- /dev/null
+++ b/devtools/client/inspector/animation/test/summary-graph_end-delay-sign_head.js
@@ -0,0 +1,100 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* import-globals-from head.js */
+
+// Test for following EndDelaySign component works.
+// * element existance
+// * marginInlineStart position
+// * width
+// * additinal class
+
+const TEST_DATA = [
+ {
+ targetClass: "enddelay-positive",
+ expectedResult: {
+ marginInlineStart: "75%",
+ width: "25%",
+ },
+ },
+ {
+ targetClass: "enddelay-negative",
+ expectedResult: {
+ additionalClass: "negative",
+ marginInlineStart: "50%",
+ width: "25%",
+ },
+ },
+ {
+ targetClass: "enddelay-with-fill-forwards",
+ expectedResult: {
+ additionalClass: "fill",
+ marginInlineStart: "75%",
+ width: "25%",
+ },
+ },
+ {
+ targetClass: "enddelay-with-iterations-infinity",
+ },
+ {
+ targetClass: "delay-negative",
+ },
+];
+
+// eslint-disable-next-line no-unused-vars
+async function testSummaryGraphEndDelaySign() {
+ await addTab(URL_ROOT + "doc_multi_timings.html");
+ await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`));
+ const { panel } = await openAnimationInspector();
+
+ for (const { targetClass, expectedResult } of TEST_DATA) {
+ const animationItemEl = await findAnimationItemByTargetSelector(
+ panel,
+ `.${targetClass}`
+ );
+
+ info(`Checking endDelay sign existance for ${targetClass}`);
+ const endDelaySignEl = animationItemEl.querySelector(
+ ".animation-end-delay-sign"
+ );
+
+ if (expectedResult) {
+ ok(
+ endDelaySignEl,
+ "The endDelay sign element should be in animation item element"
+ );
+
+ function assertExpected(key) {
+ const actual = parseFloat(endDelaySignEl.style[key]);
+ const expected = parseFloat(expectedResult[key]);
+ ok(
+ Math.abs(actual - expected) < 0.01,
+ `${key} should be ${expected} (got ${actual})`
+ );
+ }
+
+ assertExpected(`marginInlineStart`);
+ assertExpected(`width`);
+
+ if (expectedResult.additionalClass) {
+ ok(
+ endDelaySignEl.classList.contains(expectedResult.additionalClass),
+ `endDelay sign element should have ${expectedResult.additionalClass} class`
+ );
+ } else {
+ ok(
+ !endDelaySignEl.classList.contains(expectedResult.additionalClass),
+ "endDelay sign element should not have " +
+ `${expectedResult.additionalClass} class`
+ );
+ }
+ } else {
+ ok(
+ !endDelaySignEl,
+ "The endDelay sign element should not be in animation item element"
+ );
+ }
+ }
+}
diff --git a/devtools/client/inspector/animation/utils/graph-helper.js b/devtools/client/inspector/animation/utils/graph-helper.js
new file mode 100644
index 0000000000..cca2713254
--- /dev/null
+++ b/devtools/client/inspector/animation/utils/graph-helper.js
@@ -0,0 +1,332 @@
+/* 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";
+
+// BOUND_EXCLUDING_TIME should be less than 1ms and is used to exclude start
+// and end bounds when dividing duration in createPathSegments.
+const BOUND_EXCLUDING_TIME = 0.001;
+// We define default graph height since if the height of viewport in SVG is
+// too small (e.g. 1), vector-effect may not be able to calculate correctly.
+const DEFAULT_GRAPH_HEIGHT = 100;
+// Default animation duration for keyframes graph.
+const DEFAULT_KEYFRAMES_GRAPH_DURATION = 1000;
+// DEFAULT_MIN_PROGRESS_THRESHOLD shoud be between more than 0 to 1.
+const DEFAULT_MIN_PROGRESS_THRESHOLD = 0.1;
+// In the createPathSegments function, an animation duration is divided by
+// DEFAULT_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
+// DEFAULT_MIN_PROGRESS_THRESHOLD * DEFAULT_GRAPH_HEIGHT, then createPathSegments
+// re-divides by DEFAULT_DURATION_RESOLUTION.
+// DEFAULT_DURATION_RESOLUTION shoud be integer and more than 2.
+const DEFAULT_DURATION_RESOLUTION = 4;
+// Stroke width for easing hint.
+const DEFAULT_EASING_HINT_STROKE_WIDTH = 5;
+
+/**
+ * The helper class for creating summary graph.
+ */
+class SummaryGraphHelper {
+ /**
+ * Constructor.
+ *
+ * @param {Object} state
+ * State of animation.
+ * @param {Array} keyframes
+ * Array of keyframe.
+ * @param {Number} totalDuration
+ * Total displayable duration.
+ * @param {Number} minSegmentDuration
+ * Minimum segment duration.
+ * @param {Function} getValueFunc
+ * Which returns graph value of given time.
+ * The function should return a number value between 0 - 1.
+ * e.g. time => { return 1.0 };
+ * @param {Function} toPathStringFunc
+ * Which returns a path string for 'd' attribute for <path> from given segments.
+ */
+ constructor(
+ state,
+ keyframes,
+ totalDuration,
+ minSegmentDuration,
+ getValueFunc,
+ toPathStringFunc
+ ) {
+ this.totalDuration = totalDuration;
+ this.minSegmentDuration = minSegmentDuration;
+ this.minProgressThreshold =
+ getPreferredProgressThreshold(state, keyframes) * DEFAULT_GRAPH_HEIGHT;
+ this.durationResolution = getPreferredDurationResolution(keyframes);
+ this.getValue = getValueFunc;
+ this.toPathString = toPathStringFunc;
+
+ this.getSegment = this.getSegment.bind(this);
+ }
+
+ /**
+ * Create the path segments from given parameters.
+ *
+ * @param {Number} startTime
+ * Starting time of animation.
+ * @param {Number} endTime
+ * Ending time of animation.
+ * @return {Array}
+ * Array of path segment.
+ * e.g.[{x: {Number} time, y: {Number} progress}, ...]
+ */
+ createPathSegments(startTime, endTime) {
+ return createPathSegments(
+ startTime,
+ endTime,
+ this.minSegmentDuration,
+ this.minProgressThreshold,
+ this.durationResolution,
+ this.getSegment
+ );
+ }
+
+ /**
+ * Return a coordinate as a graph segment at given time.
+ *
+ * @param {Number} time
+ * @return {Object}
+ * { x: Number, y: Number }
+ */
+ getSegment(time) {
+ const value = this.getValue(time);
+ return { x: time, y: value * DEFAULT_GRAPH_HEIGHT };
+ }
+}
+
+/**
+ * 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 {Number} resolution
+ * Duration resolution for first time.
+ * @param {Function} getSegment
+ * A function that calculate the graph segment.
+ * @return {Array}
+ * Array of path segment.
+ * e.g.[{x: {Number} time, y: {Number} progress}, ...]
+ */
+function createPathSegments(
+ startTime,
+ endTime,
+ minSegmentDuration,
+ minProgressThreshold,
+ resolution,
+ getSegment
+) {
+ // If the duration is too short, early return.
+ if (endTime - startTime < minSegmentDuration) {
+ return [getSegment(startTime), getSegment(endTime)];
+ }
+
+ // Otherwise, start creating segments.
+ let pathSegments = [];
+
+ // Append the segment for the startTime position.
+ const startTimeSegment = getSegment(startTime);
+ pathSegments.push(startTimeSegment);
+ let previousSegment = startTimeSegment;
+
+ // Split the duration in equal intervals, and iterate over them.
+ // See the definition of DEFAULT_DURATION_RESOLUTION for more information about this.
+ const interval = (endTime - startTime) / resolution;
+ for (let index = 1; index <= resolution; index++) {
+ // Create a segment for this interval.
+ const currentSegment = 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 BOUND_EXCLUDING_TIME).
+ const nextStartTime = previousSegment.x + BOUND_EXCLUDING_TIME;
+ const nextEndTime = currentSegment.x - BOUND_EXCLUDING_TIME;
+ const segments = createPathSegments(
+ nextStartTime,
+ nextEndTime,
+ minSegmentDuration,
+ minProgressThreshold,
+ DEFAULT_DURATION_RESOLUTION,
+ getSegment
+ );
+ pathSegments = pathSegments.concat(segments);
+ }
+
+ pathSegments.push(currentSegment);
+ previousSegment = currentSegment;
+ }
+
+ return pathSegments;
+}
+
+/**
+ * Create a function which is used as parameter (toPathStringFunc) in constructor
+ * of SummaryGraphHelper.
+ *
+ * @param {Number} endTime
+ * end time of animation
+ * e.g. 200
+ * @param {Number} playbackRate
+ * playback rate of animation
+ * e.g. -1
+ * @return {Function}
+ */
+function createSummaryGraphPathStringFunction(endTime, playbackRate) {
+ return segments => {
+ segments = mapSegmentsToPlaybackRate(segments, endTime, playbackRate);
+ const firstSegment = segments[0];
+ let pathString = `M${firstSegment.x},0 `;
+ pathString += toPathString(segments);
+ const lastSegment = segments[segments.length - 1];
+ pathString += `L${lastSegment.x},0 Z`;
+ return pathString;
+ };
+}
+
+/**
+ * Return preferred duration resolution.
+ * This corresponds to narrow interval keyframe offset.
+ *
+ * @param {Array} keyframes
+ * Array of keyframe.
+ * @return {Number}
+ * Preferred duration resolution.
+ */
+function getPreferredDurationResolution(keyframes) {
+ if (!keyframes) {
+ return DEFAULT_DURATION_RESOLUTION;
+ }
+
+ let durationResolution = DEFAULT_DURATION_RESOLUTION;
+ let previousOffset = 0;
+ for (const keyframe of keyframes) {
+ if (previousOffset && previousOffset != keyframe.offset) {
+ const interval = keyframe.offset - previousOffset;
+ durationResolution = Math.max(
+ durationResolution,
+ Math.ceil(1 / interval)
+ );
+ }
+ previousOffset = keyframe.offset;
+ }
+
+ return durationResolution;
+}
+
+/**
+ * Return preferred progress threshold to render summary graph.
+ *
+ * @param {Object} state
+ * State of animation.
+ * @param {Array} keyframes
+ * Array of keyframe.
+ * @return {float}
+ * Preferred threshold.
+ */
+function getPreferredProgressThreshold(state, keyframes) {
+ const steps = getStepsCount(state.easing);
+ const threshold = Math.min(DEFAULT_MIN_PROGRESS_THRESHOLD, 1 / (steps + 1));
+
+ if (!keyframes) {
+ return threshold;
+ }
+
+ return Math.min(
+ threshold,
+ getPreferredProgressThresholdByKeyframes(keyframes)
+ );
+}
+
+/**
+ * Return preferred progress threshold by keyframes.
+ *
+ * @param {Array} keyframes
+ * Array of keyframe.
+ * @return {float}
+ * Preferred threshold.
+ */
+function getPreferredProgressThresholdByKeyframes(keyframes) {
+ let threshold = DEFAULT_MIN_PROGRESS_THRESHOLD;
+
+ for (let i = 0; i < keyframes.length - 1; i++) {
+ const keyframe = keyframes[i];
+
+ if (!keyframe.easing) {
+ continue;
+ }
+
+ const steps = getStepsCount(keyframe.easing);
+
+ if (steps) {
+ const nextKeyframe = keyframes[i + 1];
+ threshold = Math.min(
+ threshold,
+ (1 / (steps + 1)) * (nextKeyframe.offset - keyframe.offset)
+ );
+ }
+ }
+
+ return threshold;
+}
+
+function getStepsCount(easing) {
+ const stepsFunction = easing.match(/(steps)\((\d+)/);
+ return stepsFunction ? parseInt(stepsFunction[2], 10) : 0;
+}
+
+function mapSegmentsToPlaybackRate(segments, endTime, playbackRate) {
+ if (playbackRate > 0) {
+ return segments;
+ }
+
+ return segments.map(segment => {
+ segment.x = endTime - segment.x;
+ return segment;
+ });
+}
+
+/**
+ * Return path string for 'd' attribute for <path> from given segments.
+ *
+ * @param {Array} segments
+ * e.g. [{ x: 100, y: 0 }, { x: 200, y: 1 }]
+ * @return {String}
+ * Path string.
+ * e.g. "L100,0 L200,1"
+ */
+function toPathString(segments) {
+ let pathString = "";
+ segments.forEach(segment => {
+ pathString += `L${segment.x},${segment.y} `;
+ });
+ return pathString;
+}
+
+exports.createPathSegments = createPathSegments;
+exports.createSummaryGraphPathStringFunction =
+ createSummaryGraphPathStringFunction;
+exports.DEFAULT_DURATION_RESOLUTION = DEFAULT_DURATION_RESOLUTION;
+exports.DEFAULT_EASING_HINT_STROKE_WIDTH = DEFAULT_EASING_HINT_STROKE_WIDTH;
+exports.DEFAULT_GRAPH_HEIGHT = DEFAULT_GRAPH_HEIGHT;
+exports.DEFAULT_KEYFRAMES_GRAPH_DURATION = DEFAULT_KEYFRAMES_GRAPH_DURATION;
+exports.getPreferredProgressThresholdByKeyframes =
+ getPreferredProgressThresholdByKeyframes;
+exports.SummaryGraphHelper = SummaryGraphHelper;
+exports.toPathString = toPathString;
diff --git a/devtools/client/inspector/animation/utils/l10n.js b/devtools/client/inspector/animation/utils/l10n.js
new file mode 100644
index 0000000000..6fffa98b65
--- /dev/null
+++ b/devtools/client/inspector/animation/utils/l10n.js
@@ -0,0 +1,46 @@
+/* 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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const L10N = new LocalizationHelper(
+ "devtools/client/locales/animationinspector.properties"
+);
+const INSPECTOR_L10N = new LocalizationHelper(
+ "devtools/client/locales/inspector.properties"
+);
+
+/**
+ * Get a formatted title for this animation. This will be either:
+ * "%S", "%S : CSS Transition", "%S : CSS Animation",
+ * "%S : Script Animation", or "Script Animation", depending
+ * if the server provides the type, what type it is and if the animation
+ * has a name.
+ *
+ * @param {Object} state
+ */
+function getFormattedTitle(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);
+}
+
+module.exports = {
+ getFormatStr: (...args) => L10N.getFormatStr(...args),
+ getFormattedTitle,
+ getInspectorStr: (...args) => INSPECTOR_L10N.getStr(...args),
+ getStr: (...args) => L10N.getStr(...args),
+ numberWithDecimals: (...args) => L10N.numberWithDecimals(...args),
+};
diff --git a/devtools/client/inspector/animation/utils/moz.build b/devtools/client/inspector/animation/utils/moz.build
new file mode 100644
index 0000000000..ae73627a29
--- /dev/null
+++ b/devtools/client/inspector/animation/utils/moz.build
@@ -0,0 +1,10 @@
+# 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(
+ "graph-helper.js",
+ "l10n.js",
+ "timescale.js",
+ "utils.js",
+)
diff --git a/devtools/client/inspector/animation/utils/timescale.js b/devtools/client/inspector/animation/utils/timescale.js
new file mode 100644
index 0000000000..77297f748c
--- /dev/null
+++ b/devtools/client/inspector/animation/utils/timescale.js
@@ -0,0 +1,145 @@
+/* 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 {
+ getFormatStr,
+} = require("resource://devtools/client/inspector/animation/utils/l10n.js");
+
+// If total duration for all animations is eqaul to or less than
+// TIME_FORMAT_MAX_DURATION_IN_MS, the text which expresses time is in milliseconds,
+// and seconds otherwise. Use in formatTime function.
+const TIME_FORMAT_MAX_DURATION_IN_MS = 4000;
+
+/**
+ * TimeScale object holds the total duration, start time and end time and zero position
+ * time information for all animations which should be displayed, and is used to calculate
+ * the displayed area for each animation.
+ */
+class TimeScale {
+ constructor(animations) {
+ let resultCurrentTime = -Number.MAX_VALUE;
+ let resultMinStartTime = Infinity;
+ let resultMaxEndTime = 0;
+ let resultZeroPositionTime = 0;
+
+ for (const animation of animations) {
+ const {
+ currentTime,
+ currentTimeAtCreated,
+ delay,
+ endTime,
+ startTimeAtCreated,
+ } = animation.state.absoluteValues;
+ let { startTime } = animation.state.absoluteValues;
+
+ const negativeDelay = Math.min(delay, 0);
+ let zeroPositionTime = 0;
+
+ // To shift the zero position time is the following two patterns.
+ // * Animation has negative current time which is smaller than negative delay.
+ // * Animation has negative delay.
+ // Furthermore, we should override the zero position time if we will need to
+ // expand the duration due to this negative current time or negative delay of
+ // this target animation.
+ if (currentTimeAtCreated < negativeDelay) {
+ startTime = startTimeAtCreated;
+ zeroPositionTime = Math.abs(currentTimeAtCreated);
+ } else if (negativeDelay < 0) {
+ zeroPositionTime = Math.abs(negativeDelay);
+ }
+
+ if (startTime < resultMinStartTime) {
+ resultMinStartTime = startTime;
+ // Override the previous calculated zero position only if the duration will be
+ // expanded.
+ resultZeroPositionTime = zeroPositionTime;
+ } else {
+ resultZeroPositionTime = Math.max(
+ resultZeroPositionTime,
+ zeroPositionTime
+ );
+ }
+
+ resultMaxEndTime = Math.max(resultMaxEndTime, endTime);
+ resultCurrentTime = Math.max(resultCurrentTime, currentTime);
+ }
+
+ this.minStartTime = resultMinStartTime;
+ this.maxEndTime = resultMaxEndTime;
+ this.currentTime = resultCurrentTime;
+ this.zeroPositionTime = resultZeroPositionTime;
+ }
+
+ /**
+ * Convert a distance in % to a time, in the current time scale. The time
+ * will be relative to the zero position time.
+ * i.e., If zeroPositionTime will be negative and specified time is shorter
+ * than the absolute value of zero position time, relative time will be
+ * negative time.
+ *
+ * @param {Number} distance
+ * @return {Number}
+ */
+ distanceToRelativeTime(distance) {
+ return (this.getDuration() * distance) / 100 - this.zeroPositionTime;
+ }
+
+ /**
+ * Depending on the time scale, format the given time as milliseconds or
+ * seconds.
+ *
+ * @param {Number} time
+ * @return {String} The formatted time string.
+ */
+ formatTime(time) {
+ // Ignore negative zero
+ if (Math.abs(time) < 1 / 1000) {
+ time = 0.0;
+ }
+
+ // Format in milliseconds if the total duration is short enough.
+ if (this.getDuration() <= TIME_FORMAT_MAX_DURATION_IN_MS) {
+ return getFormatStr("timeline.timeGraduationLabel", time.toFixed(0));
+ }
+
+ // Otherwise format in seconds.
+ return getFormatStr("player.timeLabel", (time / 1000).toFixed(1));
+ }
+
+ /**
+ * Return entire animations duration.
+ *
+ * @return {Number} duration
+ */
+ getDuration() {
+ return this.maxEndTime - this.minStartTime;
+ }
+
+ /**
+ * Return current time of this time scale represents.
+ *
+ * @return {Number}
+ */
+ getCurrentTime() {
+ return this.currentTime - this.minStartTime;
+ }
+
+ /**
+ * Return end time of given animation.
+ * This time does not include playbackRate and cratedTime.
+ * Also, if the animation has infinite iterations, this returns Infinity.
+ *
+ * @param {Object} animation
+ * @return {Numbber} end time
+ */
+ getEndTime({ state }) {
+ return state.iterationCount
+ ? state.delay + state.duration * state.iterationCount + state.endDelay
+ : Infinity;
+ }
+}
+
+module.exports = TimeScale;
diff --git a/devtools/client/inspector/animation/utils/utils.js b/devtools/client/inspector/animation/utils/utils.js
new file mode 100644
index 0000000000..9040c27213
--- /dev/null
+++ b/devtools/client/inspector/animation/utils/utils.js
@@ -0,0 +1,70 @@
+/* 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";
+
+// The maximum number of times we can 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];
+
+/**
+ * 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) {
+ if (!minTimeInterval) {
+ return 0;
+ }
+
+ let numIters = 0;
+ let multiplier = 1;
+ 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;
+ }
+}
+
+/**
+ * Check whether or not the given list of animations has an iteration count of infinite.
+ *
+ * @param {Array} animations.
+ * @return {Boolean} true if there is an animation in the list of animations
+ * whose animation iteration count is infinite.
+ */
+function hasAnimationIterationCountInfinite(animations) {
+ return animations.some(({ state }) => !state.iterationCount);
+}
+
+/**
+ * Check wether the animations are running at least one.
+ *
+ * @param {Array} animations.
+ * @return {Boolean} true: running
+ */
+function hasRunningAnimation(animations) {
+ return animations.some(({ state }) => state.playState === "running");
+}
+
+exports.findOptimalTimeInterval = findOptimalTimeInterval;
+exports.hasAnimationIterationCountInfinite = hasAnimationIterationCountInfinite;
+exports.hasRunningAnimation = hasRunningAnimation;