diff options
Diffstat (limited to 'toolkit/devtools/timeline')
20 files changed, 1702 insertions, 0 deletions
diff --git a/toolkit/devtools/timeline/moz.build b/toolkit/devtools/timeline/moz.build new file mode 100644 index 000000000..b2f2d6130 --- /dev/null +++ b/toolkit/devtools/timeline/moz.build @@ -0,0 +1,10 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +EXTRA_JS_MODULES.devtools.timeline += [ + 'panel.js', +] + +BROWSER_CHROME_MANIFESTS += ['test/browser.ini'] diff --git a/toolkit/devtools/timeline/panel.js b/toolkit/devtools/timeline/panel.js new file mode 100644 index 000000000..a6e801d9e --- /dev/null +++ b/toolkit/devtools/timeline/panel.js @@ -0,0 +1,65 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { Cc, Ci, Cu, Cr } = require("chrome"); + +Cu.import("resource://gre/modules/Task.jsm"); + +loader.lazyRequireGetter(this, "promise"); +loader.lazyRequireGetter(this, "EventEmitter", + "devtools/toolkit/event-emitter"); + +loader.lazyRequireGetter(this, "TimelineFront", + "devtools/server/actors/timeline", true); + +function TimelinePanel(iframeWindow, toolbox) { + this.panelWin = iframeWindow; + this._toolbox = toolbox; + + EventEmitter.decorate(this); +}; + +exports.TimelinePanel = TimelinePanel; + +TimelinePanel.prototype = { + /** + * Open is effectively an asynchronous constructor. + * + * @return object + * A promise that is resolved when the timeline completes opening. + */ + open: Task.async(function*() { + // Local debugging needs to make the target remote. + yield this.target.makeRemote(); + + this.panelWin.gToolbox = this._toolbox; + this.panelWin.gTarget = this.target; + this.panelWin.gFront = new TimelineFront(this.target.client, this.target.form); + yield this.panelWin.startupTimeline(); + + this.isReady = true; + this.emit("ready"); + return this; + }), + + // DevToolPanel API + + get target() this._toolbox.target, + + destroy: Task.async(function*() { + // Make sure this panel is not already destroyed. + if (this._destroyed) { + return; + } + + yield this.panelWin.shutdownTimeline(); + // Destroy front to ensure packet handler is removed from client + this.panelWin.gFront.destroy(); + this.emit("destroyed"); + this._destroyed = true; + }) +}; diff --git a/toolkit/devtools/timeline/test/browser.ini b/toolkit/devtools/timeline/test/browser.ini new file mode 100644 index 000000000..6f51c8020 --- /dev/null +++ b/toolkit/devtools/timeline/test/browser.ini @@ -0,0 +1,21 @@ +[DEFAULT] +subsuite = devtools +support-files = + doc_simple-test.html + head.js + +[browser_timeline_aaa_run_first_leaktest.js] +[browser_timeline_filters.js] +[browser_timeline_overview-initial-selection-01.js] +skip-if = (os == 'mac' && debug) # bug 1139591 +[browser_timeline_overview-initial-selection-02.js] +skip-if = (os == 'mac' && debug) # bug 1139591 +[browser_timeline_overview-update.js] +[browser_timeline_overview-theme.js] +[browser_timeline_panels.js] +[browser_timeline_recording-without-memory.js] +[browser_timeline_recording.js] +[browser_timeline_waterfall-background.js] +[browser_timeline_waterfall-generic.js] +[browser_timeline_waterfall-styles.js] +[browser_timeline_waterfall-sidebar.js] diff --git a/toolkit/devtools/timeline/test/browser_timeline_aaa_run_first_leaktest.js b/toolkit/devtools/timeline/test/browser_timeline_aaa_run_first_leaktest.js new file mode 100644 index 000000000..33ecca920 --- /dev/null +++ b/toolkit/devtools/timeline/test/browser_timeline_aaa_run_first_leaktest.js @@ -0,0 +1,18 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the timeline leaks on initialization and sudden destruction. + * You can also use this initialization format as a template for other tests. + */ + +add_task(function*() { + let { target, panel } = yield initTimelinePanel(SIMPLE_URL); + + ok(target, "Should have a target available."); + ok(panel, "Should have a panel available."); + + ok(panel.panelWin.gToolbox, "Should have a toolbox reference on the panel window."); + ok(panel.panelWin.gTarget, "Should have a target reference on the panel window."); + ok(panel.panelWin.gFront, "Should have a front reference on the panel window."); +}); diff --git a/toolkit/devtools/timeline/test/browser_timeline_filters.js b/toolkit/devtools/timeline/test/browser_timeline_filters.js new file mode 100644 index 000000000..99c996a19 --- /dev/null +++ b/toolkit/devtools/timeline/test/browser_timeline_filters.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests markers filtering mechanism. + */ + +add_task(function*() { + let { target, panel } = yield initTimelinePanel(SIMPLE_URL); + let { $, $$, TimelineController, TimelineView } = panel.panelWin; + + yield TimelineController.toggleRecording(); + ok(true, "Recording has started."); + + yield waitUntil(() => { + // Wait until we get 3 different markers. + let markers = TimelineController.getMarkers(); + return markers.some(m => m.name == "Styles") && + markers.some(m => m.name == "Reflow") && + markers.some(m => m.name == "Paint"); + }); + + yield TimelineController.toggleRecording(); + + let overview = TimelineView.markersOverview; + let waterfall = TimelineView.waterfall; + + // Select everything + overview.setSelection({ start: 0, end: overview.width }) + + $("#filter-button").click(); + + yield waitUntil(() => !waterfall._outstandingMarkers.length); + + let menuItem1 = $("menuitem[marker-type=Styles]"); + let menuItem2 = $("menuitem[marker-type=Reflow]"); + let menuItem3 = $("menuitem[marker-type=Paint]"); + + let originalHeight = overview.fixedHeight; + + ok($(".waterfall-marker-bar[type=Styles]"), "Found at least one 'Styles' marker (1)"); + ok($(".waterfall-marker-bar[type=Reflow]"), "Found at least one 'Reflow' marker (1)"); + ok($(".waterfall-marker-bar[type=Paint]"), "Found at least one 'Paint' marker (1)"); + + let heightBefore = overview.fixedHeight; + EventUtils.synthesizeMouseAtCenter(menuItem1, {type: "mouseup"}, panel.panelWin); + yield once(menuItem1, "command"); + + yield waitUntil(() => !waterfall._outstandingMarkers.length); + + is(overview.fixedHeight, heightBefore, "Overview height hasn't changed"); + ok(!$(".waterfall-marker-bar[type=Styles]"), "No 'Styles' marker (2)"); + ok($(".waterfall-marker-bar[type=Reflow]"), "Found at least one 'Reflow' marker (2)"); + ok($(".waterfall-marker-bar[type=Paint]"), "Found at least one 'Paint' marker (2)"); + + heightBefore = overview.fixedHeight; + EventUtils.synthesizeMouseAtCenter(menuItem2, {type: "mouseup"}, panel.panelWin); + yield once(menuItem2, "command"); + + yield waitUntil(() => !waterfall._outstandingMarkers.length); + + is(overview.fixedHeight, heightBefore, "Overview height hasn't changed"); + ok(!$(".waterfall-marker-bar[type=Styles]"), "No 'Styles' marker (3)"); + ok(!$(".waterfall-marker-bar[type=Reflow]"), "No 'Reflow' marker (3)"); + ok($(".waterfall-marker-bar[type=Paint]"), "Found at least one 'Paint' marker (3)"); + + heightBefore = overview.fixedHeight; + EventUtils.synthesizeMouseAtCenter(menuItem3, {type: "mouseup"}, panel.panelWin); + yield once(menuItem3, "command"); + + yield waitUntil(() => !waterfall._outstandingMarkers.length); + + // A row is 11px. See markers-overview.js + is(overview.fixedHeight, heightBefore - 11, "Overview is smaller"); + ok(!$(".waterfall-marker-bar[type=Styles]"), "No 'Styles' marker (4)"); + ok(!$(".waterfall-marker-bar[type=Reflow]"), "No 'Reflow' marker (4)"); + ok(!$(".waterfall-marker-bar[type=Paint]"), "No 'Paint' marker (4)"); + + for (let item of [menuItem1, menuItem2, menuItem3]) { + EventUtils.synthesizeMouseAtCenter(item, {type: "mouseup"}, panel.panelWin); + yield once(item, "command"); + } + + yield waitUntil(() => !waterfall._outstandingMarkers.length); + + ok($(".waterfall-marker-bar[type=Styles]"), "Found at least one 'Styles' marker (5)"); + ok($(".waterfall-marker-bar[type=Reflow]"), "Found at least one 'Reflow' marker (5)"); + ok($(".waterfall-marker-bar[type=Paint]"), "Found at least one 'Paint' marker (5)"); + + is(overview.fixedHeight, originalHeight, "Overview restored"); + + $(".waterfall-marker-bar[type=Styles]"); +}); diff --git a/toolkit/devtools/timeline/test/browser_timeline_overview-initial-selection-01.js b/toolkit/devtools/timeline/test/browser_timeline_overview-initial-selection-01.js new file mode 100644 index 000000000..c29c05698 --- /dev/null +++ b/toolkit/devtools/timeline/test/browser_timeline_overview-initial-selection-01.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the overview has an initial selection when recording has finished + * and there is data available. + */ + +add_task(function*() { + let { target, panel } = yield initTimelinePanel(SIMPLE_URL); + let { $, EVENTS, TimelineView, TimelineController } = panel.panelWin; + let { OVERVIEW_INITIAL_SELECTION_RATIO: selectionRatio } = panel.panelWin; + + $("#memory-checkbox").checked = true; + yield TimelineController.updateMemoryRecording(); + + yield TimelineController.toggleRecording(); + ok(true, "Recording has started."); + + let updated = 0; + panel.panelWin.on(EVENTS.OVERVIEW_UPDATED, () => updated++); + + ok((yield waitUntil(() => updated > 10)), + "The overview graph was updated a bunch of times."); + ok((yield waitUntil(() => TimelineController.getMarkers().length > 0)), + "There are some markers available."); + ok((yield waitUntil(() => TimelineController.getMemory().length > 0)), + "There are some memory measurements available now."); + + yield TimelineController.toggleRecording(); + ok(true, "Recording has ended."); + + let interval = TimelineController.getInterval(); + let markers = TimelineController.getMarkers(); + let selection = TimelineView.markersOverview.getSelection(); + + is((selection.start) | 0, + (markers[0].start * TimelineView.markersOverview.dataScaleX) | 0, + "The initial selection start is correct."); + + is((selection.end - selection.start) | 0, + (selectionRatio * TimelineView.markersOverview.width) | 0, + "The initial selection end is correct."); +}); diff --git a/toolkit/devtools/timeline/test/browser_timeline_overview-initial-selection-02.js b/toolkit/devtools/timeline/test/browser_timeline_overview-initial-selection-02.js new file mode 100644 index 000000000..8a7675c0f --- /dev/null +++ b/toolkit/devtools/timeline/test/browser_timeline_overview-initial-selection-02.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the overview has no initial selection when recording has finished + * and there is no data available. + */ + +add_task(function*() { + let { target, panel } = yield initTimelinePanel(SIMPLE_URL); + let { $, EVENTS, TimelineView, TimelineController } = panel.panelWin; + let { OVERVIEW_INITIAL_SELECTION_RATIO: selectionRatio } = panel.panelWin; + + $("#memory-checkbox").checked = true; + yield TimelineController.updateMemoryRecording(); + + yield TimelineController.toggleRecording(); + ok(true, "Recording has started."); + + yield TimelineController._stopRecordingAndDiscardData(); + ok(true, "Recording has ended."); + + let markers = TimelineController.getMarkers(); + let memory = TimelineController.getMemory(); + let selection = TimelineView.markersOverview.getSelection(); + + is(markers.length, 0, + "There are no markers available."); + is(memory.length, 0, + "There are no memory measurements available."); + is(selection.start, null, + "The initial selection start is correct."); + is(selection.end, null, + "The initial selection end is correct."); +}); diff --git a/toolkit/devtools/timeline/test/browser_timeline_overview-theme.js b/toolkit/devtools/timeline/test/browser_timeline_overview-theme.js new file mode 100644 index 000000000..4a45bef6a --- /dev/null +++ b/toolkit/devtools/timeline/test/browser_timeline_overview-theme.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the markers and memory overviews render with the correct + * theme on load, and rerenders when changed. + */ + +const LIGHT_BG = "#fcfcfc"; +const DARK_BG = "#14171a"; + +add_task(function*() { + let { target, panel } = yield initTimelinePanel("about:blank"); + let { $, EVENTS, TimelineView, TimelineController } = panel.panelWin; + + $("#memory-checkbox").checked = true; + + setTheme("dark"); + + yield TimelineController.updateMemoryRecording(); + is(TimelineView.markersOverview.backgroundColor, DARK_BG, + "correct theme on load for markers."); + is(TimelineView.memoryOverview.backgroundColor, DARK_BG, + "correct theme on load for memory."); + + yield TimelineController.toggleRecording(); + ok(true, "Recording has started."); + + yield TimelineController.toggleRecording(); + ok(true, "Recording has ended."); + + let refreshed = Promise.all([ + once(TimelineView.markersOverview, "refresh"), + once(TimelineView.memoryOverview, "refresh"), + ]); + + setTheme("light"); + yield refreshed; + + ok(true, "Both memory and markers were rerendered after theme change."); + is(TimelineView.markersOverview.backgroundColor, LIGHT_BG, + "correct theme on after toggle for markers."); + is(TimelineView.memoryOverview.backgroundColor, LIGHT_BG, + "correct theme on after toggle for memory."); + + refreshed = Promise.all([ + once(TimelineView.markersOverview, "refresh"), + once(TimelineView.memoryOverview, "refresh"), + ]); + + setTheme("dark"); + yield refreshed; + + ok(true, "Both memory and markers were rerendered after theme change."); + is(TimelineView.markersOverview.backgroundColor, DARK_BG, + "correct theme on after toggle for markers once more."); + is(TimelineView.memoryOverview.backgroundColor, DARK_BG, + "correct theme on after toggle for memory once more."); + + refreshed = Promise.all([ + once(TimelineView.markersOverview, "refresh"), + once(TimelineView.memoryOverview, "refresh"), + ]); + + // Set theme back to light + setTheme("light"); + yield refreshed; +}); + +/** + * Mimics selecting the theme selector in the toolbox; + * sets the preference and emits an event on gDevTools to trigger + * the themeing. + */ +function setTheme (newTheme) { + let oldTheme = Services.prefs.getCharPref("devtools.theme"); + info("Setting `devtools.theme` to \"" + newTheme + "\""); + Services.prefs.setCharPref("devtools.theme", newTheme); + gDevTools.emit("pref-changed", { + pref: "devtools.theme", + newValue: newTheme, + oldValue: oldTheme + }); +} diff --git a/toolkit/devtools/timeline/test/browser_timeline_overview-update.js b/toolkit/devtools/timeline/test/browser_timeline_overview-update.js new file mode 100644 index 000000000..130f7db11 --- /dev/null +++ b/toolkit/devtools/timeline/test/browser_timeline_overview-update.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the markers and memory overviews are continuously updated. + */ + +add_task(function*() { + let { target, panel } = yield initTimelinePanel("about:blank"); + let { $, EVENTS, TimelineView, TimelineController } = panel.panelWin; + + $("#memory-checkbox").checked = true; + yield TimelineController.updateMemoryRecording(); + + yield TimelineController.toggleRecording(); + ok(true, "Recording has started."); + + ok("selectionEnabled" in TimelineView.markersOverview, + "The selection should not be enabled for the markers overview (1)."); + is(TimelineView.markersOverview.selectionEnabled, false, + "The selection should not be enabled for the markers overview (2)."); + is(TimelineView.markersOverview.hasSelection(), false, + "The markers overview shouldn't have a selection before recording."); + + ok("selectionEnabled" in TimelineView.memoryOverview, + "The selection should not be enabled for the memory overview (1)."); + is(TimelineView.memoryOverview.selectionEnabled, false, + "The selection should not be enabled for the memory overview (2)."); + is(TimelineView.memoryOverview.hasSelection(), false, + "The memory overview shouldn't have a selection before recording."); + + let updated = 0; + panel.panelWin.on(EVENTS.OVERVIEW_UPDATED, () => updated++); + + ok((yield waitUntil(() => updated > 10)), + "The overviews were updated a bunch of times."); + ok((yield waitUntil(() => TimelineController.getMemory().length > 10)), + "There are some memory measurements available now."); + + ok("selectionEnabled" in TimelineView.markersOverview, + "The selection should still not be enabled for the markers overview (3)."); + is(TimelineView.markersOverview.selectionEnabled, false, + "The selection should still not be enabled for the markers overview (4)."); + is(TimelineView.markersOverview.hasSelection(), false, + "The markers overview should not have a selection while recording."); + + ok("selectionEnabled" in TimelineView.memoryOverview, + "The selection should still not be enabled for the memory overview (3)."); + is(TimelineView.memoryOverview.selectionEnabled, false, + "The selection should still not be enabled for the memory overview (4)."); + is(TimelineView.memoryOverview.hasSelection(), false, + "The memory overview should not have a selection while recording."); + + yield TimelineController.toggleRecording(); + ok(true, "Recording has ended."); + + // TODO: Re-enable this assertion as part of bug 1120830 + // is(TimelineController.getMarkers().length, 0, + // "There are no markers available."); + isnot(TimelineController.getMemory().length, 0, + "There are some memory measurements available."); + + is(TimelineView.markersOverview.selectionEnabled, true, + "The selection should now be enabled for the markers overview."); + // TODO: Re-enable this assertion as part of bug 1120830 + // is(TimelineView.markersOverview.hasSelection(), false, + // "The markers overview should not have a selection after recording."); + + is(TimelineView.memoryOverview.selectionEnabled, true, + "The selection should now be enabled for the memory overview."); + // TODO: Re-enable this assertion as part of bug 1120830 + // is(TimelineView.memoryOverview.hasSelection(), false, + // "The memory overview should not have a selection after recording."); +}); diff --git a/toolkit/devtools/timeline/test/browser_timeline_panels.js b/toolkit/devtools/timeline/test/browser_timeline_panels.js new file mode 100644 index 000000000..f7f0de086 --- /dev/null +++ b/toolkit/devtools/timeline/test/browser_timeline_panels.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the timeline panels are correctly shown and hidden when + * recording starts and stops. + */ + +add_task(function*() { + let { target, panel } = yield initTimelinePanel(SIMPLE_URL); + let { $, EVENTS } = panel.panelWin; + + is($("#record-button").hasAttribute("checked"), false, + "The record button should not be checked yet."); + is($("#timeline-pane").selectedPanel, $("#empty-notice"), + "An empty notice is initially displayed instead of the waterfall view."); + + let whenRecStarted = panel.panelWin.once(EVENTS.RECORDING_STARTED); + EventUtils.synthesizeMouseAtCenter($("#record-button"), {}, panel.panelWin); + yield whenRecStarted; + + ok(true, "Recording has started."); + + is($("#record-button").getAttribute("checked"), "true", + "The record button should be checked now."); + is($("#timeline-pane").selectedPanel, $("#recording-notice"), + "A recording notice is now displayed instead of the waterfall view."); + + let whenRecEnded = panel.panelWin.once(EVENTS.RECORDING_ENDED); + EventUtils.synthesizeMouseAtCenter($("#record-button"), {}, panel.panelWin); + yield whenRecEnded; + + ok(true, "Recording has ended."); + + is($("#record-button").hasAttribute("checked"), false, + "The record button should be unchecked again."); + is($("#timeline-pane").selectedPanel, $("#timeline-waterfall-container"), + "A waterfall view is now displayed."); +}); diff --git a/toolkit/devtools/timeline/test/browser_timeline_recording-without-memory.js b/toolkit/devtools/timeline/test/browser_timeline_recording-without-memory.js new file mode 100644 index 000000000..5b8b0e0ea --- /dev/null +++ b/toolkit/devtools/timeline/test/browser_timeline_recording-without-memory.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the timeline actor isn't unnecessarily asked to record memory. + */ + +add_task(function*() { + let { target, panel } = yield initTimelinePanel(SIMPLE_URL); + let { $, EVENTS, TimelineView, TimelineController } = panel.panelWin; + + yield TimelineController.toggleRecording(); + ok(true, "Recording has started."); + + let updated = 0; + panel.panelWin.on(EVENTS.OVERVIEW_UPDATED, () => updated++); + + ok((yield waitUntil(() => updated > 10)), + "The overview graph was updated a bunch of times."); + ok((yield waitUntil(() => TimelineController.getMarkers().length > 0)), + "There are some markers available."); + + yield TimelineController.toggleRecording(); + ok(true, "Recording has ended."); + + let markers = TimelineController.getMarkers(); + let memory = TimelineController.getMemory(); + + isnot(markers.length, 0, + "There are some markers available."); + is(memory.length, 0, + "There are no memory measurements available."); +}); diff --git a/toolkit/devtools/timeline/test/browser_timeline_recording.js b/toolkit/devtools/timeline/test/browser_timeline_recording.js new file mode 100644 index 000000000..3cc977ff8 --- /dev/null +++ b/toolkit/devtools/timeline/test/browser_timeline_recording.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the timeline can properly start and stop a recording. + */ + +add_task(function*() { + let { target, panel } = yield initTimelinePanel(SIMPLE_URL); + let { $, gFront, TimelineController } = panel.panelWin; + + $("#memory-checkbox").checked = true; + yield TimelineController.updateMemoryRecording(); + + is((yield gFront.isRecording()), false, + "The timeline actor should not be recording when the tool starts."); + is(TimelineController.getMarkers().length, 0, + "There should be no markers available when the tool starts."); + is(TimelineController.getMemory().length, 0, + "There should be no memory measurements available when the tool starts."); + + yield TimelineController.toggleRecording(); + + is((yield gFront.isRecording()), true, + "The timeline actor should be recording now."); + ok((yield waitUntil(() => TimelineController.getMarkers().length > 0)), + "There are some markers available now."); + ok((yield waitUntil(() => TimelineController.getMemory().length > 0)), + "There are some memory measurements available now."); + + info("Interval: " + TimelineController.getInterval().toSource()); + info("Markers: " + TimelineController.getMarkers().toSource()); + info("Memory: " + TimelineController.getMemory().toSource()); + + ok("startTime" in TimelineController.getInterval(), + "A `startTime` field was set on the recording data."); + ok("endTime" in TimelineController.getInterval(), + "An `endTime` field was set on the recording data."); + + ok(TimelineController.getInterval().endTime > + TimelineController.getInterval().startTime, + "Some time has passed since the recording started."); + + yield TimelineController.toggleRecording(); +}); diff --git a/toolkit/devtools/timeline/test/browser_timeline_waterfall-background.js b/toolkit/devtools/timeline/test/browser_timeline_waterfall-background.js new file mode 100644 index 000000000..47c1cfba1 --- /dev/null +++ b/toolkit/devtools/timeline/test/browser_timeline_waterfall-background.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the waterfall background is a 1px high canvas stretching across + * the container bounds. + */ + +add_task(function*() { + let { target, panel } = yield initTimelinePanel(SIMPLE_URL); + let { $, EVENTS, TimelineView, TimelineController } = panel.panelWin; + + yield TimelineController.toggleRecording(); + ok(true, "Recording has started."); + + let updated = 0; + panel.panelWin.on(EVENTS.OVERVIEW_UPDATED, () => updated++); + + ok((yield waitUntil(() => updated > 0)), + "The overview graphs were updated a bunch of times."); + ok((yield waitUntil(() => TimelineController.getMarkers().length > 0)), + "There are some markers available."); + + yield TimelineController.toggleRecording(); + ok(true, "Recording has ended."); + + // Test the waterfall background. + + let parentWidth = $("#timeline-waterfall").getBoundingClientRect().width; + let waterfallWidth = TimelineView.waterfall._waterfallWidth; + let sidebarWidth = 150; // px + is(waterfallWidth, parentWidth - sidebarWidth, + "The waterfall width is correct.") + + ok(TimelineView.waterfall._canvas, + "A canvas should be created after the recording ended."); + ok(TimelineView.waterfall._ctx, + "A 2d context should be created after the recording ended."); + + is(TimelineView.waterfall._canvas.width, waterfallWidth, + "The canvas width is correct."); + is(TimelineView.waterfall._canvas.height, 1, + "The canvas height is correct."); +}); diff --git a/toolkit/devtools/timeline/test/browser_timeline_waterfall-generic.js b/toolkit/devtools/timeline/test/browser_timeline_waterfall-generic.js new file mode 100644 index 000000000..ceff2bd1f --- /dev/null +++ b/toolkit/devtools/timeline/test/browser_timeline_waterfall-generic.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the waterfall is properly built after finishing a recording. + */ + +add_task(function*() { + let { target, panel } = yield initTimelinePanel(SIMPLE_URL); + let { $, $$, EVENTS, TimelineController } = panel.panelWin; + + yield TimelineController.toggleRecording(); + ok(true, "Recording has started."); + + let updated = 0; + panel.panelWin.on(EVENTS.OVERVIEW_UPDATED, () => updated++); + + ok((yield waitUntil(() => updated > 0)), + "The overview graphs were updated a bunch of times."); + ok((yield waitUntil(() => TimelineController.getMarkers().length > 0)), + "There are some markers available."); + + yield TimelineController.toggleRecording(); + ok(true, "Recording has ended."); + + // Test the header container. + + ok($(".waterfall-header-container"), + "A header container should have been created."); + + // Test the header sidebar (left). + + ok($(".waterfall-header-container > .waterfall-sidebar"), + "A header sidebar node should have been created."); + ok($(".waterfall-header-container > .waterfall-sidebar > .waterfall-header-name"), + "A header name label should have been created inside the sidebar."); + + // Test the header ticks (right). + + ok($(".waterfall-header-ticks"), + "A header ticks node should have been created."); + ok($$(".waterfall-header-ticks > .waterfall-header-tick").length > 0, + "Some header tick labels should have been created inside the tick node."); + + // Test the markers container. + + ok($(".waterfall-marker-container"), + "A marker container should have been created."); + + // Test the markers sidebar (left). + + ok($$(".waterfall-marker-container > .waterfall-sidebar").length, + "Some marker sidebar nodes should have been created."); + ok($$(".waterfall-marker-container > .waterfall-sidebar:not(spacer) > .waterfall-marker-bullet").length, + "Some marker color bullets should have been created inside the sidebar."); + ok($$(".waterfall-marker-container > .waterfall-sidebar:not(spacer) > .waterfall-marker-name").length, + "Some marker name labels should have been created inside the sidebar."); + + // Test the markers waterfall (right). + + ok($$(".waterfall-marker-item").length, + "Some marker waterfall nodes should have been created."); + ok($$(".waterfall-marker-item:not(spacer) > .waterfall-marker-bar").length, + "Some marker color bars should have been created inside the waterfall."); +}); diff --git a/toolkit/devtools/timeline/test/browser_timeline_waterfall-sidebar.js b/toolkit/devtools/timeline/test/browser_timeline_waterfall-sidebar.js new file mode 100644 index 000000000..0c5564982 --- /dev/null +++ b/toolkit/devtools/timeline/test/browser_timeline_waterfall-sidebar.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the sidebar is properly updated when a marker is selected. + */ + +add_task(function*() { + let { target, panel } = yield initTimelinePanel(SIMPLE_URL); + let { $, $$, EVENTS, TimelineController, TimelineView, TIMELINE_BLUEPRINT} = panel.panelWin; + let { L10N } = devtools.require("devtools/shared/timeline/global"); + + yield TimelineController.toggleRecording(); + ok(true, "Recording has started."); + + yield waitUntil(() => { + // Wait until we get 3 different markers. + let markers = TimelineController.getMarkers(); + return markers.some(m => m.name == "Styles") && + markers.some(m => m.name == "Reflow") && + markers.some(m => m.name == "Paint"); + }); + + yield TimelineController.toggleRecording(); + ok(true, "Recording has ended."); + + // Select everything + TimelineView.markersOverview.setSelection({ start: 0, end: TimelineView.markersOverview.width }) + + + let bars = $$(".waterfall-marker-item:not(spacer) > .waterfall-marker-bar"); + let markers = TimelineController.getMarkers(); + + ok(bars.length > 2, "got at least 3 markers"); + + let sidebar = $("#timeline-waterfall-details"); + for (let i = 0; i < bars.length; i++) { + let bar = bars[i]; + bar.click(); + let m = markers[i]; + + let name = TIMELINE_BLUEPRINT[m.name].label; + + is($("#timeline-waterfall-details .marker-details-type").getAttribute("value"), name, + "sidebar title matches markers name"); + + let printedStartTime = $(".marker-details-start .marker-details-labelvalue").getAttribute("value"); + let printedEndTime = $(".marker-details-end .marker-details-labelvalue").getAttribute("value"); + let printedDuration= $(".marker-details-duration .marker-details-labelvalue").getAttribute("value"); + + let toMs = ms => L10N.getFormatStrWithNumbers("timeline.tick", ms); + + // Values are rounded. We don't use a strict equality. + is(toMs(m.start), printedStartTime, "sidebar start time is valid"); + is(toMs(m.end), printedEndTime, "sidebar end time is valid"); + is(toMs(m.end - m.start), printedDuration, "sidebar duration is valid"); + } +}); diff --git a/toolkit/devtools/timeline/test/browser_timeline_waterfall-styles.js b/toolkit/devtools/timeline/test/browser_timeline_waterfall-styles.js new file mode 100644 index 000000000..35ab9ae7f --- /dev/null +++ b/toolkit/devtools/timeline/test/browser_timeline_waterfall-styles.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the waterfall is properly built after making a selection + * and the child nodes are styled correctly. + */ + +var gRGB_TO_HSL = { + "rgb(193, 132, 214)": "hsl(285,50%,68%)", + "rgb(152, 61, 183)": "hsl(285,50%,48%)", + "rgb(161, 223, 138)": "hsl(104,57%,71%)", + "rgb(96, 201, 58)": "hsl(104,57%,51%)", + "rgb(240, 195, 111)": "hsl(39,82%,69%)", + "rgb(227, 155, 22)": "hsl(39,82%,49%)", + "rgb(204, 204, 204)": "hsl(0,0%,80%)", + "rgb(153, 153, 153)": "hsl(0,0%,60%)", +}; + +add_task(function*() { + let { target, panel } = yield initTimelinePanel(SIMPLE_URL); + let { TIMELINE_BLUEPRINT } = devtools.require("devtools/shared/timeline/global"); + let { $, $$, EVENTS, TimelineController } = panel.panelWin; + + yield TimelineController.toggleRecording(); + ok(true, "Recording has started."); + + let updated = 0; + panel.panelWin.on(EVENTS.OVERVIEW_UPDATED, () => updated++); + + ok((yield waitUntil(() => updated > 0)), + "The overview graphs were updated a bunch of times."); + ok((yield waitUntil(() => TimelineController.getMarkers().length > 0)), + "There are some markers available."); + + yield TimelineController.toggleRecording(); + ok(true, "Recording has ended."); + + // Test the table sidebars. + + for (let sidebar of [ + ...$$(".timeline-header-sidebar"), + ...$$(".timeline-marker-sidebar") + ]) { + is(sidebar.getAttribute("width"), "150", + "The table's sidebar width is correct."); + } + + // Test the table ticks. + + for (let tick of $$(".timeline-header-tick")) { + ok(tick.getAttribute("value").match(/^\d+ ms$/), + "The table's timeline ticks appear to have correct labels."); + ok(tick.style.transform.match(/^translateX\(.*px\)$/), + "The table's timeline ticks appear to have proper translations."); + } + + // Test the marker bullets. + + for (let bullet of $$(".timeline-marker-bullet")) { + let type = bullet.getAttribute("type"); + + ok(type in TIMELINE_BLUEPRINT, + "The bullet type is present in the timeline blueprint."); + is(gRGB_TO_HSL[bullet.style.backgroundColor], TIMELINE_BLUEPRINT[type].fill, + "The bullet's background color is correct."); + is(gRGB_TO_HSL[bullet.style.borderColor], TIMELINE_BLUEPRINT[type].stroke, + "The bullet's border color is correct."); + } + + // Test the marker bars. + + for (let bar of $$(".timeline-marker-bar")) { + let type = bar.getAttribute("type"); + + ok(type in TIMELINE_BLUEPRINT, + "The bar type is present in the timeline blueprint."); + is(gRGB_TO_HSL[bar.style.backgroundColor], TIMELINE_BLUEPRINT[type].fill, + "The bar's background color is correct."); + is(gRGB_TO_HSL[bar.style.borderColor], TIMELINE_BLUEPRINT[type].stroke, + "The bar's border color is correct."); + + ok(bar.getAttribute("width") > 0, + "The bar appears to have a proper width."); + ok(bar.style.transform.match(/^translateX\(.*px\)$/), + "The bar appears to have proper translations."); + } +}); diff --git a/toolkit/devtools/timeline/test/doc_simple-test.html b/toolkit/devtools/timeline/test/doc_simple-test.html new file mode 100644 index 000000000..d038c46a7 --- /dev/null +++ b/toolkit/devtools/timeline/test/doc_simple-test.html @@ -0,0 +1,25 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Timeline test page</title> + </head> + + <body> + <script type="text/javascript"> + var x = 1; + function test() { + document.body.style.borderTop = x + "px solid red"; + x = 1^x; + document.body.innerHeight; // flush pending reflows + } + + // Prevent this script from being garbage collected. + window.setInterval(test, 1); + </script> + </body> + +</html> diff --git a/toolkit/devtools/timeline/test/head.js b/toolkit/devtools/timeline/test/head.js new file mode 100644 index 000000000..74b185aa3 --- /dev/null +++ b/toolkit/devtools/timeline/test/head.js @@ -0,0 +1,148 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +let { Services } = Cu.import("resource://gre/modules/Services.jsm", {}); + +// Disable logging for all the tests. Both the debugger server and frontend will +// be affected by this pref. +let gEnableLogging = Services.prefs.getBoolPref("devtools.debugger.log"); +Services.prefs.setBoolPref("devtools.debugger.log", false); + +// Enable the tool while testing. +let gToolEnabled = Services.prefs.getBoolPref("devtools.timeline.enabled"); +Services.prefs.setBoolPref("devtools.timeline.enabled", true); + +let { Task } = Cu.import("resource://gre/modules/Task.jsm", {}); +let { Promise: promise } = Cu.import("resource://gre/modules/Promise.jsm", {}); +let { DevToolsUtils } = Cu.import("resource://gre/modules/devtools/DevToolsUtils.jsm", {}); +let { gDevTools } = Cu.import("resource:///modules/devtools/gDevTools.jsm", {}); +let { devtools } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}); + +let TargetFactory = devtools.TargetFactory; +let Toolbox = devtools.Toolbox; + +const EXAMPLE_URL = "http://example.com/browser/browser/devtools/timeline/test/"; +const SIMPLE_URL = EXAMPLE_URL + "doc_simple-test.html"; + +// All tests are asynchronous. +waitForExplicitFinish(); + +registerCleanupFunction(() => { + info("finish() was called, cleaning up..."); + Services.prefs.setBoolPref("devtools.debugger.log", gEnableLogging); + Services.prefs.setBoolPref("devtools.timeline.enabled", gToolEnabled); +}); + +// Close the toolbox and all opened tabs automatically. +registerCleanupFunction(function*() { + let target = TargetFactory.forTab(gBrowser.selectedTab); + yield gDevTools.closeToolbox(target); + + while (gBrowser.tabs.length > 1) { + gBrowser.removeCurrentTab(); + } +}); + +function addTab(url) { + info("Adding tab: " + url); + + let deferred = promise.defer(); + let tab = gBrowser.selectedTab = gBrowser.addTab(url); + let linkedBrowser = tab.linkedBrowser; + + linkedBrowser.addEventListener("load", function onLoad() { + linkedBrowser.removeEventListener("load", onLoad, true); + info("Tab added and finished loading: " + url); + deferred.resolve(tab); + }, true); + + return deferred.promise; +} + +/** + * Spawns a new tab and starts up a toolbox with the timeline panel + * automatically selected. + * + * Must be used within a task. + * + * @param string url + * The location of the new tab to spawn. + * @return object + * A promise resolved once the timeline is initialized, with the + * {target, panel} instances. + */ +function* initTimelinePanel(url) { + info("Initializing a timeline pane."); + + let tab = yield addTab(url); + let target = TargetFactory.forTab(tab); + + yield target.makeRemote(); + + let toolbox = yield gDevTools.showToolbox(target, "timeline"); + let panel = toolbox.getCurrentPanel(); + return { target, panel }; +} + +/** + * Waits until a predicate returns true. + * + * @param function predicate + * Invoked once in a while until it returns true. + * @param number interval [optional] + * How often the predicate is invoked, in milliseconds. + */ +function waitUntil(predicate, interval = 10) { + if (predicate()) { + return promise.resolve(true); + } + let deferred = promise.defer(); + setTimeout(function() { + waitUntil(predicate).then(() => deferred.resolve(true)); + }, interval); + return deferred.promise; + +} + +/** + * Wait until next tick. + */ +function nextTick() { + let def = promise.defer(); + executeSoon(() => def.resolve()) + return def.promise; +} + +/** + * Wait for eventName on target. + * @param {Object} target An observable object that either supports on/off or + * addEventListener/removeEventListener + * @param {String} eventName + * @param {Boolean} useCapture Optional, for addEventListener/removeEventListener + * @return A promise that resolves when the event has been handled + */ +function once(target, eventName, useCapture=false) { + info("Waiting for event: '" + eventName + "' on " + target + "."); + + let deferred = promise.defer(); + + for (let [add, remove] of [ + ["addEventListener", "removeEventListener"], + ["addListener", "removeListener"], + ["on", "off"] + ]) { + if ((add in target) && (remove in target)) { + target[add](eventName, function onEvent(...aArgs) { + info("Got event: '" + eventName + "' on " + target + "."); + target[remove](eventName, onEvent, useCapture); + deferred.resolve.apply(deferred, aArgs); + }, useCapture); + break; + } + } + + return deferred.promise; +} diff --git a/toolkit/devtools/timeline/timeline.js b/toolkit/devtools/timeline/timeline.js new file mode 100644 index 000000000..f70a6b3cb --- /dev/null +++ b/toolkit/devtools/timeline/timeline.js @@ -0,0 +1,627 @@ +/* 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 { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/devtools/Loader.jsm"); +Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); +Cu.import("resource:///modules/devtools/gDevTools.jsm"); + +devtools.lazyRequireGetter(this, "promise"); +devtools.lazyRequireGetter(this, "EventEmitter", + "devtools/toolkit/event-emitter"); + +devtools.lazyRequireGetter(this, "MarkersOverview", + "devtools/shared/timeline/markers-overview", true); +devtools.lazyRequireGetter(this, "MemoryOverview", + "devtools/shared/timeline/memory-overview", true); +devtools.lazyRequireGetter(this, "Waterfall", + "devtools/shared/timeline/waterfall", true); +devtools.lazyRequireGetter(this, "MarkerDetails", + "devtools/shared/timeline/marker-details", true); +devtools.lazyRequireGetter(this, "TIMELINE_BLUEPRINT", + "devtools/shared/timeline/global", true); + +devtools.lazyImporter(this, "CanvasGraphUtils", + "resource:///modules/devtools/Graphs.jsm"); + +devtools.lazyImporter(this, "PluralForm", + "resource://gre/modules/PluralForm.jsm"); + +const OVERVIEW_UPDATE_INTERVAL = 200; +const OVERVIEW_INITIAL_SELECTION_RATIO = 0.15; + +/** + * Preference for devtools.timeline.hiddenMarkers. + * Stores which markers should be hidden. + */ +const Prefs = new ViewHelpers.Prefs("devtools.timeline", { + hiddenMarkers: ["Json", "hiddenMarkers"] +}); + +// The panel's window global is an EventEmitter firing the following events: +const EVENTS = { + // When a recording is started or stopped, via the `stopwatch` button. + RECORDING_STARTED: "Timeline:RecordingStarted", + RECORDING_ENDED: "Timeline:RecordingEnded", + + // When the overview graphs are populated with new markers. + OVERVIEW_UPDATED: "Timeline:OverviewUpdated", + + // When the waterfall view is populated with new markers. + WATERFALL_UPDATED: "Timeline:WaterfallUpdated" +}; + +/** + * The current target and the timeline front, set by this tool's host. + */ +let gToolbox, gTarget, gFront; + +/** + * Initializes the timeline controller and views. + */ +let startupTimeline = Task.async(function*() { + yield TimelineView.initialize(); + yield TimelineController.initialize(); +}); + +/** + * Destroys the timeline controller and views. + */ +let shutdownTimeline = Task.async(function*() { + yield TimelineView.destroy(); + yield TimelineController.destroy(); + yield gFront.stop(); +}); + +/** + * Functions handling the timeline frontend controller. + */ +let TimelineController = { + /** + * Permanent storage for the markers and the memory measurements streamed by + * the backend, along with the start and end timestamps. + */ + _starTime: 0, + _endTime: 0, + _markers: [], + _memory: [], + _frames: [], + + /** + * Initialization function, called when the tool is started. + */ + initialize: function() { + this._onRecordingTick = this._onRecordingTick.bind(this); + this._onMarkers = this._onMarkers.bind(this); + this._onMemory = this._onMemory.bind(this); + this._onFrames = this._onFrames.bind(this); + + gFront.on("markers", this._onMarkers); + gFront.on("memory", this._onMemory); + gFront.on("frames", this._onFrames); + }, + + /** + * Destruction function, called when the tool is closed. + */ + destroy: function() { + gFront.off("markers", this._onMarkers); + gFront.off("memory", this._onMemory); + gFront.off("frames", this._onFrames); + }, + + /** + * Gets the { stat, end } time interval for this recording. + * @return object + */ + getInterval: function() { + return { startTime: this._startTime, endTime: this._endTime }; + }, + + /** + * Gets the accumulated markers in this recording. + * @return array + */ + getMarkers: function() { + return this._markers; + }, + + /** + * Gets the accumulated memory measurements in this recording. + * @return array + */ + getMemory: function() { + return this._memory; + }, + + /** + * Gets stack frame array reported by the actor. The marker "stack" + * and "endStack" properties are indices into this array. See + * actors/utils/stack.js for more details. + * @return array + */ + getFrames: function() { + return this._frames; + }, + + /** + * Updates the views to show or hide the memory recording data. + */ + updateMemoryRecording: Task.async(function*() { + if ($("#memory-checkbox").checked) { + yield TimelineView.showMemoryOverview(); + } else { + yield TimelineView.hideMemoryOverview(); + } + }), + + /** + * Starts/stops the timeline recording and streaming. + */ + toggleRecording: Task.async(function*() { + let isRecording = yield gFront.isRecording(); + if (isRecording == false) { + yield this._startRecording(); + } else { + yield this._stopRecording(); + } + }), + + /** + * Starts the recording, updating the UI as needed. + */ + _startRecording: function*() { + TimelineView.handleRecordingStarted(); + + let withMemory = $("#memory-checkbox").checked; + let startTime = yield gFront.start({ withMemory }); + + // Times must come from the actor in order to be self-consistent. + // However, we also want to update the view with the elapsed time + // even when the actor is not generating data. To do this we get + // the local time and use it to compute a reasonable elapsed time. + // See _onRecordingTick. + this._localStartTime = performance.now(); + this._startTime = startTime; + this._endTime = startTime; + this._markers = []; + this._memory = []; + this._frames = []; + this._updateId = setInterval(this._onRecordingTick, OVERVIEW_UPDATE_INTERVAL); + }, + + /** + * Stops the recording, updating the UI as needed. + */ + _stopRecording: function*() { + clearInterval(this._updateId); + + // Sorting markers is only important when displayed in the waterfall. + this._markers = this._markers.sort((a,b) => (a.start > b.start)); + + TimelineView.handleRecordingUpdate(); + TimelineView.handleRecordingEnded(); + yield gFront.stop(); + }, + + /** + * Used in tests. Stops the recording, discarding the accumulated markers and + * updating the UI as needed. + */ + _stopRecordingAndDiscardData: function*() { + // Clear the markers before calling async method _stopRecording to properly + // reset the selection if markers were already received. Bug 1092452. + this._markers.length = 0; + this._memory.length = 0; + + yield this._stopRecording(); + + // Clear the markers after _stopRecording has finished. It's possible that + // server sent new markers before it received the request to stop sending + // them and client received them while we were waiting for _stopRecording + // to finish. Bug 1067287. + this._markers.length = 0; + this._memory.length = 0; + }, + + /** + * Callback handling the "markers" event on the timeline front. + * + * @param array markers + * A list of new markers collected since the last time this + * function was invoked. + * @param number endTime + * A time after the last marker in markers was collected. + */ + _onMarkers: function(markers, endTime) { + for (let marker of markers) { + marker.start -= this._startTime; + marker.end -= this._startTime; + } + Array.prototype.push.apply(this._markers, markers); + this._endTime = endTime; + }, + + /** + * Callback handling the "memory" event on the timeline front. + * + * @param number delta + * The number of milliseconds elapsed since epoch. + * @param object measurement + * A detailed breakdown of the current memory usage. + */ + _onMemory: function(delta, measurement) { + this._memory.push({ + delta: delta - this._startTime, + value: measurement.total / 1024 / 1024 + }); + }, + + /** + * Callback handling the "frames" event on the timeline front. + * + * @param number delta + * The number of milliseconds elapsed since epoch. + * @param object frames + * Newly generated frame objects. + */ + _onFrames: function(delta, frames) { + Array.prototype.push.apply(this._frames, frames); + }, + + /** + * Callback invoked at a fixed interval while recording. + * Updates the current time and the timeline overview. + */ + _onRecordingTick: function() { + // Compute an approximate ending time for the view. This is + // needed to ensure that the view updates even when new data is + // not being generated. + let fakeTime = this._startTime + (performance.now() - this._localStartTime); + if (fakeTime > this._endTime) { + this._endTime = fakeTime; + } + TimelineView.handleRecordingUpdate(); + } +}; + +/** + * Functions handling the timeline frontend view. + */ +let TimelineView = { + /** + * Initialization function, called when the tool is started. + */ + initialize: Task.async(function*() { + let blueprint = this._getFilteredBluePrint(); + this.markersOverview = new MarkersOverview($("#markers-overview"), blueprint); + this.waterfall = new Waterfall($("#timeline-waterfall"), $("#timeline-pane"), blueprint); + this.markerDetails = new MarkerDetails($("#timeline-waterfall-details"), $("#timeline-waterfall-container > splitter")); + + this._onThemeChange = this._onThemeChange.bind(this); + this._onSelecting = this._onSelecting.bind(this); + this._onRefresh = this._onRefresh.bind(this); + + gDevTools.on("pref-changed", this._onThemeChange); + this.markersOverview.on("selecting", this._onSelecting); + this.markersOverview.on("refresh", this._onRefresh); + this.markerDetails.on("resize", this._onRefresh); + + this._onMarkerSelected = this._onMarkerSelected.bind(this); + this.waterfall.on("selected", this._onMarkerSelected); + this.waterfall.on("unselected", this._onMarkerSelected); + + let theme = Services.prefs.getCharPref("devtools.theme"); + this.markersOverview.setTheme(theme); + + yield this.markersOverview.ready(); + + yield this.waterfall.recalculateBounds(); + + this._buildFilterPopup(); + }), + + /** + * Destruction function, called when the tool is closed. + */ + destroy: function() { + gDevTools.off("pref-changed", this._onThemeChange); + this.markerDetails.off("resize", this._onRefresh); + this.markerDetails.destroy(); + this.waterfall.off("selected", this._onMarkerSelected); + this.waterfall.off("unselected", this._onMarkerSelected); + this.waterfall.destroy(); + this.markersOverview.off("selecting", this._onSelecting); + this.markersOverview.off("refresh", this._onRefresh); + this.markersOverview.destroy(); + + // The memory overview graph is not always available. + if (this.memoryOverview) { + this.memoryOverview.destroy(); + } + }, + + /** + * Shows the memory overview graph. + */ + showMemoryOverview: Task.async(function*() { + let theme = Services.prefs.getCharPref("devtools.theme"); + + this.memoryOverview = new MemoryOverview($("#memory-overview")); + this.memoryOverview.setTheme(theme); + yield this.memoryOverview.ready(); + + let memory = TimelineController.getMemory(); + this.memoryOverview.setData(memory); + + CanvasGraphUtils.linkAnimation(this.markersOverview, this.memoryOverview); + CanvasGraphUtils.linkSelection(this.markersOverview, this.memoryOverview); + }), + + /** + * Hides the memory overview graph. + */ + hideMemoryOverview: function() { + if (!this.memoryOverview) { + return; + } + this.memoryOverview.destroy(); + this.memoryOverview = null; + }, + + /** + * A marker has been selected in the waterfall. + */ + _onMarkerSelected: function(event, marker) { + if (event == "selected") { + this.markerDetails.render({ + toolbox: gToolbox, + marker: marker, + frames: TimelineController.getFrames() + }); + } + if (event == "unselected") { + this.markerDetails.empty(); + } + }, + + /** + * Signals that a recording session has started and triggers the appropriate + * changes in the UI. + */ + handleRecordingStarted: function() { + $("#record-button").setAttribute("checked", "true"); + $("#memory-checkbox").setAttribute("disabled", "true"); + $("#timeline-pane").selectedPanel = $("#recording-notice"); + + this.markersOverview.clearView(); + + // The memory overview graph is not always available. + if (this.memoryOverview) { + this.memoryOverview.clearView(); + } + + this.waterfall.clearView(); + + window.emit(EVENTS.RECORDING_STARTED); + }, + + /** + * Signals that a recording session has ended and triggers the appropriate + * changes in the UI. + */ + handleRecordingEnded: function() { + $("#record-button").removeAttribute("checked"); + $("#memory-checkbox").removeAttribute("disabled"); + $("#timeline-pane").selectedPanel = $("#timeline-waterfall-container"); + + this.markersOverview.selectionEnabled = true; + + // The memory overview graph is not always available. + if (this.memoryOverview) { + this.memoryOverview.selectionEnabled = true; + } + + let interval = TimelineController.getInterval(); + let markers = TimelineController.getMarkers(); + let memory = TimelineController.getMemory(); + + if (markers.length) { + let start = markers[0].start * this.markersOverview.dataScaleX; + let end = start + this.markersOverview.width * OVERVIEW_INITIAL_SELECTION_RATIO; + this.markersOverview.setSelection({ start, end }); + } else { + let startTime = interval.startTime; + let endTime = interval.endTime; + this.waterfall.setData({ markers, interval: { startTime, endTime } }); + } + + window.emit(EVENTS.RECORDING_ENDED); + }, + + /** + * Signals that a new set of markers was made available by the controller, + * or that the overview graph needs to be updated. + */ + handleRecordingUpdate: function() { + let interval = TimelineController.getInterval(); + let markers = TimelineController.getMarkers(); + let memory = TimelineController.getMemory(); + + let duration = interval.endTime - interval.startTime; + this.markersOverview.setData({ markers, duration }); + + // The memory overview graph is not always available. + if (this.memoryOverview) { + this.memoryOverview.setData(memory); + } + + window.emit(EVENTS.OVERVIEW_UPDATED); + }, + + /** + * Callback handling the "selecting" event on the timeline overview. + */ + _onSelecting: function() { + if (!this.markersOverview.hasSelection() && + !this.markersOverview.hasSelectionInProgress()) { + this.waterfall.clearView(); + return; + } + this.waterfall.resetSelection(); + this.updateWaterfall(); + }, + + /** + * Rebuild the waterfall. + */ + updateWaterfall: function() { + let selection = this.markersOverview.getSelection(); + let start = selection.start / this.markersOverview.dataScaleX; + let end = selection.end / this.markersOverview.dataScaleX; + + let markers = TimelineController.getMarkers(); + let interval = TimelineController.getInterval(); + + let startTime = Math.min(start, end); + let endTime = Math.max(start, end); + + this.waterfall.setData({ markers, interval: { startTime, endTime } }); + }, + + /** + * Callback handling the "refresh" event on the timeline overview. + */ + _onRefresh: function() { + this.waterfall.recalculateBounds(); + this.updateWaterfall(); + }, + + /** + * Rebuild a blueprint without hidden markers. + */ + _getFilteredBluePrint: function() { + let hiddenMarkers = Prefs.hiddenMarkers; + let filteredBlueprint = Cu.cloneInto(TIMELINE_BLUEPRINT, {}); + let maybeRemovedGroups = new Set(); + let removedGroups = new Set(); + + // 1. Remove hidden markers from the blueprint. + + for (let hiddenMarkerName of hiddenMarkers) { + maybeRemovedGroups.add(filteredBlueprint[hiddenMarkerName].group); + delete filteredBlueprint[hiddenMarkerName]; + } + + // 2. Get a list of all the groups that will be removed. + + for (let removedGroup of maybeRemovedGroups) { + let markerNames = Object.keys(filteredBlueprint); + let allGroupsRemoved = markerNames.every(e => filteredBlueprint[e].group != removedGroup); + if (allGroupsRemoved) { + removedGroups.add(removedGroup); + } + } + + // 3. Offset groups. + + for (let removedGroup of removedGroups) { + for (let [, markerDetails] of Iterator(filteredBlueprint)) { + if (markerDetails.group > removedGroup) { + markerDetails.group--; + } + } + } + + return filteredBlueprint; + + }, + + /** + * When the list of hidden markers changes, update waterfall + * and overview. + */ + _onHiddenMarkersChanged: function(e) { + let menuItems = $$("#timelineFilterPopup menuitem[marker-type]:not([checked])"); + let hiddenMarkers = Array.map(menuItems, e => e.getAttribute("marker-type")); + + Prefs.hiddenMarkers = hiddenMarkers; + let blueprint = this._getFilteredBluePrint(); + + this.waterfall.setBlueprint(blueprint); + this.updateWaterfall(); + + this.markersOverview.setBlueprint(blueprint); + this.markersOverview.refresh({ force: true }); + }, + + /** + * Creates the filter popup. + */ + _buildFilterPopup: function() { + let popup = $("#timelineFilterPopup"); + let button = $("#filter-button"); + + popup.addEventListener("popupshowing", () => button.setAttribute("open", "true")); + popup.addEventListener("popuphiding", () => button.removeAttribute("open")); + + this._onHiddenMarkersChanged = this._onHiddenMarkersChanged.bind(this); + + for (let [markerName, markerDetails] of Iterator(TIMELINE_BLUEPRINT)) { + let menuitem = document.createElement("menuitem"); + menuitem.setAttribute("closemenu", "none"); + menuitem.setAttribute("type", "checkbox"); + menuitem.setAttribute("marker-type", markerName); + menuitem.setAttribute("label", markerDetails.label); + menuitem.setAttribute("flex", "1"); + menuitem.setAttribute("align", "center"); + + menuitem.addEventListener("command", this._onHiddenMarkersChanged); + + if (Prefs.hiddenMarkers.indexOf(markerName) == -1) { + menuitem.setAttribute("checked", "true"); + } + + // Style used by pseudo element ::before in timeline.css.in + let bulletStyle = `--bullet-bg: ${markerDetails.fill};` + bulletStyle += `--bullet-border: ${markerDetails.stroke}`; + menuitem.setAttribute("style", bulletStyle); + + popup.appendChild(menuitem); + } + }, + + /* + * Called when the developer tools theme changes. Redraws + * the graphs with the new theme setting. + */ + _onThemeChange: function (_, theme) { + if (this.memoryOverview) { + this.memoryOverview.setTheme(theme.newValue); + this.memoryOverview.refresh({ force: true }); + } + + this.markersOverview.setTheme(theme.newValue); + this.markersOverview.refresh({ force: true }); + } +}; + +/** + * Convenient way of emitting events from the panel window. + */ +EventEmitter.decorate(this); + +/** + * DOM query helpers. + */ +function $(selector, target = document) { + return target.querySelector(selector); +} +function $$(selector, target = document) { + return target.querySelectorAll(selector); +} diff --git a/toolkit/devtools/timeline/timeline.xul b/toolkit/devtools/timeline/timeline.xul new file mode 100644 index 000000000..2d4b8e193 --- /dev/null +++ b/toolkit/devtools/timeline/timeline.xul @@ -0,0 +1,86 @@ +<?xml version="1.0"?> +<!-- 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/. --> +<?xml-stylesheet href="chrome://browser/skin/" type="text/css"?> +<?xml-stylesheet href="chrome://browser/content/devtools/widgets.css" type="text/css"?> +<?xml-stylesheet href="chrome://browser/skin/devtools/widgets.css" type="text/css"?> +<?xml-stylesheet href="chrome://browser/skin/devtools/common.css" type="text/css"?> +<?xml-stylesheet href="chrome://browser/skin/devtools/timeline.css" type="text/css"?> + +<!DOCTYPE window [ + <!ENTITY % timelineDTD SYSTEM "chrome://browser/locale/devtools/timeline.dtd"> + %timelineDTD; +]> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://browser/content/devtools/theme-switching.js"/> + <script type="application/javascript" src="timeline.js"/> + + <popupset id="timelinePopupset"> + <menupopup id="timelineFilterPopup" position="after_start"/> + </popupset> + + <vbox class="theme-body" flex="1"> + <toolbar id="timeline-toolbar" + class="devtools-toolbar"> + <hbox id="recordings-controls" + class="devtools-toolbarbutton-group" + align="center"> + <toolbarbutton id="record-button" + class="devtools-toolbarbutton" + oncommand="TimelineController.toggleRecording()" + tooltiptext="&timelineUI.recordButton.tooltip;"/> + <toolbarbutton id="filter-button" + popup="timelineFilterPopup" + class="devtools-toolbarbutton" + tooltiptext="&timelineUI.filterButton.tooltip;"/> + <checkbox id="memory-checkbox" + label="&timelineUI.memoryCheckbox.label;" + oncommand="TimelineController.updateMemoryRecording()" + tooltiptext="&timelineUI.memoryCheckbox.tooltip;"/> + <label id="record-label" + value="&timelineUI.recordLabel;"/> + </hbox> + </toolbar> + + <vbox id="markers-overview"/> + <vbox id="memory-overview"/> + + <deck id="timeline-pane" + flex="1"> + <hbox id="empty-notice" + class="notice-container" + align="center" + pack="center" + flex="1"> + <label value="&timelineUI.emptyNotice1;"/> + <button id="profiling-notice-button" + class="devtools-toolbarbutton" + standalone="true" + oncommand="TimelineController.toggleRecording()"/> + <label value="&timelineUI.emptyNotice2;"/> + </hbox> + + <hbox id="recording-notice" + class="notice-container" + align="center" + pack="center" + flex="1"> + <label value="&timelineUI.stopNotice1;"/> + <button id="profiling-notice-button" + class="devtools-toolbarbutton" + standalone="true" + checked="true" + oncommand="TimelineController.toggleRecording()"/> + <label value="&timelineUI.stopNotice2;"/> + </hbox> + + <hbox id="timeline-waterfall-container" class="devtools-responsive-container" flex="1"> + <vbox id="timeline-waterfall" flex="1"/> + <splitter class="devtools-side-splitter"/> + <vbox id="timeline-waterfall-details" class="theme-sidebar" width="150" height="150"/> + </hbox> + </deck> + </vbox> +</window> |