diff options
Diffstat (limited to 'devtools/client/performance')
222 files changed, 26818 insertions, 0 deletions
diff --git a/devtools/client/performance/components/JITOptimizations.js b/devtools/client/performance/components/JITOptimizations.js new file mode 100644 index 0000000000..3e924d28de --- /dev/null +++ b/devtools/client/performance/components/JITOptimizations.js @@ -0,0 +1,274 @@ +/* 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("devtools/shared/l10n"); +const STRINGS_URI = "devtools/client/locales/jit-optimizations.properties"; +const L10N = new LocalizationHelper(STRINGS_URI); + +const { assert } = require("devtools/shared/DevToolsUtils"); +const { + Component, + createFactory, +} = require("devtools/client/shared/vendor/react"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const Tree = createFactory( + require("devtools/client/shared/components/VirtualizedTree") +); +const OptimizationsItem = createFactory( + require("devtools/client/performance/components/JITOptimizationsItem") +); +const FrameView = createFactory( + require("devtools/client/shared/components/Frame") +); +const JIT_TITLE = L10N.getStr("jit.title"); +// If TREE_ROW_HEIGHT changes, be sure to change `var(--jit-tree-row-height)` +// in `devtools/client/themes/jit-optimizations.css` +const TREE_ROW_HEIGHT = 14; + +/* eslint-disable no-unused-vars */ +/** + * TODO - Re-enable this eslint rule. The JIT tool is a work in progress, and isn't fully + * integrated as of yet, and this may represent intended functionality. + */ +const onClickTooltipString = frame => + L10N.getFormatStr( + "viewsourceindebugger", + `${frame.source}:${frame.line}:${frame.column}` + ); +/* eslint-enable no-unused-vars */ + +const optimizationAttemptModel = { + id: PropTypes.number.isRequired, + strategy: PropTypes.string.isRequired, + outcome: PropTypes.string.isRequired, +}; + +const optimizationObservedTypeModel = { + keyedBy: PropTypes.string.isRequired, + name: PropTypes.string, + location: PropTypes.string, + line: PropTypes.string, +}; + +const optimizationIonTypeModel = { + id: PropTypes.number.isRequired, + typeset: PropTypes.arrayOf(optimizationObservedTypeModel), + site: PropTypes.number.isRequired, + mirType: PropTypes.number.isRequired, +}; + +const optimizationSiteModel = { + id: PropTypes.number.isRequired, + propertyName: PropTypes.string, + line: PropTypes.number.isRequired, + column: PropTypes.number.isRequired, + data: PropTypes.shape({ + attempts: PropTypes.arrayOf(optimizationAttemptModel).isRequired, + types: PropTypes.arrayOf(optimizationIonTypeModel).isRequired, + }).isRequired, +}; + +class JITOptimizations extends Component { + static get propTypes() { + return { + onViewSourceInDebugger: PropTypes.func.isRequired, + frameData: PropTypes.object.isRequired, + optimizationSites: PropTypes.arrayOf(optimizationSiteModel).isRequired, + autoExpandDepth: PropTypes.number, + }; + } + + static get defaultProps() { + return { + autoExpandDepth: 0, + }; + } + + constructor(props) { + super(props); + + this.state = { + expanded: new Set(), + }; + + this._createHeader = this._createHeader.bind(this); + this._createTree = this._createTree.bind(this); + } + + /** + * Frame data generated from `frameNode.getInfo()`, or an empty + * object, as well as a handler for clicking on the frame component. + * + * @param {?Object} .frameData + * @param {Function} .onViewSourceInDebugger + * @return {ReactElement} + */ + _createHeader({ frameData, onViewSourceInDebugger }) { + const { isMetaCategory, url, line } = frameData; + const name = isMetaCategory + ? frameData.categoryData.label + : frameData.functionName || ""; + + // Neither Meta Category nodes, or the lack of a selected frame node, + // renders out a frame source, like "file.js:123"; so just use + // an empty span. + let frameComponent; + if (isMetaCategory || !name) { + frameComponent = dom.span(); + } else { + frameComponent = FrameView({ + frame: { + source: url, + line: +line, + functionDisplayName: name, + }, + onClick: onViewSourceInDebugger, + }); + } + + return dom.div( + { className: "optimization-header" }, + dom.span({ className: "header-title" }, JIT_TITLE), + dom.span({ className: "header-function-name" }, name), + frameComponent + ); + } + + _createTree(props) { + const { + autoExpandDepth, + frameData, + onViewSourceInDebugger, + optimizationSites: sites, + } = this.props; + + const getSite = id => sites.find(site => site.id === id); + const getIonTypeForObserved = type => { + return getSite(type.id).data.types.find(iontype => + (iontype.typeset || []).includes(type) + ); + }; + const isSite = site => getSite(site.id) === site; + const isAttempts = attempts => + getSite(attempts.id).data.attempts === attempts; + const isAttempt = attempt => + getSite(attempt.id).data.attempts.includes(attempt); + const isTypes = types => getSite(types.id).data.types === types; + const isType = type => getSite(type.id).data.types.includes(type); + const isObservedType = type => getIonTypeForObserved(type); + + const getRowType = node => { + if (isSite(node)) { + return "site"; + } + if (isAttempts(node)) { + return "attempts"; + } + if (isTypes(node)) { + return "types"; + } + if (isAttempt(node)) { + return "attempt"; + } + if (isType(node)) { + return "type"; + } + if (isObservedType(node)) { + return "observedtype"; + } + return null; + }; + + // Creates a unique key for each node in the + // optimizations data + const getKey = node => { + const site = getSite(node.id); + if (isSite(node)) { + return node.id; + } else if (isAttempts(node)) { + return `${node.id}-A`; + } else if (isTypes(node)) { + return `${node.id}-T`; + } else if (isType(node)) { + return `${node.id}-T-${site.data.types.indexOf(node)}`; + } else if (isAttempt(node)) { + return `${node.id}-A-${site.data.attempts.indexOf(node)}`; + } else if (isObservedType(node)) { + const iontype = getIonTypeForObserved(node); + return `${getKey(iontype)}-O-${iontype.typeset.indexOf(node)}`; + } + return ""; + }; + + return Tree({ + autoExpandDepth, + preventNavigationOnArrowRight: false, + getParent: node => { + const site = getSite(node.id); + let parent; + if (isAttempts(node) || isTypes(node)) { + parent = site; + } else if (isType(node)) { + parent = site.data.types; + } else if (isAttempt(node)) { + parent = site.data.attempts; + } else if (isObservedType(node)) { + parent = getIonTypeForObserved(node); + } + assert(parent, "Could not find a parent for optimization data node"); + + return parent; + }, + getChildren: node => { + if (isSite(node)) { + return [node.data.types, node.data.attempts]; + } else if (isAttempts(node) || isTypes(node)) { + return node; + } else if (isType(node)) { + return node.typeset || []; + } + return []; + }, + isExpanded: node => this.state.expanded.has(node), + onExpand: node => + this.setState(state => { + const expanded = new Set(state.expanded); + expanded.add(node); + return { expanded }; + }), + onCollapse: node => + this.setState(state => { + const expanded = new Set(state.expanded); + expanded.delete(node); + return { expanded }; + }), + onFocus: function() {}, + getKey, + getRoots: () => sites || [], + itemHeight: TREE_ROW_HEIGHT, + renderItem: (item, depth, focused, arrow, expanded) => + new OptimizationsItem({ + onViewSourceInDebugger, + item, + depth, + focused, + arrow, + expanded, + type: getRowType(item), + frameData, + }), + }); + } + + render() { + const header = this._createHeader(this.props); + const tree = this._createTree(this.props); + + return dom.div({}, header, tree); + } +} + +module.exports = JITOptimizations; diff --git a/devtools/client/performance/components/JITOptimizationsItem.js b/devtools/client/performance/components/JITOptimizationsItem.js new file mode 100644 index 0000000000..6f962a8606 --- /dev/null +++ b/devtools/client/performance/components/JITOptimizationsItem.js @@ -0,0 +1,228 @@ +/* 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("devtools/shared/l10n"); +const STRINGS_URI = "devtools/client/locales/jit-optimizations.properties"; +const L10N = new LocalizationHelper(STRINGS_URI); + +const { PluralForm } = require("devtools/shared/plural-form"); +const { + Component, + createFactory, +} = require("devtools/client/shared/vendor/react"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const Frame = createFactory(require("devtools/client/shared/components/Frame")); +const PROPNAME_MAX_LENGTH = 4; +// If TREE_ROW_HEIGHT changes, be sure to change `var(--jit-tree-row-height)` +// in `devtools/client/themes/jit-optimizations.css` +const TREE_ROW_HEIGHT = 14; + +const OPTIMIZATION_ITEM_TYPES = [ + "site", + "attempts", + "types", + "attempt", + "type", + "observedtype", +]; + +/* eslint-disable no-unused-vars */ +/** + * TODO - Re-enable this eslint rule. The JIT tool is a work in progress, and isn't fully + * integrated as of yet. + */ +const { + JITOptimizations, + hasSuccessfulOutcome, + isSuccessfulOutcome, +} = require("devtools/client/performance/modules/logic/jit"); +const OPTIMIZATION_FAILURE = L10N.getStr("jit.optimizationFailure"); +const JIT_SAMPLES = L10N.getStr("jit.samples"); +const JIT_TYPES = L10N.getStr("jit.types"); +const JIT_ATTEMPTS = L10N.getStr("jit.attempts"); + +/* eslint-enable no-unused-vars */ + +class JITOptimizationsItem extends Component { + static get propTypes() { + return { + onViewSourceInDebugger: PropTypes.func.isRequired, + frameData: PropTypes.object.isRequired, + type: PropTypes.oneOf(OPTIMIZATION_ITEM_TYPES).isRequired, + depth: PropTypes.number.isRequired, + arrow: PropTypes.element.isRequired, + item: PropTypes.object, + focused: PropTypes.bool, + }; + } + + constructor(props) { + super(props); + this._renderSite = this._renderSite.bind(this); + this._renderAttempts = this._renderAttempts.bind(this); + this._renderTypes = this._renderTypes.bind(this); + this._renderAttempt = this._renderAttempt.bind(this); + this._renderType = this._renderType.bind(this); + this._renderObservedType = this._renderObservedType.bind(this); + } + + _renderSite({ item: site, onViewSourceInDebugger, frameData }) { + const attempts = site.data.attempts; + const lastStrategy = attempts[attempts.length - 1].strategy; + let propString = ""; + const propertyName = site.data.propertyName; + + // Display property name if it exists + if (propertyName) { + if (propertyName.length > PROPNAME_MAX_LENGTH) { + propString = ` (.${propertyName.substr(0, PROPNAME_MAX_LENGTH)}…)`; + } else { + propString = ` (.${propertyName})`; + } + } + + const sampleString = PluralForm.get(site.samples, JIT_SAMPLES).replace( + "#1", + site.samples + ); + const text = dom.span( + { className: "optimization-site-title" }, + `${lastStrategy}${propString} – (${sampleString})` + ); + const frame = Frame({ + onClick: onViewSourceInDebugger, + frame: { + source: frameData.url, + line: +site.data.line, + column: site.data.column, + }, + }); + const children = [text, frame]; + + if (!hasSuccessfulOutcome(site)) { + children.unshift(dom.span({ className: "opt-icon warning" })); + } + + return dom.span({ className: "optimization-site" }, ...children); + } + + _renderAttempts({ item: attempts }) { + return dom.span( + { className: "optimization-attempts" }, + `${JIT_ATTEMPTS} (${attempts.length})` + ); + } + + _renderTypes({ item: types }) { + return dom.span( + { className: "optimization-types" }, + `${JIT_TYPES} (${types.length})` + ); + } + + _renderAttempt({ item: attempt }) { + const success = isSuccessfulOutcome(attempt.outcome); + const { strategy, outcome } = attempt; + return dom.span( + { className: "optimization-attempt" }, + dom.span({ className: "optimization-strategy" }, strategy), + " → ", + dom.span( + { + className: `optimization-outcome ${success ? "success" : "failure"}`, + }, + outcome + ) + ); + } + + _renderType({ item: type }) { + return dom.span( + { className: "optimization-ion-type" }, + `${type.site}:${type.mirType}` + ); + } + + _renderObservedType({ onViewSourceInDebugger, item: type }) { + const children = [ + dom.span( + { className: "optimization-observed-type-keyed" }, + `${type.keyedBy}${type.name ? ` → ${type.name}` : ""}` + ), + ]; + + // If we have a line and location, make a link to the debugger + if (type.location && type.line) { + children.push( + Frame({ + onClick: onViewSourceInDebugger, + frame: { + source: type.location, + line: type.line, + column: type.column, + }, + }) + ); + // Otherwise if we just have a location, it's probably just a memory location. + } else if (type.location) { + children.push(`@${type.location}`); + } + + return dom.span({ className: "optimization-observed-type" }, ...children); + } + + render() { + /* eslint-disable no-unused-vars */ + /** + * TODO - Re-enable this eslint rule. The JIT tool is a work in progress, and these + * undefined variables may represent intended functionality. + */ + const { + depth, + arrow, + type, + // TODO - The following are currently unused. + item, + focused, + frameData, + onViewSourceInDebugger, + } = this.props; + /* eslint-enable no-unused-vars */ + + let content; + switch (type) { + case "site": + content = this._renderSite(this.props); + break; + case "attempts": + content = this._renderAttempts(this.props); + break; + case "types": + content = this._renderTypes(this.props); + break; + case "attempt": + content = this._renderAttempt(this.props); + break; + case "type": + content = this._renderType(this.props); + break; + case "observedtype": + content = this._renderObservedType(this.props); + break; + } + + return dom.div( + { + className: `optimization-tree-item optimization-tree-item-${type}`, + style: { marginInlineStart: depth * TREE_ROW_HEIGHT }, + }, + arrow, + content + ); + } +} + +module.exports = JITOptimizationsItem; diff --git a/devtools/client/performance/components/RecordingButton.js b/devtools/client/performance/components/RecordingButton.js new file mode 100644 index 0000000000..f11b684a3d --- /dev/null +++ b/devtools/client/performance/components/RecordingButton.js @@ -0,0 +1,45 @@ +/* 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 { L10N } = require("devtools/client/performance/modules/global"); +const { Component } = require("devtools/client/shared/vendor/react"); +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const { button } = dom; + +class RecordingButton extends Component { + static get propTypes() { + return { + onRecordButtonClick: PropTypes.func.isRequired, + isRecording: PropTypes.bool, + isLocked: PropTypes.bool, + }; + } + + render() { + const { onRecordButtonClick, isRecording, isLocked } = this.props; + + const classList = ["devtools-button", "record-button"]; + + if (isRecording) { + classList.push("checked"); + } + + return button( + { + className: classList.join(" "), + onClick: onRecordButtonClick, + "data-standalone": "true", + "data-text-only": "true", + disabled: isLocked, + }, + isRecording + ? L10N.getStr("recordings.stop") + : L10N.getStr("recordings.start") + ); + } +} + +module.exports = RecordingButton; diff --git a/devtools/client/performance/components/RecordingControls.js b/devtools/client/performance/components/RecordingControls.js new file mode 100644 index 0000000000..8c753c45e6 --- /dev/null +++ b/devtools/client/performance/components/RecordingControls.js @@ -0,0 +1,63 @@ +/* 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 { L10N } = require("devtools/client/performance/modules/global"); +const { Component } = require("devtools/client/shared/vendor/react"); +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const { div, button } = dom; + +class RecordingControls extends Component { + static get propTypes() { + return { + onClearButtonClick: PropTypes.func.isRequired, + onRecordButtonClick: PropTypes.func.isRequired, + onImportButtonClick: PropTypes.func.isRequired, + isRecording: PropTypes.bool, + isLocked: PropTypes.bool, + }; + } + + render() { + const { + onClearButtonClick, + onRecordButtonClick, + onImportButtonClick, + isRecording, + isLocked, + } = this.props; + + const recordButtonClassList = ["devtools-button", "record-button"]; + + if (isRecording) { + recordButtonClassList.push("checked"); + } + + return div( + { className: "devtools-toolbar" }, + button({ + id: "clear-button", + className: "devtools-button", + title: L10N.getStr("recordings.clear.tooltip"), + onClick: onClearButtonClick, + }), + button({ + id: "main-record-button", + className: recordButtonClassList.join(" "), + disabled: isLocked, + title: L10N.getStr("recordings.start.tooltip"), + onClick: onRecordButtonClick, + }), + button({ + id: "import-button", + className: "devtools-button", + title: L10N.getStr("recordings.import.tooltip"), + onClick: onImportButtonClick, + }) + ); + } +} + +module.exports = RecordingControls; diff --git a/devtools/client/performance/components/RecordingList.js b/devtools/client/performance/components/RecordingList.js new file mode 100644 index 0000000000..161b612232 --- /dev/null +++ b/devtools/client/performance/components/RecordingList.js @@ -0,0 +1,32 @@ +/* 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("devtools/client/shared/vendor/react"); +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const { L10N } = require("devtools/client/performance/modules/global"); +const { ul, div } = dom; + +class RecordingList extends Component { + static get propTypes() { + return { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + itemComponent: PropTypes.func.isRequired, + }; + } + + render() { + const { items, itemComponent: Item } = this.props; + + return items.length > 0 + ? ul({ className: "recording-list" }, ...items.map(Item)) + : div( + { className: "recording-list-empty" }, + L10N.getStr("noRecordingsText") + ); + } +} + +module.exports = RecordingList; diff --git a/devtools/client/performance/components/RecordingListItem.js b/devtools/client/performance/components/RecordingListItem.js new file mode 100644 index 0000000000..e714aeebf2 --- /dev/null +++ b/devtools/client/performance/components/RecordingListItem.js @@ -0,0 +1,65 @@ +/* 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("devtools/client/shared/vendor/react"); +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const { div, li, span, button } = dom; +const { L10N } = require("devtools/client/performance/modules/global"); + +class RecordingListItem extends Component { + static get propTypes() { + return { + label: PropTypes.string.isRequired, + duration: PropTypes.string, + onSelect: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + isLoading: PropTypes.bool, + isSelected: PropTypes.bool, + isRecording: PropTypes.bool, + }; + } + + render() { + const { + label, + duration, + onSelect, + onSave, + isLoading, + isSelected, + isRecording, + } = this.props; + + const className = `recording-list-item ${isSelected ? "selected" : ""}`; + + let durationText; + if (isLoading) { + durationText = L10N.getStr("recordingsList.loadingLabel"); + } else if (isRecording) { + durationText = L10N.getStr("recordingsList.recordingLabel"); + } else { + durationText = L10N.getFormatStr( + "recordingsList.durationLabel", + duration + ); + } + + return li( + { className, onClick: onSelect }, + div({ className: "recording-list-item-label" }, label), + div( + { className: "recording-list-item-footer" }, + span({ className: "recording-list-item-duration" }, durationText), + button( + { className: "recording-list-item-save", onClick: onSave }, + L10N.getStr("recordingsList.saveLabel") + ) + ) + ); + } +} + +module.exports = RecordingListItem; diff --git a/devtools/client/performance/components/Waterfall.js b/devtools/client/performance/components/Waterfall.js new file mode 100644 index 0000000000..fe49948a8a --- /dev/null +++ b/devtools/client/performance/components/Waterfall.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"; + +/** + * This file contains the "waterfall" view, essentially a detailed list + * of all the markers in the timeline data. + */ + +const { createFactory } = require("devtools/client/shared/vendor/react"); +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const WaterfallHeader = createFactory( + require("devtools/client/performance/components/WaterfallHeader") +); +const WaterfallTree = createFactory( + require("devtools/client/performance/components/WaterfallTree") +); + +function Waterfall(props) { + return dom.div( + { className: "waterfall-markers" }, + WaterfallHeader(props), + WaterfallTree(props) + ); +} + +Waterfall.displayName = "Waterfall"; + +Waterfall.propTypes = { + marker: PropTypes.object.isRequired, + startTime: PropTypes.number.isRequired, + endTime: PropTypes.number.isRequired, + dataScale: PropTypes.number.isRequired, + sidebarWidth: PropTypes.number.isRequired, + waterfallWidth: PropTypes.number.isRequired, + onFocus: PropTypes.func, + onBlur: PropTypes.func, +}; + +module.exports = Waterfall; diff --git a/devtools/client/performance/components/WaterfallHeader.js b/devtools/client/performance/components/WaterfallHeader.js new file mode 100644 index 0000000000..eac566506b --- /dev/null +++ b/devtools/client/performance/components/WaterfallHeader.js @@ -0,0 +1,73 @@ +/* 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 "waterfall ticks" view, a header for the markers displayed in the waterfall. + */ + +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const { L10N } = require("devtools/client/performance/modules/global"); +const { + TickUtils, +} = require("devtools/client/performance/modules/waterfall-ticks"); + +const WATERFALL_HEADER_TICKS_MULTIPLE = 5; // ms +const WATERFALL_HEADER_TICKS_SPACING_MIN = 50; // px +const WATERFALL_HEADER_TEXT_PADDING = 3; // px + +function WaterfallHeader(props) { + const { startTime, dataScale, sidebarWidth, waterfallWidth } = props; + + const tickInterval = TickUtils.findOptimalTickInterval({ + ticksMultiple: WATERFALL_HEADER_TICKS_MULTIPLE, + ticksSpacingMin: WATERFALL_HEADER_TICKS_SPACING_MIN, + dataScale: dataScale, + }); + + const ticks = []; + for (let x = 0; x < waterfallWidth; x += tickInterval) { + const left = x + WATERFALL_HEADER_TEXT_PADDING; + const time = Math.round(x / dataScale + startTime); + const label = L10N.getFormatStr("timeline.tick", time); + + const node = dom.div( + { + key: x, + className: "plain waterfall-header-tick", + style: { transform: `translateX(${left}px)` }, + }, + label + ); + ticks.push(node); + } + + return dom.div( + { className: "waterfall-header" }, + dom.div( + { + className: "waterfall-sidebar theme-sidebar waterfall-header-name", + style: { width: sidebarWidth + "px" }, + }, + L10N.getStr("timeline.records") + ), + dom.div( + { className: "waterfall-header-ticks waterfall-background-ticks" }, + ticks + ) + ); +} + +WaterfallHeader.displayName = "WaterfallHeader"; + +WaterfallHeader.propTypes = { + startTime: PropTypes.number.isRequired, + dataScale: PropTypes.number.isRequired, + sidebarWidth: PropTypes.number.isRequired, + waterfallWidth: PropTypes.number.isRequired, +}; + +module.exports = WaterfallHeader; diff --git a/devtools/client/performance/components/WaterfallTree.js b/devtools/client/performance/components/WaterfallTree.js new file mode 100644 index 0000000000..c235f90b33 --- /dev/null +++ b/devtools/client/performance/components/WaterfallTree.js @@ -0,0 +1,189 @@ +/* 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("devtools/client/shared/vendor/react"); +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const Tree = createFactory( + require("devtools/client/shared/components/VirtualizedTree") +); +const WaterfallTreeRow = createFactory( + require("devtools/client/performance/components/WaterfallTreeRow") +); + +// Keep in sync with var(--waterfall-tree-row-height) in performance.css +const WATERFALL_TREE_ROW_HEIGHT = 15; // px + +/** + * Checks if a given marker is in the specified time range. + * + * @param object e + * The marker containing the { start, end } timestamps. + * @param number start + * The earliest allowed time. + * @param number end + * The latest allowed time. + * @return boolean + * True if the marker fits inside the specified time range. + */ +function isMarkerInRange(e, start, end) { + const mStart = e.start | 0; + const mEnd = e.end | 0; + + return ( + // bounds inside + (mStart >= start && mEnd <= end) || + // bounds outside + (mStart < start && mEnd > end) || + // overlap start + (mStart < start && mEnd >= start && mEnd <= end) || + // overlap end + (mEnd > end && mStart >= start && mStart <= end) + ); +} + +class WaterfallTree extends Component { + static get propTypes() { + return { + marker: PropTypes.object.isRequired, + startTime: PropTypes.number.isRequired, + endTime: PropTypes.number.isRequired, + dataScale: PropTypes.number.isRequired, + sidebarWidth: PropTypes.number.isRequired, + waterfallWidth: PropTypes.number.isRequired, + onFocus: PropTypes.func, + }; + } + + constructor(props) { + super(props); + + this.state = { + focused: null, + expanded: new Set(), + }; + + this._getRoots = this._getRoots.bind(this); + this._getParent = this._getParent.bind(this); + this._getChildren = this._getChildren.bind(this); + this._getKey = this._getKey.bind(this); + this._isExpanded = this._isExpanded.bind(this); + this._onExpand = this._onExpand.bind(this); + this._onCollapse = this._onCollapse.bind(this); + this._onFocus = this._onFocus.bind(this); + this._filter = this._filter.bind(this); + this._renderItem = this._renderItem.bind(this); + } + + _getRoots(node) { + const roots = this.props.marker.submarkers || []; + return roots.filter(this._filter); + } + + /** + * Find the parent node of 'node' with a depth-first search of the marker tree + */ + _getParent(node) { + function findParent(marker) { + if (marker.submarkers) { + for (const submarker of marker.submarkers) { + if (submarker === node) { + return marker; + } + + const parent = findParent(submarker); + if (parent) { + return parent; + } + } + } + + return null; + } + + const rootMarker = this.props.marker; + const parent = findParent(rootMarker); + + // We are interested only in parent markers that are rendered, + // which rootMarker is not. Return null if the parent is rootMarker. + return parent !== rootMarker ? parent : null; + } + + _getChildren(node) { + const submarkers = node.submarkers || []; + return submarkers.filter(this._filter); + } + + _getKey(node) { + return `marker-${node.index}`; + } + + _isExpanded(node) { + return this.state.expanded.has(node); + } + + _onExpand(node) { + this.setState(state => { + const expanded = new Set(state.expanded); + expanded.add(node); + return { expanded }; + }); + } + + _onCollapse(node) { + this.setState(state => { + const expanded = new Set(state.expanded); + expanded.delete(node); + return { expanded }; + }); + } + + _onFocus(node) { + this.setState({ focused: node }); + if (this.props.onFocus) { + this.props.onFocus(node); + } + } + + _filter(node) { + const { startTime, endTime } = this.props; + return isMarkerInRange(node, startTime, endTime); + } + + _renderItem(marker, level, focused, arrow, expanded) { + const { startTime, dataScale, sidebarWidth } = this.props; + return WaterfallTreeRow({ + marker, + level, + arrow, + expanded, + focused, + startTime, + dataScale, + sidebarWidth, + }); + } + + render() { + return Tree({ + preventNavigationOnArrowRight: false, + getRoots: this._getRoots, + getParent: this._getParent, + getChildren: this._getChildren, + getKey: this._getKey, + isExpanded: this._isExpanded, + onExpand: this._onExpand, + onCollapse: this._onCollapse, + onFocus: this._onFocus, + renderItem: this._renderItem, + focused: this.state.focused, + itemHeight: WATERFALL_TREE_ROW_HEIGHT, + }); + } +} + +module.exports = WaterfallTree; diff --git a/devtools/client/performance/components/WaterfallTreeRow.js b/devtools/client/performance/components/WaterfallTreeRow.js new file mode 100644 index 0000000000..745bb5d23e --- /dev/null +++ b/devtools/client/performance/components/WaterfallTreeRow.js @@ -0,0 +1,116 @@ +/* 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"; + +/** + * A single row (node) in the waterfall tree + */ + +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const { + MarkerBlueprintUtils, +} = require("devtools/client/performance/modules/marker-blueprint-utils"); + +const LEVEL_INDENT = 10; // px +const ARROW_NODE_OFFSET = -14; // px +const WATERFALL_MARKER_TIMEBAR_WIDTH_MIN = 5; // px + +function buildMarkerSidebar(blueprint, props) { + const { marker, level, sidebarWidth } = props; + + const bullet = dom.div({ + className: `waterfall-marker-bullet marker-color-${blueprint.colorName}`, + style: { transform: `translateX(${level * LEVEL_INDENT}px)` }, + "data-type": marker.name, + }); + + const label = MarkerBlueprintUtils.getMarkerLabel(marker); + + const name = dom.div( + { + className: "plain waterfall-marker-name", + style: { transform: `translateX(${level * LEVEL_INDENT}px)` }, + title: label, + }, + label + ); + + return dom.div( + { + className: "waterfall-sidebar theme-sidebar", + style: { width: sidebarWidth + "px" }, + }, + bullet, + name + ); +} + +function buildMarkerTimebar(blueprint, props) { + const { marker, startTime, dataScale, arrow } = props; + const offset = (marker.start - startTime) * dataScale + ARROW_NODE_OFFSET; + const width = Math.max( + (marker.end - marker.start) * dataScale, + WATERFALL_MARKER_TIMEBAR_WIDTH_MIN + ); + + const bar = dom.div( + { + className: "waterfall-marker-wrap", + style: { transform: `translateX(${offset}px)` }, + }, + arrow, + dom.div({ + className: `waterfall-marker-bar marker-color-${blueprint.colorName}`, + style: { width: `${width}px` }, + "data-type": marker.name, + }) + ); + + return dom.div( + { className: "waterfall-marker waterfall-background-ticks" }, + bar + ); +} + +function WaterfallTreeRow(props) { + const { marker, focused } = props; + const blueprint = MarkerBlueprintUtils.getBlueprintFor(marker); + + const attrs = { + className: "waterfall-tree-item" + (focused ? " focused" : ""), + "data-otmt": marker.isOffMainThread, + }; + + // Don't render an expando-arrow for leaf nodes. + const submarkers = marker.submarkers; + const hasDescendants = submarkers && submarkers.length > 0; + if (hasDescendants) { + attrs["data-expandable"] = ""; + } else { + attrs["data-invisible"] = ""; + } + + return dom.div( + attrs, + buildMarkerSidebar(blueprint, props), + buildMarkerTimebar(blueprint, props) + ); +} + +WaterfallTreeRow.displayName = "WaterfallTreeRow"; + +WaterfallTreeRow.propTypes = { + marker: PropTypes.object.isRequired, + level: PropTypes.number.isRequired, + arrow: PropTypes.element.isRequired, + expanded: PropTypes.bool.isRequired, + focused: PropTypes.bool.isRequired, + startTime: PropTypes.number.isRequired, + dataScale: PropTypes.number.isRequired, + sidebarWidth: PropTypes.number.isRequired, +}; + +module.exports = WaterfallTreeRow; diff --git a/devtools/client/performance/components/chrome/chrome.ini b/devtools/client/performance/components/chrome/chrome.ini new file mode 100644 index 0000000000..5ba24a9af7 --- /dev/null +++ b/devtools/client/performance/components/chrome/chrome.ini @@ -0,0 +1,5 @@ +[DEFAULT] +support-files = + head.js + +[test_jit_optimizations_01.html] diff --git a/devtools/client/performance/components/chrome/head.js b/devtools/client/performance/components/chrome/head.js new file mode 100644 index 0000000000..4b6627001e --- /dev/null +++ b/devtools/client/performance/components/chrome/head.js @@ -0,0 +1,245 @@ +/* Any copyright is dedicated to the Public Domain. + yield new Promise(function(){}); + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/* global window, document, SimpleTest, requestAnimationFrame, is, ok */ +/* exported Cc, Ci, Cu, Cr, Assert, Task, TargetFactory, Toolbox, browserRequire, + forceRender, setProps, dumpn, checkOptimizationHeader, checkOptimizationTree */ +const { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm"); +let { Assert } = require("resource://testing-common/Assert.jsm"); +const { BrowserLoader } = ChromeUtils.import( + "resource://devtools/client/shared/browser-loader.js" +); +const flags = require("devtools/shared/flags"); +let { TargetFactory } = require("devtools/client/framework/target"); +let { Toolbox } = require("devtools/client/framework/toolbox"); + +let { require: browserRequire } = BrowserLoader({ + baseURI: "resource://devtools/client/performance/", + window, +}); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); + +const $ = (selector, scope = document) => scope.querySelector(selector); +const $$ = (selector, scope = document) => scope.querySelectorAll(selector); + +function forceRender(comp) { + return setState(comp, {}).then(() => setState(comp, {})); +} + +// All tests are asynchronous. +SimpleTest.waitForExplicitFinish(); + +function onNextAnimationFrame(fn) { + return () => requestAnimationFrame(() => requestAnimationFrame(fn)); +} + +function setState(component, newState) { + return new Promise(resolve => { + component.setState(newState, onNextAnimationFrame(resolve)); + }); +} + +function setProps(component, newState) { + return new Promise(resolve => { + component.setProps(newState, onNextAnimationFrame(resolve)); + }); +} + +function dumpn(msg) { + dump(`PERFORMANCE-COMPONENT-TEST: ${msg}\n`); +} + +/** + * Default opts data for testing. First site has a simple IonType, + * and an IonType with an ObservedType, and a successful outcome. + * Second site does not have a successful outcome. + */ +const OPTS_DATA_GENERAL = [ + { + id: 1, + propertyName: "my property name", + line: 100, + column: 200, + samples: 90, + data: { + attempts: [ + { + id: 1, + strategy: "GetElem_TypedObject", + outcome: "AccessNotTypedObject", + }, + { id: 1, strategy: "GetElem_Dense", outcome: "AccessNotDense" }, + { id: 1, strategy: "GetElem_TypedStatic", outcome: "Disabled" }, + { id: 1, strategy: "GetElem_TypedArray", outcome: "GenericSuccess" }, + ], + types: [ + { + id: 1, + site: "Receiver", + mirType: "Object", + typeset: [ + { + id: 1, + keyedBy: "constructor", + name: "MyView", + location: "http://internet.com/file.js", + line: "123", + }, + ], + }, + { + id: 1, + typeset: void 0, + site: "Index", + mirType: "Int32", + }, + ], + }, + }, + { + id: 2, + propertyName: void 0, + line: 50, + column: 51, + samples: 100, + data: { + attempts: [ + { id: 2, strategy: "Call_Inline", outcome: "CantInlineBigData" }, + ], + types: [ + { + id: 2, + site: "Call_Target", + mirType: "Object", + typeset: [ + { id: 2, keyedBy: "primitive" }, + { + id: 2, + keyedBy: "constructor", + name: "B", + location: "http://mypage.com/file.js", + line: "2", + }, + { + id: 2, + keyedBy: "constructor", + name: "C", + location: "http://mypage.com/file.js", + line: "3", + }, + { + id: 2, + keyedBy: "constructor", + name: "D", + location: "http://mypage.com/file.js", + line: "4", + }, + ], + }, + ], + }, + }, +]; + +OPTS_DATA_GENERAL.forEach(site => { + site.data.types.forEach(type => { + if (type.typeset) { + type.typeset.id = site.id; + } + }); + site.data.attempts.id = site.id; + site.data.types.id = site.id; +}); + +function checkOptimizationHeader(name, file, line) { + is( + $(".optimization-header .header-function-name").textContent, + name, + "correct optimization header function name" + ); + is( + $(".optimization-header .frame-link-filename").textContent, + file, + "correct optimization header file name" + ); + is( + $(".optimization-header .frame-link-line").textContent, + `:${line}`, + "correct optimization header line" + ); +} + +function checkOptimizationTree(rowData) { + const rows = $$(".tree .tree-node"); + + for (let i = 0; i < rowData.length; i++) { + const row = rows[i]; + const expected = rowData[i]; + + switch (expected.type) { + case "site": + is( + $(".optimization-site-title", row).textContent, + `${expected.strategy} – (${expected.samples} samples)`, + `row ${i}th: correct optimization site row` + ); + + is( + !!$(".opt-icon.warning", row), + !!expected.failureIcon, + `row ${i}th: expected visibility of failure icon for unsuccessful outcomes` + ); + break; + case "types": + is( + $(".optimization-types", row).textContent, + `Types (${expected.count})`, + `row ${i}th: correct types row` + ); + break; + case "attempts": + is( + $(".optimization-attempts", row).textContent, + `Attempts (${expected.count})`, + `row ${i}th: correct attempts row` + ); + break; + case "type": + is( + $(".optimization-ion-type", row).textContent, + `${expected.site}:${expected.mirType}`, + `row ${i}th: correct ion type row` + ); + break; + case "observedtype": + is( + $(".optimization-observed-type-keyed", row).textContent, + expected.name + ? `${expected.keyedBy} → ${expected.name}` + : expected.keyedBy, + `row ${i}th: correct observed type row` + ); + break; + case "attempt": + is( + $(".optimization-strategy", row).textContent, + expected.strategy, + `row ${i}th: correct attempt row, attempt item` + ); + is( + $(".optimization-outcome", row).textContent, + expected.outcome, + `row ${i}th: correct attempt row, outcome item` + ); + ok( + $(".optimization-outcome", row).classList.contains( + expected.success ? "success" : "failure" + ), + `row ${i}th: correct attempt row, failure/success status` + ); + break; + } + } +} diff --git a/devtools/client/performance/components/chrome/test_jit_optimizations_01.html b/devtools/client/performance/components/chrome/test_jit_optimizations_01.html new file mode 100644 index 0000000000..cf2823d387 --- /dev/null +++ b/devtools/client/performance/components/chrome/test_jit_optimizations_01.html @@ -0,0 +1,70 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test the rendering of the JIT Optimizations tree. Tests when jit data has observed types, multiple observed types, multiple sites, a site with a successful strategy, site with no successful strategy. +--> +<head> + <meta charset="utf-8"> + <title>JITOptimizations component test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body style="height: 10000px;"> +<pre id="test"> +<script src="head.js" type="application/javascript"></script> +<script type="application/javascript"> +"use strict"; + +window.onload = async function () { + try { + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const React = browserRequire("devtools/client/shared/vendor/react"); + const JITOptimizations = React.createFactory(browserRequire("devtools/client/performance/components/JITOptimizations")); + ok(JITOptimizations, "Should get JITOptimizations"); + const opts = ReactDOM.render(JITOptimizations({ + onViewSourceInDebugger: function(){}, + frameData: { + isMetaCategory: false, + url: "http://internet.com/file.js", + line: 1, + functionName: "myfunc", + }, + optimizationSites: OPTS_DATA_GENERAL, + autoExpandDepth: 1000, + }), window.document.body); + await forceRender(opts); + + checkOptimizationHeader("myfunc", "file.js", "1"); + + checkOptimizationTree([ + { type: "site", strategy: "GetElem_TypedArray", samples: "90" }, + { type: "types", count: "2" }, + { type: "type", site: "Receiver", mirType: "Object" }, + { type: "observedtype", keyedBy: "constructor", name: "MyView" }, + { type: "type", site: "Index", mirType: "Int32" }, + { type: "attempts", count: "4" }, + { type: "attempt", strategy: "GetElem_TypedObject", outcome: "AccessNotTypedObject" }, + { type: "attempt", strategy: "GetElem_Dense", outcome: "AccessNotDense" }, + { type: "attempt", strategy: "GetElem_TypedStatic", outcome: "Disabled" }, + { type: "attempt", strategy: "GetElem_TypedArray", outcome: "GenericSuccess", success: true }, + { type: "site", strategy: "Call_Inline", samples: "100", failureIcon: true }, + { type: "types", count: "1" }, + { type: "type", site: "Call_Target", mirType: "Object" }, + { type: "observedtype", keyedBy: "primitive" }, + { type: "observedtype", keyedBy: "constructor", name: "B" }, + { type: "observedtype", keyedBy: "constructor", name: "C" }, + { type: "observedtype", keyedBy: "constructor", name: "D" }, + { type: "attempts", count: "1" }, + { type: "attempt", strategy: "Call_Inline", outcome: "CantInlineBigData" }, + ]); + + } catch(e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/client/performance/components/moz.build b/devtools/client/performance/components/moz.build new file mode 100644 index 0000000000..dc9ccff784 --- /dev/null +++ b/devtools/client/performance/components/moz.build @@ -0,0 +1,19 @@ +# 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/. + +DevToolsModules( + "JITOptimizations.js", + "JITOptimizationsItem.js", + "RecordingButton.js", + "RecordingControls.js", + "RecordingList.js", + "RecordingListItem.js", + "Waterfall.js", + "WaterfallHeader.js", + "WaterfallTree.js", + "WaterfallTreeRow.js", +) + +MOCHITEST_CHROME_MANIFESTS += ["chrome/chrome.ini"] diff --git a/devtools/client/performance/docs/markers.md b/devtools/client/performance/docs/markers.md new file mode 100644 index 0000000000..0053837321 --- /dev/null +++ b/devtools/client/performance/docs/markers.md @@ -0,0 +1,171 @@ +# Timeline Markers + +## Common + +* DOMHighResTimeStamp start +* DOMHighResTimeStamp end +* DOMString name +* object? stack +* object? endStack +* unsigned short processType; +* boolean isOffMainThread; + +The `processType` a GeckoProcessType enum listed in xpcom/build/nsXULAppAPI.h, +specifying if this marker originates in a content, chrome, plugin etc. process. +Additionally, markers may be created from any thread on those processes, and +`isOffMainThread` highights whether or not they're from the main thread. The most +common type of marker is probably going to be from a GeckoProcessType_Content's +main thread when debugging content. + +## DOMEvent + +Triggered when a DOM event occurs, like a click or a keypress. + +* unsigned short eventPhase - a number indicating what phase this event is + in (target, bubbling, capturing, maps to Event constants) +* DOMString type - the type of event, like "keypress" or "click" + +## Reflow + +Reflow markers (labeled as "Layout") indicate when a change has occurred to +a DOM element's positioning that requires the frame tree (rendering +representation of the DOM) to figure out the new position of a handful of +elements. Fired via `PresShell::DoReflow` + +## Paint + +* sequence<{ long height, long width, long x, long y }> rectangles - An array + of rectangle objects indicating where painting has occurred. + +## Styles + +Style markers (labeled as "Recalculating Styles") are triggered when Gecko +needs to figure out the computational style of an element. Fired via +`RestyleTracker::DoProcessRestyles` when there are elements to restyle. + +## Javascript + +`Javascript` markers are emitted indicating when JS execution begins and ends, +with a reason that triggered it (causeName), like a requestAnimationFrame or +a setTimeout. + +* string causeName - The reason that JS was entered. There are many possible + reasons, and the interesting ones to show web developers (triggered by content) are: + + * "\<script\> element" + * "EventListener.handleEvent" + * "setInterval handler" + * "setTimeout handler" + * "FrameRequestCallback" + * "EventHandlerNonNull" + * "promise callback" + * "promise initializer" + * "Worker runnable" + + There are also many more potential JS causes, some which are just internally + used and won't emit a marker, but the below ones are only of interest to + Gecko hackers, most likely + + * "promise thenable" + * "worker runnable" + * "nsHTTPIndex set HTTPIndex property" + * "XPCWrappedJS method call" + * "nsHTTPIndex OnFTPControlLog" + * "XPCWrappedJS QueryInterface" + * "xpcshell argument processing” + * "XPConnect sandbox evaluation " + * "component loader report global" + * "component loader load module" + * "Cross-Process Object Wrapper call/construct" + * "Cross-Process Object Wrapper ’set'" + * "Cross-Process Object Wrapper 'get'" + * "nsXULTemplateBuilder creation" + * "TestShellCommand" + * "precompiled XUL \<script\> element" + * "XBL \<field\> initialization " + * "NPAPI NPN_evaluate" + * "NPAPI get" + * "NPAPI set" + * "NPAPI doInvoke" + * "javascript: URI" + * "geolocation.always_precise indexing" + * "geolocation.app_settings enumeration" + * "WebIDL dictionary creation" + * "XBL \<constructor\>/\<destructor\> invocation" + * "message manager script load" + * "message handler script load" + * "nsGlobalWindow report new global" + +## GarbageCollection + +Emitted after a full GC cycle has completed (which is after any number of +incremental slices). + +* DOMString causeName - The reason for a GC event to occur. A full list of + GC reasons can be found [on MDN](https://developer.mozilla.org/en-US/docs/Tools/Debugger-API/Debugger.Memory#Debugger.Memory_Handler_Functions). +* DOMString nonincremenetalReason - If the GC could not do an incremental + GC (smaller, quick GC events), and we have to walk the entire heap and + GC everything marked, then the reason listed here is why. + +## nsCycleCollector::Collect + +An `nsCycleCollector::Collect` marker is emitted for each incremental cycle +collection slice and each non-incremental cycle collection. + +# nsCycleCollector::ForgetSkippable + +`nsCycleCollector::ForgetSkippable` is presented as "Cycle Collection", but in +reality it is preparation/pre-optimization for cycle collection, and not the +actual tracing of edges and collecting of cycles. + +## ConsoleTime + +A marker generated via `console.time()` and `console.timeEnd()`. + +* DOMString causeName - the label passed into `console.time(label)` and + `console.timeEnd(label)` if passed in. + +## TimeStamp + +A marker generated via `console.timeStamp(label)`. + +* DOMString causeName - the label passed into `console.timeStamp(label)` + if passed in. + +## document::DOMContentLoaded + +A marker generated when the DOMContentLoaded event is fired. + +## document::Load + +A marker generated when the document's "load" event is fired. + +## Parse HTML + +## Parse XML + +## Worker + +Emitted whenever there's an operation dealing with Workers (any kind of worker, +Web Workers, Service Workers etc.). Currently there are 4 types of operations +being tracked: serializing/deserializing data on the main thread, and also +serializing/deserializing data off the main thread. + +* ProfileTimelineWorkerOperationType operationType - the type of operation + being done by the Worker or the main thread when dealing with workers. + Can be one of the following enums defined in ProfileTimelineMarker.webidl + * "serializeDataOffMainThread" + * "serializeDataOnMainThread" + * "deserializeDataOffMainThread" + * "deserializeDataOnMainThread" + +## Composite + +Composite markers trace the actual time an inner composite operation +took on the compositor thread. Currently, these markers are only especially +interesting for Gecko platform developers, and thus disabled by default. + +## CompositeForwardTransaction + +Markers generated when the IPC request was made to the compositor from +the child process's main thread. diff --git a/devtools/client/performance/events.js b/devtools/client/performance/events.js new file mode 100644 index 0000000000..c6de3339c9 --- /dev/null +++ b/devtools/client/performance/events.js @@ -0,0 +1,110 @@ +/* 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 ControllerEvents = { + // Fired when a performance pref changes (either because the user changed it + // via the tool's UI, by changing something about:config or programatically). + PREF_CHANGED: "Performance:PrefChanged", + + // Fired when the devtools theme changes. + THEME_CHANGED: "Performance:ThemeChanged", + + // When a new recording model is received by the controller. + RECORDING_ADDED: "Performance:RecordingAdded", + + // When a recording model gets removed from the controller. + RECORDING_DELETED: "Performance:RecordingDeleted", + + // When a recording model becomes "started", "stopping" or "stopped". + RECORDING_STATE_CHANGE: "Performance:RecordingStateChange", + + // When a recording is offering information on the profiler's circular buffer. + RECORDING_PROFILER_STATUS_UPDATE: "Performance:RecordingProfilerStatusUpdate", + + // When a recording model becomes marked as selected. + RECORDING_SELECTED: "Performance:RecordingSelected", + + // When starting a recording is attempted and fails because the backend + // does not permit it at this time. + BACKEND_FAILED_AFTER_RECORDING_START: + "Performance:BackendFailedRecordingStart", + + // When a recording is started and the backend has started working. + BACKEND_READY_AFTER_RECORDING_START: "Performance:BackendReadyRecordingStart", + + // When a recording is stopped and the backend has finished cleaning up. + BACKEND_READY_AFTER_RECORDING_STOP: "Performance:BackendReadyRecordingStop", + + // When a recording is exported. + RECORDING_EXPORTED: "Performance:RecordingExported", + + // When a recording is imported. + RECORDING_IMPORTED: "Performance:RecordingImported", + + // When a source is shown in the JavaScript Debugger at a specific location. + SOURCE_SHOWN_IN_JS_DEBUGGER: "Performance:UI:SourceShownInJsDebugger", + SOURCE_NOT_FOUND_IN_JS_DEBUGGER: "Performance:UI:SourceNotFoundInJsDebugger", + + // Fired by the PerformanceController when `populateWithRecordings` is finished. + RECORDINGS_SEEDED: "Performance:RecordingsSeeded", +}; + +const ViewEvents = { + // Emitted by the `ToolbarView` when a preference changes. + UI_PREF_CHANGED: "Performance:UI:PrefChanged", + + // When the state (display mode) changes, for example when switching between + // "empty", "recording" or "recorded". This causes certain parts of the UI + // to be hidden or visible. + UI_STATE_CHANGED: "Performance:UI:StateChanged", + + // Emitted by the `PerformanceView` on clear button click. + UI_CLEAR_RECORDINGS: "Performance:UI:ClearRecordings", + + // Emitted by the `PerformanceView` on record button click. + UI_START_RECORDING: "Performance:UI:StartRecording", + UI_STOP_RECORDING: "Performance:UI:StopRecording", + + // Emitted by the `PerformanceView` on import/export button click. + UI_IMPORT_RECORDING: "Performance:UI:ImportRecording", + UI_EXPORT_RECORDING: "Performance:UI:ExportRecording", + + // Emitted by the `PerformanceView` when the profiler's circular buffer + // status has been rendered. + UI_RECORDING_PROFILER_STATUS_RENDERED: + "Performance:UI:RecordingProfilerStatusRendered", + + // When a recording is selected in the UI. + UI_RECORDING_SELECTED: "Performance:UI:RecordingSelected", + + // Emitted by the `DetailsView` when a subview is selected + UI_DETAILS_VIEW_SELECTED: "Performance:UI:DetailsViewSelected", + + // Emitted by the `OverviewView` after something has been rendered. + UI_OVERVIEW_RENDERED: "Performance:UI:OverviewRendered", + UI_MARKERS_GRAPH_RENDERED: "Performance:UI:OverviewMarkersRendered", + UI_MEMORY_GRAPH_RENDERED: "Performance:UI:OverviewMemoryRendered", + UI_FRAMERATE_GRAPH_RENDERED: "Performance:UI:OverviewFramerateRendered", + + // Emitted by the `OverviewView` when a range has been selected in the graphs. + UI_OVERVIEW_RANGE_SELECTED: "Performance:UI:OverviewRangeSelected", + + // Emitted by the `WaterfallView` when it has been rendered. + UI_WATERFALL_RENDERED: "Performance:UI:WaterfallRendered", + + // Emitted by the `JsCallTreeView` when it has been rendered. + UI_JS_CALL_TREE_RENDERED: "Performance:UI:JsCallTreeRendered", + + // Emitted by the `JsFlameGraphView` when it has been rendered. + UI_JS_FLAMEGRAPH_RENDERED: "Performance:UI:JsFlameGraphRendered", + + // Emitted by the `MemoryCallTreeView` when it has been rendered. + UI_MEMORY_CALL_TREE_RENDERED: "Performance:UI:MemoryCallTreeRendered", + + // Emitted by the `MemoryFlameGraphView` when it has been rendered. + UI_MEMORY_FLAMEGRAPH_RENDERED: "Performance:UI:MemoryFlameGraphRendered", +}; + +module.exports = Object.assign({}, ControllerEvents, ViewEvents); diff --git a/devtools/client/performance/index.xhtml b/devtools/client/performance/index.xhtml new file mode 100644 index 0000000000..4ef54d0662 --- /dev/null +++ b/devtools/client/performance/index.xhtml @@ -0,0 +1,354 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://devtools/content/shared/widgets/widgets.css" type="text/css"?> +<?xml-stylesheet href="chrome://devtools/content/shared/toolbarbutton.css" type="text/css"?> +<?xml-stylesheet href="chrome://devtools/skin/widgets.css" type="text/css"?> +<?xml-stylesheet href="chrome://devtools/skin/performance.css" type="text/css"?> +<?xml-stylesheet href="chrome://devtools/skin/jit-optimizations.css" type="text/css"?> +<?xml-stylesheet href="chrome://devtools/skin/components-frame.css" type="text/css"?> +<!DOCTYPE window [ + <!ENTITY % performanceDTD SYSTEM "chrome://devtools/locale/performance.dtd"> + %performanceDTD; +]> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + <script src="chrome://devtools/content/shared/theme-switching.js"/> + <script src="resource://devtools/client/performance/initializer.js"/> + + <popupset id="performance-options-popupset"> + <menupopup id="performance-filter-menupopup" position="before_start"/> + <menupopup id="performance-options-menupopup" position="before_end"> + <menuitem id="option-show-platform-data" + type="checkbox" + data-pref="show-platform-data" + label="&performanceUI.showPlatformData;" + tooltiptext="&performanceUI.showPlatformData.tooltiptext;"/> + <menuitem id="option-show-jit-optimizations" + class="experimental-option" + type="checkbox" + data-pref="show-jit-optimizations" + label="&performanceUI.showJITOptimizations;" + tooltiptext="&performanceUI.showJITOptimizations.tooltiptext;"/> + <menuitem id="option-enable-memory" + class="experimental-option" + type="checkbox" + data-pref="enable-memory" + label="&performanceUI.enableMemory;" + tooltiptext="&performanceUI.enableMemory.tooltiptext;"/> + <menuitem id="option-enable-allocations" + type="checkbox" + data-pref="enable-allocations" + label="&performanceUI.enableAllocations;" + tooltiptext="&performanceUI.enableAllocations.tooltiptext;"/> + <menuitem id="option-enable-framerate" + type="checkbox" + data-pref="enable-framerate" + label="&performanceUI.enableFramerate;" + tooltiptext="&performanceUI.enableFramerate.tooltiptext;"/> + <menuitem id="option-invert-call-tree" + type="checkbox" + data-pref="invert-call-tree" + label="&performanceUI.invertTree;" + tooltiptext="&performanceUI.invertTree.tooltiptext;"/> + <menuitem id="option-invert-flame-graph" + type="checkbox" + data-pref="invert-flame-graph" + label="&performanceUI.invertFlameGraph;" + tooltiptext="&performanceUI.invertFlameGraph.tooltiptext;"/> + <menuitem id="option-flatten-tree-recursion" + type="checkbox" + data-pref="flatten-tree-recursion" + label="&performanceUI.flattenTreeRecursion;" + tooltiptext="&performanceUI.flattenTreeRecursion.tooltiptext;"/> + </menupopup> + </popupset> + + <hbox id="body" class="theme-body performance-tool" flex="1"> + + <!-- Sidebar: controls and recording list --> + <vbox id="recordings-pane"> + <hbox id="recordings-controls"> + <html:div id='recording-controls-mount'/> + </hbox> + <vbox id="recordings-list" class="theme-sidebar" flex="1"> + <html:div id="recording-list-mount"/> + </vbox> + </vbox> + + <!-- Main panel content --> + <vbox id="performance-pane" flex="1"> + + <!-- Top toolbar controls --> + <toolbar id="performance-toolbar" + class="devtools-toolbar"> + <hbox id="performance-toolbar-controls-other" + class="devtools-toolbarbutton-group"> + <toolbarbutton id="filter-button" + class="devtools-toolbarbutton" + popup="performance-filter-menupopup" + tooltiptext="&performanceUI.options.filter.tooltiptext;"/> + </hbox> + <hbox id="performance-toolbar-controls-detail-views" + class="devtools-toolbarbutton-group"> + <toolbarbutton id="select-waterfall-view" + class="devtools-toolbarbutton" + label="&performanceUI.toolbar.waterfall;" + hidden="true" + data-view="waterfall" + tooltiptext="&performanceUI.toolbar.waterfall.tooltiptext;" /> + <toolbarbutton id="select-js-calltree-view" + class="devtools-toolbarbutton" + label="&performanceUI.toolbar.js-calltree;" + hidden="true" + data-view="js-calltree" + tooltiptext="&performanceUI.toolbar.js-calltree.tooltiptext;" /> + <toolbarbutton id="select-js-flamegraph-view" + class="devtools-toolbarbutton" + label="&performanceUI.toolbar.js-flamegraph;" + hidden="true" + data-view="js-flamegraph" + tooltiptext="&performanceUI.toolbar.js-flamegraph.tooltiptext;" /> + <toolbarbutton id="select-memory-calltree-view" + class="devtools-toolbarbutton" + label="&performanceUI.toolbar.memory-calltree;" + hidden="true" + data-view="memory-calltree" + tooltiptext="&performanceUI.toolbar.allocations.tooltiptext;" /> + <toolbarbutton id="select-memory-flamegraph-view" + class="devtools-toolbarbutton" + label="&performanceUI.toolbar.memory-flamegraph;" + hidden="true" + data-view="memory-flamegraph" /> + </hbox> + <spacer flex="1"></spacer> + <hbox id="performance-toolbar-controls-options" + class="devtools-toolbarbutton-group"> + <toolbarbutton id="performance-options-button" + class="devtools-toolbarbutton devtools-option-toolbarbutton" + popup="performance-options-menupopup" + tooltiptext="&performanceUI.options.gear.tooltiptext;"/> + </hbox> + </toolbar> + + <!-- Recording contents and general notice messages --> + <deck id="performance-view" flex="1"> + + <!-- A default notice, shown while initially opening the tool. + Keep this element the first child of #performance-view. --> + <hbox id="tool-loading-notice" + class="notice-container" + flex="1"> + </hbox> + + <!-- "Unavailable" notice, shown when the entire tool is disabled, + for example, when in private browsing mode. --> + <vbox id="unavailable-notice" + class="notice-container" + align="center" + pack="center" + flex="1"> + <hbox pack="center"> + <html:div class='recording-button-mount'/> + </hbox> + <description class="tool-disabled-message"> + &performanceUI.unavailableNoticePB; + </description> + </vbox> + + <!-- "Empty" notice, shown when there's no recordings available --> + <hbox id="empty-notice" + class="notice-container" + align="center" + pack="center" + flex="1"> + <hbox pack="center"> + <html:div class='recording-button-mount'/> + </hbox> + </hbox> + + <!-- Recording contents --> + <vbox id="performance-view-content" flex="1"> + + <!-- Overview graphs --> + <vbox id="overview-pane"> + <hbox id="markers-overview"/> + <hbox id="memory-overview"/> + <hbox id="time-framerate"/> + </vbox> + + <!-- Detail views and specific notice messages --> + <deck id="details-pane-container" flex="1"> + + <!-- "Loading" notice, shown when a recording is being loaded --> + <hbox id="loading-notice" + class="notice-container devtools-throbber" + align="center" + pack="center" + flex="1"> + <label value="&performanceUI.loadingNotice;"/> + </hbox> + + <!-- "Recording" notice, shown when a recording is in progress --> + <vbox id="recording-notice" + class="notice-container" + align="center" + pack="center" + flex="1"> + <hbox pack="center"> + <html:div class='recording-button-mount'/> + </hbox> + <label class="realtime-disabled-on-e10s-message" + value="&performanceUI.disabledRealTime.disabledE10S;"/> + <label class="buffer-status-message" + tooltiptext="&performanceUI.bufferStatusTooltip;"/> + <label class="buffer-status-message-full" + value="&performanceUI.bufferStatusFull;"/> + </vbox> + + <!-- "Console" notice, shown when a console recording is in progress --> + <vbox id="console-recording-notice" + class="notice-container" + align="center" + pack="center" + flex="1"> + <hbox class="console-profile-recording-notice"> + <label value="&performanceUI.console.recordingNoticeStart;" /> + <label class="console-profile-command" /> + <label value="&performanceUI.console.recordingNoticeEnd;" /> + </hbox> + <hbox class="console-profile-stop-notice"> + <label value="&performanceUI.console.stopCommandStart;" /> + <label class="console-profile-command" /> + <label value="&performanceUI.console.stopCommandEnd;" /> + </hbox> + <label class="realtime-disabled-on-e10s-message" + value="&performanceUI.disabledRealTime.disabledE10S;"/> + <label class="buffer-status-message" + tooltiptext="&performanceUI.bufferStatusTooltip;"/> + <label class="buffer-status-message-full" + value="&performanceUI.bufferStatusFull;"/> + </vbox> + + <!-- Detail views --> + <deck id="details-pane" flex="1"> + + <!-- Waterfall --> + <hbox id="waterfall-view" flex="1"> + <html:div xmlns="http://www.w3.org/1999/xhtml" id="waterfall-tree" /> + <splitter class="devtools-side-splitter"/> + <vbox id="waterfall-details" + class="theme-sidebar"/> + </hbox> + + <!-- JS Tree and JIT view --> + <hbox id="js-profile-view" flex="1"> + <vbox id="js-calltree-view" flex="1"> + <hbox class="call-tree-headers-container"> + <label class="plain call-tree-header" + type="duration" + crop="end" + value="&performanceUI.table.totalDuration;" + tooltiptext="&performanceUI.table.totalDuration.tooltip;"/> + <label class="plain call-tree-header" + type="percentage" + crop="end" + value="&performanceUI.table.totalPercentage;" + tooltiptext="&performanceUI.table.totalPercentage.tooltip;"/> + <label class="plain call-tree-header" + type="self-duration" + crop="end" + value="&performanceUI.table.selfDuration;" + tooltiptext="&performanceUI.table.selfDuration.tooltip;"/> + <label class="plain call-tree-header" + type="self-percentage" + crop="end" + value="&performanceUI.table.selfPercentage;" + tooltiptext="&performanceUI.table.selfPercentage.tooltip;"/> + <label class="plain call-tree-header" + type="samples" + crop="end" + value="&performanceUI.table.samples;" + tooltiptext="&performanceUI.table.samples.tooltip;"/> + <label class="plain call-tree-header" + type="function" + crop="end" + value="&performanceUI.table.function;" + tooltiptext="&performanceUI.table.function.tooltip;"/> + </hbox> + <vbox class="call-tree-cells-container" flex="1"/> + </vbox> + <splitter class="devtools-side-splitter"/> + <!-- Optimizations Panel --> + <vbox id="jit-optimizations-view" + class="hidden"> + </vbox> + </hbox> + + <!-- JS FlameChart --> + <hbox id="js-flamegraph-view" flex="1"> + </hbox> + + <!-- Memory Tree --> + <vbox id="memory-calltree-view" flex="1"> + <hbox class="call-tree-headers-container"> + <label class="plain call-tree-header" + type="self-size" + crop="end" + value="Self Bytes" + tooltiptext="&performanceUI.table.totalAlloc.tooltip;"/> + <label class="plain call-tree-header" + type="self-size-percentage" + crop="end" + value="Self Bytes %" + tooltiptext="&performanceUI.table.totalAlloc.tooltip;"/> + <label class="plain call-tree-header" + type="self-count" + crop="end" + value="Self Count" + tooltiptext="&performanceUI.table.totalAlloc.tooltip;"/> + <label class="plain call-tree-header" + type="self-count-percentage" + crop="end" + value="Self Count %" + tooltiptext="&performanceUI.table.totalAlloc.tooltip;"/> + <label class="plain call-tree-header" + type="size" + crop="end" + value="Total Size" + tooltiptext="&performanceUI.table.totalAlloc.tooltip;"/> + <label class="plain call-tree-header" + type="size-percentage" + crop="end" + value="Total Size %" + tooltiptext="&performanceUI.table.totalAlloc.tooltip;"/> + <label class="plain call-tree-header" + type="count" + crop="end" + value="Total Count" + tooltiptext="&performanceUI.table.totalAlloc.tooltip;"/> + <label class="plain call-tree-header" + type="count-percentage" + crop="end" + value="Total Count %" + tooltiptext="&performanceUI.table.totalAlloc.tooltip;"/> + <label class="plain call-tree-header" + type="function" + crop="end" + value="&performanceUI.table.function;"/> + </hbox> + <vbox class="call-tree-cells-container" flex="1"/> + </vbox> + + <!-- Memory FlameChart --> + <hbox id="memory-flamegraph-view" flex="1"></hbox> + </deck> + </deck> + </vbox> + </deck> + </vbox> + </hbox> +</window> diff --git a/devtools/client/performance/initializer.js b/devtools/client/performance/initializer.js new file mode 100644 index 0000000000..6623be5157 --- /dev/null +++ b/devtools/client/performance/initializer.js @@ -0,0 +1,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 { BrowserLoader } = ChromeUtils.import( + "resource://devtools/client/shared/browser-loader.js" +); +const { require } = BrowserLoader({ + baseURI: "resource://devtools/client/performance/", + window: window, +}); + +const { + PerformanceController, +} = require("devtools/client/performance/performance-controller"); +const { + PerformanceView, +} = require("devtools/client/performance/performance-view"); +const { DetailsView } = require("devtools/client/performance/views/details"); +const { + DetailsSubview, +} = require("devtools/client/performance/views/details-abstract-subview"); +const { + JsCallTreeView, +} = require("devtools/client/performance/views/details-js-call-tree"); +const { + JsFlameGraphView, +} = require("devtools/client/performance/views/details-js-flamegraph"); +const { + MemoryCallTreeView, +} = require("devtools/client/performance/views/details-memory-call-tree"); +const { + MemoryFlameGraphView, +} = require("devtools/client/performance/views/details-memory-flamegraph"); +const { OverviewView } = require("devtools/client/performance/views/overview"); +const { + RecordingsView, +} = require("devtools/client/performance/views/recordings"); +const { ToolbarView } = require("devtools/client/performance/views/toolbar"); +const { + WaterfallView, +} = require("devtools/client/performance/views/details-waterfall"); + +const EVENTS = require("devtools/client/performance/events"); + +/** + * The performance panel used to only share modules through references on the window + * object. We started cleaning this up and to require() explicitly in Bug 1524982, but + * some modules and tests are still relying on those references so we keep exposing them + * for the moment. Bug 1528777. + */ +window.PerformanceController = PerformanceController; +window.PerformanceView = PerformanceView; +window.DetailsView = DetailsView; +window.DetailsSubview = DetailsSubview; +window.JsCallTreeView = JsCallTreeView; +window.JsFlameGraphView = JsFlameGraphView; +window.MemoryCallTreeView = MemoryCallTreeView; +window.MemoryFlameGraphView = MemoryFlameGraphView; +window.OverviewView = OverviewView; +window.RecordingsView = RecordingsView; +window.ToolbarView = ToolbarView; +window.WaterfallView = WaterfallView; + +window.EVENTS = EVENTS; + +/** + * DOM query helpers. + */ +/* exported $, $$ */ +function $(selector, target = document) { + return target.querySelector(selector); +} +window.$ = $; +function $$(selector, target = document) { + return target.querySelectorAll(selector); +} +window.$$ = $$; diff --git a/devtools/client/performance/modules/categories.js b/devtools/client/performance/modules/categories.js new file mode 100644 index 0000000000..f6ef7073ef --- /dev/null +++ b/devtools/client/performance/modules/categories.js @@ -0,0 +1,87 @@ +/* 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 { L10N } = require("devtools/client/performance/modules/global"); + +/** + * Details about each label stack frame category. + * To be kept in sync with the JS::ProfilingCategory enum in ProfilingCategory.h + */ +const CATEGORIES = [ + { + color: "#d99b28", + abbrev: "idle", + label: L10N.getStr("category.idle"), + }, + { + color: "#5e88b0", + abbrev: "other", + label: L10N.getStr("category.other"), + }, + { + color: "#46afe3", + abbrev: "layout", + label: L10N.getStr("category.layout"), + }, + { + color: "#d96629", + abbrev: "js", + label: L10N.getStr("category.js"), + }, + { + color: "#eb5368", + abbrev: "gc", + label: L10N.getStr("category.gc"), + }, + { + color: "#df80ff", + abbrev: "network", + label: L10N.getStr("category.network"), + }, + { + color: "#70bf53", + abbrev: "graphics", + label: L10N.getStr("category.graphics"), + }, + { + color: "#8fa1b2", + abbrev: "dom", + label: L10N.getStr("category.dom"), + }, + { + // The devtools-only "tools" category which gets computed by + // computeIsContentAndCategory and is not part of the category list in + // ProfilingStack.h. + color: "#8fa1b2", + abbrev: "tools", + label: L10N.getStr("category.tools"), + }, +]; + +/** + * Get the numeric index for the given category abbreviation. + * See `CATEGORIES` above. + */ +const CATEGORY_INDEX = (() => { + const indexForCategory = {}; + for ( + let categoryIndex = 0; + categoryIndex < CATEGORIES.length; + categoryIndex++ + ) { + const category = CATEGORIES[categoryIndex]; + indexForCategory[category.abbrev] = categoryIndex; + } + + return function(name) { + if (!(name in indexForCategory)) { + throw new Error(`Category abbreviation "${name}" does not exist.`); + } + return indexForCategory[name]; + }; +})(); + +exports.CATEGORIES = CATEGORIES; +exports.CATEGORY_INDEX = CATEGORY_INDEX; diff --git a/devtools/client/performance/modules/constants.js b/devtools/client/performance/modules/constants.js new file mode 100644 index 0000000000..a0adaf5961 --- /dev/null +++ b/devtools/client/performance/modules/constants.js @@ -0,0 +1,11 @@ +/* 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"; + +exports.Constants = { + // ms + FRAMERATE_GRAPH_LOW_RES_INTERVAL: 100, + // ms + FRAMERATE_GRAPH_HIGH_RES_INTERVAL: 16, +}; diff --git a/devtools/client/performance/modules/global.js b/devtools/client/performance/modules/global.js new file mode 100644 index 0000000000..7816becc70 --- /dev/null +++ b/devtools/client/performance/modules/global.js @@ -0,0 +1,36 @@ +/* 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 { MultiLocalizationHelper } = require("devtools/shared/l10n"); +const { PrefsHelper } = require("devtools/client/shared/prefs"); + +/** + * Localization convenience methods. + */ +exports.L10N = new MultiLocalizationHelper( + "devtools/client/locales/markers.properties", + "devtools/client/locales/performance.properties" +); + +/** + * A list of preferences for this tool. The values automatically update + * if somebody edits edits about:config or the prefs change somewhere else. + * + * This needs to be registered and unregistered when used for the auto-update + * functionality to work. The PerformanceController handles this, but if you + * just use this module in a test independently, ensure you call + * `registerObserver()` and `unregisterUnobserver()`. + */ +exports.PREFS = new PrefsHelper("devtools.performance", { + "show-triggers-for-gc-types": ["Char", "ui.show-triggers-for-gc-types"], + "show-platform-data": ["Bool", "ui.show-platform-data"], + "hidden-markers": ["Json", "timeline.hidden-markers"], + "memory-sample-probability": ["Float", "memory.sample-probability"], + "memory-max-log-length": ["Int", "memory.max-log-length"], + "profiler-buffer-size": ["Int", "profiler.buffer-size"], + "profiler-sample-frequency": ["Int", "profiler.sample-frequency-hz"], + // TODO: re-enable once we flame charts via bug 1148663. + "enable-memory-flame": ["Bool", "ui.enable-memory-flame"], +}); diff --git a/devtools/client/performance/modules/io.js b/devtools/client/performance/modules/io.js new file mode 100644 index 0000000000..97fb16dc41 --- /dev/null +++ b/devtools/client/performance/modules/io.js @@ -0,0 +1,173 @@ +/* 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 } = require("chrome"); + +const RecordingUtils = require("devtools/shared/performance/recording-utils"); +const { FileUtils } = require("resource://gre/modules/FileUtils.jsm"); +const { NetUtil } = require("resource://gre/modules/NetUtil.jsm"); + +// This identifier string is used to tentatively ascertain whether or not +// a JSON loaded from disk is actually something generated by this tool. +// It isn't, of course, a definitive verification, but a Good Enough™ +// approximation before continuing the import. Don't localize this. +const PERF_TOOL_SERIALIZER_IDENTIFIER = "Recorded Performance Data"; +const PERF_TOOL_SERIALIZER_LEGACY_VERSION = 1; +const PERF_TOOL_SERIALIZER_CURRENT_VERSION = 2; + +/** + * Helpers for importing/exporting JSON. + */ + +/** + * Gets a nsIScriptableUnicodeConverter instance with a default UTF-8 charset. + * @return object + */ +function getUnicodeConverter() { + const cname = "@mozilla.org/intl/scriptableunicodeconverter"; + const converter = Cc[cname].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + return converter; +} + +/** + * Saves a recording as JSON to a file. The provided data is assumed to be + * acyclical, so that it can be properly serialized. + * + * @param object recordingData + * The recording data to stream as JSON. + * @param nsIFile file + * The file to stream the data into. + * @return object + * A promise that is resolved once streaming finishes, or rejected + * if there was an error. + */ +function saveRecordingToFile(recordingData, file) { + recordingData.fileType = PERF_TOOL_SERIALIZER_IDENTIFIER; + recordingData.version = PERF_TOOL_SERIALIZER_CURRENT_VERSION; + + const string = JSON.stringify(recordingData); + const inputStream = getUnicodeConverter().convertToInputStream(string); + const outputStream = FileUtils.openSafeFileOutputStream(file); + + return new Promise(resolve => { + NetUtil.asyncCopy(inputStream, outputStream, resolve); + }); +} + +/** + * Loads a recording stored as JSON from a file. + * + * @param nsIFile file + * The file to import the data from. + * @return object + * A promise that is resolved once importing finishes, or rejected + * if there was an error. + */ +function loadRecordingFromFile(file) { + const channel = NetUtil.newChannel({ + uri: NetUtil.newURI(file), + loadUsingSystemPrincipal: true, + }); + + channel.contentType = "text/plain"; + + return new Promise((resolve, reject) => { + NetUtil.asyncFetch(channel, inputStream => { + let recordingData; + + try { + const string = NetUtil.readInputStreamToString( + inputStream, + inputStream.available() + ); + recordingData = JSON.parse(string); + } catch (e) { + reject(new Error("Could not read recording data file.")); + return; + } + + if (recordingData.fileType != PERF_TOOL_SERIALIZER_IDENTIFIER) { + reject(new Error("Unrecognized recording data file.")); + return; + } + + if (!isValidSerializerVersion(recordingData.version)) { + reject(new Error("Unsupported recording data file version.")); + return; + } + + if (recordingData.version === PERF_TOOL_SERIALIZER_LEGACY_VERSION) { + recordingData = convertLegacyData(recordingData); + } + + if (recordingData.profile.meta.version === 2) { + RecordingUtils.deflateProfile(recordingData.profile); + } + + // If the recording has no label, set it to be the + // filename without its extension. + if (!recordingData.label) { + recordingData.label = file.leafName.replace(/\.[^.]+$/, ""); + } + + resolve(recordingData); + }); + }); +} + +/** + * Returns a boolean indicating whether or not the passed in `version` + * is supported by this serializer. + * + * @param number version + * @return boolean + */ +function isValidSerializerVersion(version) { + return !!~[ + PERF_TOOL_SERIALIZER_LEGACY_VERSION, + PERF_TOOL_SERIALIZER_CURRENT_VERSION, + ].indexOf(version); +} + +/** + * Takes recording data (with version `1`, from the original profiler tool), + * and massages the data to be line with the current performance tool's + * property names and values. + * + * @param object legacyData + * @return object + */ +function convertLegacyData(legacyData) { + const { profilerData, ticksData, recordingDuration } = legacyData; + + // The `profilerData` and `ticksData` stay, but the previously unrecorded + // fields just are empty arrays or objects. + const data = { + label: profilerData.profilerLabel, + duration: recordingDuration, + markers: [], + frames: [], + memory: [], + ticks: ticksData, + allocations: { sites: [], timestamps: [], frames: [], sizes: [] }, + profile: profilerData.profile, + // Fake a configuration object here if there's tick data, + // so that it can be rendered. + configuration: { + withTicks: !!ticksData.length, + withMarkers: false, + withMemory: false, + withAllocations: false, + }, + systemHost: {}, + systemClient: {}, + }; + + return data; +} + +exports.saveRecordingToFile = saveRecordingToFile; +exports.loadRecordingFromFile = loadRecordingFromFile; diff --git a/devtools/client/performance/modules/logic/frame-utils.js b/devtools/client/performance/modules/logic/frame-utils.js new file mode 100644 index 0000000000..a799381274 --- /dev/null +++ b/devtools/client/performance/modules/logic/frame-utils.js @@ -0,0 +1,510 @@ +/* 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 global = require("devtools/client/performance/modules/global"); +const demangle = require("devtools/client/shared/demangle"); +const { assert } = require("devtools/shared/DevToolsUtils"); +const { + isChromeScheme, + isContentScheme, + isWASM, + parseURL, +} = require("devtools/client/shared/source-utils"); + +const { + CATEGORY_INDEX, + CATEGORIES, +} = require("devtools/client/performance/modules/categories"); + +// Character codes used in various parsing helper functions. +const CHAR_CODE_R = "r".charCodeAt(0); +const CHAR_CODE_0 = "0".charCodeAt(0); +const CHAR_CODE_9 = "9".charCodeAt(0); +const CHAR_CODE_CAP_Z = "Z".charCodeAt(0); + +const CHAR_CODE_LPAREN = "(".charCodeAt(0); +const CHAR_CODE_RPAREN = ")".charCodeAt(0); +const CHAR_CODE_COLON = ":".charCodeAt(0); +const CHAR_CODE_SPACE = " ".charCodeAt(0); +const CHAR_CODE_UNDERSCORE = "_".charCodeAt(0); + +const EVAL_TOKEN = "%20%3E%20eval"; + +// The cache used to store inflated frames. +const gInflatedFrameStore = new WeakMap(); + +// The cache used to store frame data from `getInfo`. +const gFrameData = new WeakMap(); + +/** + * Parses the raw location of this function call to retrieve the actual + * function name, source url, host name, line and column. + */ +// eslint-disable-next-line complexity +function parseLocation(location, fallbackLine, fallbackColumn) { + // Parse the `location` for the function name, source url, line, column etc. + + let line, column, url; + + // These two indices are used to extract the resource substring, which is + // location[parenIndex + 1 .. lineAndColumnIndex]. + // + // There are 3 variants of location strings in the profiler (with optional + // column numbers): + // 1) "name (resource:line)" + // 2) "resource:line" + // 3) "resource" + // + // For example for (1), take "foo (bar.js:1)". + // ^ ^ + // | | + // | | + // | | + // parenIndex will point to ------+ | + // | + // lineAndColumnIndex will point to -----+ + // + // For an example without parentheses, take "bar.js:2". + // ^ ^ + // | | + // parenIndex will point to ----------------+ | + // | + // lineAndColumIndex will point to ----------------+ + // + // To parse, we look for the last occurrence of the string ' ('. + // + // For 1), all occurrences of space ' ' characters in the resource string + // are urlencoded, so the last occurrence of ' (' is the separator between + // the function name and the resource. + // + // For 2) and 3), there can be no occurences of ' (' since ' ' characters + // are urlencoded in the resource string. + // + // XXX: Note that 3) is ambiguous with Gecko Profiler marker locations like + // "EnterJIT". We can't distinguish the two, so we treat 3) like a function + // name. + let parenIndex = -1; + let lineAndColumnIndex = -1; + + const lastCharCode = location.charCodeAt(location.length - 1); + let i; + if (lastCharCode === CHAR_CODE_RPAREN) { + // Case 1) + i = location.length - 2; + } else if (isNumeric(lastCharCode)) { + // Case 2) + i = location.length - 1; + } else { + // Case 3) + i = 0; + } + + if (i !== 0) { + // Look for a :number. + let end = i; + while (isNumeric(location.charCodeAt(i))) { + i--; + } + if (location.charCodeAt(i) === CHAR_CODE_COLON) { + column = location.substr(i + 1, end - i); + i--; + } + + // Look for a preceding :number. + end = i; + while (isNumeric(location.charCodeAt(i))) { + i--; + } + + // If two were found, the first is the line and the second is the + // column. If only a single :number was found, then it is the line number. + if (location.charCodeAt(i) === CHAR_CODE_COLON) { + line = location.substr(i + 1, end - i); + lineAndColumnIndex = i; + i--; + } else { + lineAndColumnIndex = i + 1; + line = column; + column = undefined; + } + } + + // Look for the last occurrence of ' (' in case 1). + if (lastCharCode === CHAR_CODE_RPAREN) { + for (; i >= 0; i--) { + if ( + location.charCodeAt(i) === CHAR_CODE_LPAREN && + i > 0 && + location.charCodeAt(i - 1) === CHAR_CODE_SPACE + ) { + parenIndex = i; + break; + } + } + } + + let parsedUrl; + if (lineAndColumnIndex > 0) { + const resource = location.substring(parenIndex + 1, lineAndColumnIndex); + url = resource.split(" -> ").pop(); + if (url) { + parsedUrl = parseURL(url); + } + } + + let functionName, fileName, port, host; + line = line || fallbackLine; + column = column || fallbackColumn; + + // If the URL digged out from the `location` is valid, this is a JS frame. + if (parsedUrl) { + functionName = location.substring(0, parenIndex - 1); + fileName = parsedUrl.fileName; + port = parsedUrl.port; + host = parsedUrl.host; + + // Check for the case of the filename containing eval + // e.g. "file.js%20line%2065%20%3E%20eval" + const evalIndex = fileName.indexOf(EVAL_TOKEN); + if (evalIndex !== -1 && evalIndex === fileName.length - EVAL_TOKEN.length) { + // Match the filename + const evalLine = line; + const [, _fileName, , _line] = + fileName.match(/(.+)(%20line%20(\d+)%20%3E%20eval)/) || []; + fileName = `${_fileName} (eval:${evalLine})`; + line = _line; + assert( + _fileName !== undefined, + "Filename could not be found from an eval location site" + ); + assert( + _line !== undefined, + "Line could not be found from an eval location site" + ); + + // Match the url as well + [, url] = url.match(/(.+)( line (\d+) > eval)/) || []; + assert( + url !== undefined, + "The URL could not be parsed correctly from an eval location site" + ); + } + } else { + functionName = location; + url = null; + } + + return { functionName, fileName, host, port, url, line, column }; +} + +/** + * Sets the properties of `isContent` and `category` on a frame. + * + * @param {InflatedFrame} frame + */ +function computeIsContentAndCategory(frame) { + const location = frame.location; + + // There are 3 variants of location strings in the profiler (with optional + // column numbers): + // 1) "name (resource:line)" + // 2) "resource:line" + // 3) "resource" + const lastCharCode = location.charCodeAt(location.length - 1); + let schemeStartIndex = -1; + if (lastCharCode === CHAR_CODE_RPAREN) { + // Case 1) + // + // Need to search for the last occurrence of ' (' to find the start of the + // resource string. + for (let i = location.length - 2; i >= 0; i--) { + if ( + location.charCodeAt(i) === CHAR_CODE_LPAREN && + i > 0 && + location.charCodeAt(i - 1) === CHAR_CODE_SPACE + ) { + schemeStartIndex = i + 1; + break; + } + } + } else { + // Cases 2) and 3) + schemeStartIndex = 0; + } + + // We can't know if WASM frames are content or not at the time of this writing, so label + // them all as content. + if (isContentScheme(location, schemeStartIndex) || isWASM(location)) { + frame.isContent = true; + return; + } + + if (frame.category !== null && frame.category !== undefined) { + return; + } + + if (schemeStartIndex !== 0) { + for (let j = schemeStartIndex; j < location.length; j++) { + if ( + location.charCodeAt(j) === CHAR_CODE_R && + isChromeScheme(location, j) && + (location.includes("resource://devtools") || + location.includes("resource://devtools")) + ) { + frame.category = CATEGORY_INDEX("tools"); + return; + } + } + } + + if (location === "EnterJIT") { + frame.category = CATEGORY_INDEX("js"); + return; + } + + frame.category = CATEGORY_INDEX("other"); +} + +/** + * Get caches to cache inflated frames and computed frame keys of a frame + * table. + * + * @param object framesTable + * @return object + */ +function getInflatedFrameCache(frameTable) { + let inflatedCache = gInflatedFrameStore.get(frameTable); + if (inflatedCache !== undefined) { + return inflatedCache; + } + + // Fill with nulls to ensure no holes. + inflatedCache = Array.from({ length: frameTable.data.length }, () => null); + gInflatedFrameStore.set(frameTable, inflatedCache); + return inflatedCache; +} + +/** + * Get or add an inflated frame to a cache. + * + * @param object cache + * @param number index + * @param object frameTable + * @param object stringTable + */ +function getOrAddInflatedFrame(cache, index, frameTable, stringTable) { + let inflatedFrame = cache[index]; + if (inflatedFrame === null) { + inflatedFrame = cache[index] = new InflatedFrame( + index, + frameTable, + stringTable + ); + } + return inflatedFrame; +} + +/** + * An intermediate data structured used to hold inflated frames. + * + * @param number index + * @param object frameTable + * @param object stringTable + */ +function InflatedFrame(index, frameTable, stringTable) { + const LOCATION_SLOT = frameTable.schema.location; + const IMPLEMENTATION_SLOT = frameTable.schema.implementation; + const OPTIMIZATIONS_SLOT = frameTable.schema.optimizations; + const LINE_SLOT = frameTable.schema.line; + const CATEGORY_SLOT = frameTable.schema.category; + + const frame = frameTable.data[index]; + const category = frame[CATEGORY_SLOT]; + this.location = stringTable[frame[LOCATION_SLOT]]; + this.implementation = frame[IMPLEMENTATION_SLOT]; + this.optimizations = frame[OPTIMIZATIONS_SLOT]; + this.line = frame[LINE_SLOT]; + this.column = undefined; + this.category = category; + this.isContent = false; + + // Attempt to compute if this frame is a content frame, and if not, + // its category. + // + // Since only C++ stack frames have associated category information, + // attempt to generate a useful category, fallback to the one provided + // by the profiling data, or fallback to an unknown category. + computeIsContentAndCategory(this); +} + +/** + * Gets the frame key (i.e., equivalence group) according to options. Content + * frames are always identified by location. Chrome frames are identified by + * location if content-only filtering is off. If content-filtering is on, they + * are identified by their category. + * + * @param object options + * @return string + */ +InflatedFrame.prototype.getFrameKey = function getFrameKey(options) { + if (this.isContent || !options.contentOnly || options.isRoot) { + options.isMetaCategoryOut = false; + return this.location; + } + + if (options.isLeaf) { + // We only care about leaf platform frames if we are displaying content + // only. If no category is present, give the default category of "other". + // + // 1. The leaf is where time is _actually_ being spent, so we _need_ to + // show it to developers in some way to give them accurate profiling + // data. We decide to split the platform into various category buckets + // and just show time spent in each bucket. + // + // 2. The calls leading to the leaf _aren't_ where we are spending time, + // but _do_ give the developer context for how they got to the leaf + // where they _are_ spending time. For non-platform hackers, the + // non-leaf platform frames don't give any meaningful context, and so we + // can safely filter them out. + options.isMetaCategoryOut = true; + return this.category; + } + + // Return an empty string denoting that this frame should be skipped. + return ""; +}; + +function isNumeric(c) { + return c >= CHAR_CODE_0 && c <= CHAR_CODE_9; +} + +function shouldDemangle(name) { + return ( + name?.charCodeAt && + name.charCodeAt(0) === CHAR_CODE_UNDERSCORE && + name.charCodeAt(1) === CHAR_CODE_UNDERSCORE && + name.charCodeAt(2) === CHAR_CODE_CAP_Z + ); +} + +/** + * Calculates the relative costs of this frame compared to a root, + * and generates allocations information if specified. Uses caching + * if possible. + * + * @param {ThreadNode|FrameNode} node + * The node we are calculating. + * @param {ThreadNode} options.root + * The root thread node to calculate relative costs. + * Generates [self|total] [duration|percentage] values. + * @param {boolean} options.allocations + * Generates `totalAllocations` and `selfAllocations`. + * + * @return {object} + */ +function getFrameInfo(node, options) { + let data = gFrameData.get(node); + + if (!data) { + if (node.nodeType === "Thread") { + data = Object.create(null); + data.functionName = global.L10N.getStr("table.root"); + } else { + data = parseLocation(node.location, node.line, node.column); + data.hasOptimizations = node.hasOptimizations(); + data.isContent = node.isContent; + data.isMetaCategory = node.isMetaCategory; + } + data.samples = node.youngestFrameSamples; + const hasCategory = node.category !== null && node.category !== undefined; + data.categoryData = hasCategory + ? CATEGORIES[node.category] || CATEGORIES[CATEGORY_INDEX("other")] + : {}; + data.nodeType = node.nodeType; + + // Frame name (function location or some meta information) + if (data.isMetaCategory) { + data.name = data.categoryData.label; + } else if (shouldDemangle(data.functionName)) { + data.name = demangle(data.functionName); + } else { + data.name = data.functionName; + } + + data.tooltiptext = data.isMetaCategory + ? data.categoryData.label + : node.location || ""; + + gFrameData.set(node, data); + } + + // If no options specified, we can't calculate relative values, abort here + if (!options) { + return data; + } + + // If a root specified, calculate the relative costs in the context of + // this call tree. The cached store may already have this, but generate + // if it does not. + const totalSamples = options.root.samples; + const totalDuration = options.root.duration; + if (options?.root && !data.COSTS_CALCULATED) { + data.selfDuration = + (node.youngestFrameSamples / totalSamples) * totalDuration; + data.selfPercentage = (node.youngestFrameSamples / totalSamples) * 100; + data.totalDuration = (node.samples / totalSamples) * totalDuration; + data.totalPercentage = (node.samples / totalSamples) * 100; + data.COSTS_CALCULATED = true; + } + + if (options?.allocations && !data.ALLOCATION_DATA_CALCULATED) { + const totalBytes = options.root.byteSize; + data.selfCount = node.youngestFrameSamples; + data.totalCount = node.samples; + data.selfCountPercentage = (node.youngestFrameSamples / totalSamples) * 100; + data.totalCountPercentage = (node.samples / totalSamples) * 100; + data.selfSize = node.youngestFrameByteSize; + data.totalSize = node.byteSize; + data.selfSizePercentage = (node.youngestFrameByteSize / totalBytes) * 100; + data.totalSizePercentage = (node.byteSize / totalBytes) * 100; + data.ALLOCATION_DATA_CALCULATED = true; + } + + return data; +} + +exports.getFrameInfo = getFrameInfo; + +/** + * Takes an inverted ThreadNode and searches its youngest frames for + * a FrameNode with matching location. + * + * @param {ThreadNode} threadNode + * @param {string} location + * @return {?FrameNode} + */ +function findFrameByLocation(threadNode, location) { + if (!threadNode.inverted) { + throw new Error( + "FrameUtils.findFrameByLocation only supports leaf nodes in an inverted tree." + ); + } + + const calls = threadNode.calls; + for (let i = 0; i < calls.length; i++) { + if (calls[i].location === location) { + return calls[i]; + } + } + return null; +} + +exports.findFrameByLocation = findFrameByLocation; +exports.computeIsContentAndCategory = computeIsContentAndCategory; +exports.parseLocation = parseLocation; +exports.getInflatedFrameCache = getInflatedFrameCache; +exports.getOrAddInflatedFrame = getOrAddInflatedFrame; +exports.InflatedFrame = InflatedFrame; +exports.shouldDemangle = shouldDemangle; diff --git a/devtools/client/performance/modules/logic/jit.js b/devtools/client/performance/modules/logic/jit.js new file mode 100644 index 0000000000..5063dd4a8b --- /dev/null +++ b/devtools/client/performance/modules/logic/jit.js @@ -0,0 +1,350 @@ +/* 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"; + +// An outcome of an OptimizationAttempt that is considered successful. +const SUCCESSFUL_OUTCOMES = [ + "GenericSuccess", + "Inlined", + "DOM", + "Monomorphic", + "Polymorphic", +]; + +/** + * Model representing JIT optimization sites from the profiler + * for a frame (represented by a FrameNode). Requires optimization data from + * a profile, which is an array of RawOptimizationSites. + * + * When the ThreadNode for the profile iterates over the samples' frames, each + * frame's optimizations are accumulated in their respective FrameNodes. Each + * FrameNode may contain many different optimization sites. One sample may + * pick up optimization X on line Y in the frame, with the next sample + * containing optimization Z on line W in the same frame, as each frame is + * only function. + * + * An OptimizationSite contains a record of how many times the + * RawOptimizationSite was sampled, as well as the unique id based off of the + * original profiler array, and the RawOptimizationSite itself as a reference. + * @see devtools/client/performance/modules/logic/tree-model.js + * + * @struct RawOptimizationSite + * A structure describing a location in a script that was attempted to be optimized. + * Contains all the IonTypes observed, and the sequence of OptimizationAttempts that + * were attempted, and the line and column in the script. This is retrieved from the + * profiler after a recording, and our base data structure. Should always be referenced, + * and unmodified. + * + * Note that propertyName is an index into a string table, which needs to be + * provided in order for the raw optimization site to be inflated. + * + * @type {Array<IonType>} types + * @type {Array<OptimizationAttempt>} attempts + * @type {?number} propertyName + * @type {number} line + * @type {number} column + * + * + * @struct IonType + * IonMonkey attempts to classify each value in an optimization site by some type. + * Based off of the observed types for a value (like a variable that could be a + * string or an instance of an object), it determines what kind of type it should be + * classified as. Each IonType here contains an array of all ObservedTypes under `types`, + * the Ion type that IonMonkey decided this value should be (Int32, Object, etc.) as + * `mirType`, and the component of this optimization type that this value refers to -- + * like a "getter" optimization, `a[b]`, has site `a` (the "Receiver") and `b` + * (the "Index"). + * + * Generally the more ObservedTypes, the more deoptimized this OptimizationSite is. + * There could be no ObservedTypes, in which case `typeset` is undefined. + * + * @type {?Array<ObservedType>} typeset + * @type {string} site + * @type {string} mirType + * + * + * @struct ObservedType + * When IonMonkey attempts to determine what type a value is, it checks on each sample. + * The ObservedType can be thought of in more of JavaScripty-terms, rather than C++. + * The `keyedBy` property is a high level description of the type, like "primitive", + * "constructor", "function", "singleton", "alloc-site" (that one is a bit more weird). + * If the `keyedBy` type is a function or constructor, the ObservedType should have a + * `name` property, referring to the function or constructor name from the JS source. + * If IonMonkey can determine the origin of this type (like where the constructor is + * defined), the ObservedType will also have `location` and `line` properties, but + * `location` can sometimes be non-URL strings like "self-hosted" or a memory location + * like "102ca7880", or no location at all, and maybe `line` is 0 or undefined. + * + * @type {string} keyedBy + * @type {?string} name + * @type {?string} location + * @type {?string} line + * + * + * @struct OptimizationAttempt + * Each RawOptimizationSite contains an array of OptimizationAttempts. Generally, + * IonMonkey goes through a series of strategies for each kind of optimization, starting + * from most-niche and optimized, to the less-optimized, but more general strategies -- + * for example, a getter opt may first try to optimize for the scenario of a getter on an + * `arguments` object -- that will fail most of the time, as most objects are not + * arguments objects, but it will attempt several strategies in order until it finds a + * strategy that works, or fails. Even in the best scenarios, some attempts will fail + * (like the arguments getter example), which is OK, as long as some attempt succeeds + * (with the earlier attempts preferred, as those are more optimized). In an + * OptimizationAttempt structure, we store just the `strategy` name and `outcome` name, + * both from enums in js/public/TrackedOptimizationInfo.h as TRACKED_STRATEGY_LIST and + * TRACKED_OUTCOME_LIST, respectively. An array of successful outcome strings are above + * in SUCCESSFUL_OUTCOMES. + * + * @see js/public/TrackedOptimizationInfo.h + * + * @type {string} strategy + * @type {string} outcome + */ + +/* + * A wrapper around RawOptimizationSite to record sample count and ID (referring to the + * index of where this is in the initially seeded optimizations data), so we don't mutate + * the original data from the profiler. Provides methods to access the underlying + * optimization data easily, so understanding the semantics of JIT data isn't necessary. + * + * @constructor + * + * @param {Array<RawOptimizationSite>} optimizations + * @param {number} optsIndex + * + * @type {RawOptimizationSite} data + * @type {number} samples + * @type {number} id + */ + +const OptimizationSite = function(id, opts) { + this.id = id; + this.data = opts; + this.samples = 1; +}; + +/** + * Constructor for JITOptimizations. A collection of OptimizationSites for a frame. + * + * @constructor + * @param {Array<RawOptimizationSite>} rawSites + * Array of raw optimization sites. + * @param {Array<string>} stringTable + * Array of strings from the profiler used to inflate + * JIT optimizations. Do not modify this! + */ + +const JITOptimizations = function(rawSites, stringTable) { + // Build a histogram of optimization sites. + const sites = []; + + for (const rawSite of rawSites) { + const existingSite = sites.find(site => site.data === rawSite); + if (existingSite) { + existingSite.samples++; + } else { + sites.push(new OptimizationSite(sites.length, rawSite)); + } + } + + // Inflate the optimization information. + for (const site of sites) { + const data = site.data; + const STRATEGY_SLOT = data.attempts.schema.strategy; + const OUTCOME_SLOT = data.attempts.schema.outcome; + const attempts = data.attempts.data.map(a => { + return { + id: site.id, + strategy: stringTable[a[STRATEGY_SLOT]], + outcome: stringTable[a[OUTCOME_SLOT]], + }; + }); + const types = data.types.map(t => { + const typeset = maybeTypeset(t.typeset, stringTable); + if (typeset) { + typeset.forEach(ts => { + ts.id = site.id; + }); + } + + return { + id: site.id, + typeset, + site: stringTable[t.site], + mirType: stringTable[t.mirType], + }; + }); + // Add IDs to to all children objects, so we can correllate sites when + // just looking at a specific type, attempt, etc.. + attempts.id = types.id = site.id; + + site.data = { + attempts, + types, + propertyName: maybeString(stringTable, data.propertyName), + line: data.line, + column: data.column, + }; + } + + this.optimizationSites = sites.sort((a, b) => b.samples - a.samples); +}; + +/** + * Make JITOptimizations iterable. + */ +JITOptimizations.prototype = { + [Symbol.iterator]: function*() { + yield* this.optimizationSites; + }, + + get length() { + return this.optimizationSites.length; + }, +}; + +/** + * Takes an "outcome" string from an OptimizationAttempt and returns + * a boolean indicating whether or not its a successful outcome. + * + * @return {boolean} + */ + +function isSuccessfulOutcome(outcome) { + return !!~SUCCESSFUL_OUTCOMES.indexOf(outcome); +} + +/** + * Takes an OptimizationSite. Returns a boolean indicating if the passed + * in OptimizationSite has a "good" outcome at the end of its attempted strategies. + * + * @param {OptimizationSite} optimizationSite + * @return {boolean} + */ + +function hasSuccessfulOutcome(optimizationSite) { + const attempts = optimizationSite.data.attempts; + const lastOutcome = attempts[attempts.length - 1].outcome; + return isSuccessfulOutcome(lastOutcome); +} + +function maybeString(stringTable, index) { + return index ? stringTable[index] : undefined; +} + +function maybeTypeset(typeset, stringTable) { + if (!typeset) { + return undefined; + } + return typeset.map(ty => { + return { + keyedBy: maybeString(stringTable, ty.keyedBy), + name: maybeString(stringTable, ty.name), + location: maybeString(stringTable, ty.location), + line: ty.line, + }; + }); +} + +// Map of optimization implementation names to an enum. +const IMPLEMENTATION_MAP = { + interpreter: 0, + baseline: 1, + ion: 2, +}; +const IMPLEMENTATION_NAMES = Object.keys(IMPLEMENTATION_MAP); + +/** + * Takes data from a FrameNode and computes rendering positions for + * a stacked mountain graph, to visualize JIT optimization tiers over time. + * + * @param {FrameNode} frameNode + * The FrameNode who's optimizations we're iterating. + * @param {Array<number>} sampleTimes + * An array of every sample time within the range we're counting. + * From a ThreadNode's `sampleTimes` property. + * @param {number} bucketSize + * Size of each bucket in milliseconds. + * `duration / resolution = bucketSize` in OptimizationsGraph. + * @return {?Array<object>} + */ +function createTierGraphDataFromFrameNode(frameNode, sampleTimes, bucketSize) { + const tierData = frameNode.getTierData(); + const stringTable = frameNode._stringTable; + const output = []; + let implEnum; + + let tierDataIndex = 0; + let nextOptSample = tierData[tierDataIndex]; + + // Bucket data + let samplesInCurrentBucket = 0; + let currentBucketStartTime = sampleTimes[0]; + let bucket = []; + + // Store previous data point so we can have straight vertical lines + let previousValues; + + // Iterate one after the samples, so we can finalize the last bucket + for (let i = 0; i <= sampleTimes.length; i++) { + const sampleTime = sampleTimes[i]; + + // If this sample is in the next bucket, or we're done + // checking sampleTimes and on the last iteration, finalize previous bucket + if ( + sampleTime >= currentBucketStartTime + bucketSize || + i >= sampleTimes.length + ) { + const dataPoint = {}; + dataPoint.values = []; + dataPoint.delta = currentBucketStartTime; + + // Map the opt site counts as a normalized percentage (0-1) + // of its count in context of total samples this bucket + for (let j = 0; j < IMPLEMENTATION_NAMES.length; j++) { + dataPoint.values[j] = (bucket[j] || 0) / (samplesInCurrentBucket || 1); + } + + // Push the values from the previous bucket to the same time + // as the current bucket so we get a straight vertical line. + if (previousValues) { + const data = Object.create(null); + data.values = previousValues; + data.delta = currentBucketStartTime; + output.push(data); + } + + output.push(dataPoint); + + // Set the new start time of this bucket and reset its count + currentBucketStartTime += bucketSize; + samplesInCurrentBucket = 0; + previousValues = dataPoint.values; + bucket = []; + } + + // If this sample observed an optimization in this frame, record it + if (nextOptSample && nextOptSample.time === sampleTime) { + // If no implementation defined, it was the "interpreter". + implEnum = + IMPLEMENTATION_MAP[ + stringTable[nextOptSample.implementation] || "interpreter" + ]; + bucket[implEnum] = (bucket[implEnum] || 0) + 1; + nextOptSample = tierData[++tierDataIndex]; + } + + samplesInCurrentBucket++; + } + + return output; +} + +exports.createTierGraphDataFromFrameNode = createTierGraphDataFromFrameNode; +exports.OptimizationSite = OptimizationSite; +exports.JITOptimizations = JITOptimizations; +exports.hasSuccessfulOutcome = hasSuccessfulOutcome; +exports.isSuccessfulOutcome = isSuccessfulOutcome; +exports.SUCCESSFUL_OUTCOMES = SUCCESSFUL_OUTCOMES; diff --git a/devtools/client/performance/modules/logic/moz.build b/devtools/client/performance/modules/logic/moz.build new file mode 100644 index 0000000000..01f77231d7 --- /dev/null +++ b/devtools/client/performance/modules/logic/moz.build @@ -0,0 +1,12 @@ +# 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/. + +DevToolsModules( + "frame-utils.js", + "jit.js", + "telemetry.js", + "tree-model.js", + "waterfall-utils.js", +) diff --git a/devtools/client/performance/modules/logic/telemetry.js b/devtools/client/performance/modules/logic/telemetry.js new file mode 100644 index 0000000000..4ea267d747 --- /dev/null +++ b/devtools/client/performance/modules/logic/telemetry.js @@ -0,0 +1,106 @@ +/* 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 Telemetry = require("devtools/client/shared/telemetry"); +const EVENTS = require("devtools/client/performance/events"); + +const EVENT_MAP_FLAGS = new Map([ + [EVENTS.RECORDING_IMPORTED, "DEVTOOLS_PERFTOOLS_RECORDING_IMPORT_FLAG"], + [EVENTS.RECORDING_EXPORTED, "DEVTOOLS_PERFTOOLS_RECORDING_EXPORT_FLAG"], +]); + +const RECORDING_FEATURES = [ + "withMarkers", + "withTicks", + "withMemory", + "withAllocations", +]; + +const SELECTED_VIEW_HISTOGRAM_NAME = "DEVTOOLS_PERFTOOLS_SELECTED_VIEW_MS"; + +function PerformanceTelemetry(emitter) { + this._emitter = emitter; + this._telemetry = new Telemetry(); + this.onFlagEvent = this.onFlagEvent.bind(this); + this.onRecordingStateChange = this.onRecordingStateChange.bind(this); + this.onViewSelected = this.onViewSelected.bind(this); + + for (const [event] of EVENT_MAP_FLAGS) { + this._emitter.on(event, this.onFlagEvent.bind(this, event)); + } + + this._emitter.on(EVENTS.RECORDING_STATE_CHANGE, this.onRecordingStateChange); + this._emitter.on(EVENTS.UI_DETAILS_VIEW_SELECTED, this.onViewSelected); +} + +PerformanceTelemetry.prototype.destroy = function() { + if (this._previousView) { + this._telemetry.finishKeyed( + SELECTED_VIEW_HISTOGRAM_NAME, + this._previousView, + this, + false + ); + } + + for (const [event] of EVENT_MAP_FLAGS) { + this._emitter.off(event, this.onFlagEvent); + } + this._emitter.off(EVENTS.RECORDING_STATE_CHANGE, this.onRecordingStateChange); + this._emitter.off(EVENTS.UI_DETAILS_VIEW_SELECTED, this.onViewSelected); + this._emitter = null; +}; + +PerformanceTelemetry.prototype.onFlagEvent = function(eventName, ...data) { + this._telemetry.getHistogramById(EVENT_MAP_FLAGS.get(eventName)).add(true); +}; + +PerformanceTelemetry.prototype.onRecordingStateChange = function( + status, + model +) { + if (status != "recording-stopped") { + return; + } + + if (model.isConsole()) { + this._telemetry + .getHistogramById("DEVTOOLS_PERFTOOLS_CONSOLE_RECORDING_COUNT") + .add(true); + } else { + this._telemetry + .getHistogramById("DEVTOOLS_PERFTOOLS_RECORDING_COUNT") + .add(true); + } + + this._telemetry + .getHistogramById("DEVTOOLS_PERFTOOLS_RECORDING_DURATION_MS") + .add(model.getDuration()); + + const config = model.getConfiguration(); + for (const k in config) { + if (RECORDING_FEATURES.includes(k)) { + this._telemetry + .getKeyedHistogramById("DEVTOOLS_PERFTOOLS_RECORDING_FEATURES_USED") + .add(k, config[k]); + } + } +}; + +PerformanceTelemetry.prototype.onViewSelected = function(viewName) { + if (this._previousView) { + this._telemetry.finishKeyed( + SELECTED_VIEW_HISTOGRAM_NAME, + this._previousView, + this, + false + ); + } + this._previousView = viewName; + this._telemetry.startKeyed(SELECTED_VIEW_HISTOGRAM_NAME, viewName, this); +}; + +exports.PerformanceTelemetry = PerformanceTelemetry; diff --git a/devtools/client/performance/modules/logic/tree-model.js b/devtools/client/performance/modules/logic/tree-model.js new file mode 100644 index 0000000000..518b4838a2 --- /dev/null +++ b/devtools/client/performance/modules/logic/tree-model.js @@ -0,0 +1,589 @@ +/* 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 { + JITOptimizations, +} = require("devtools/client/performance/modules/logic/jit"); +const FrameUtils = require("devtools/client/performance/modules/logic/frame-utils"); + +/** + * A call tree for a thread. This is essentially a linkage between all frames + * of all samples into a single tree structure, with additional information + * on each node, like the time spent (in milliseconds) and samples count. + * + * @param object thread + * The raw thread object received from the backend. Contains samples, + * stackTable, frameTable, and stringTable. + * @param object options + * Additional supported options + * - number startTime + * - number endTime + * - boolean contentOnly [optional] + * - boolean invertTree [optional] + * - boolean flattenRecursion [optional] + */ +function ThreadNode(thread, options = {}) { + if (options.endTime == void 0 || options.startTime == void 0) { + throw new Error("ThreadNode requires both `startTime` and `endTime`."); + } + this.samples = 0; + this.sampleTimes = []; + this.youngestFrameSamples = 0; + this.calls = []; + this.duration = options.endTime - options.startTime; + this.nodeType = "Thread"; + this.inverted = options.invertTree; + + // Total bytesize of all allocations if enabled + this.byteSize = 0; + this.youngestFrameByteSize = 0; + + const { samples, stackTable, frameTable, stringTable } = thread; + + // Nothing to do if there are no samples. + if (samples.data.length === 0) { + return; + } + + this._buildInverted(samples, stackTable, frameTable, stringTable, options); + if (!options.invertTree) { + this._uninvert(); + } +} + +ThreadNode.prototype = { + /** + * Build an inverted call tree from profile samples. The format of the + * samples is described in tools/profiler/ProfileEntry.h, under the heading + * "Thread profile JSON Format". + * + * The profile data is naturally presented inverted. Inverting the call tree + * is also the default in the Performance tool. + * + * @param object samples + * The raw samples array received from the backend. + * @param object stackTable + * The table of deduplicated stacks from the backend. + * @param object frameTable + * The table of deduplicated frames from the backend. + * @param object stringTable + * The table of deduplicated strings from the backend. + * @param object options + * Additional supported options + * - number startTime + * - number endTime + * - boolean contentOnly [optional] + * - boolean invertTree [optional] + */ + _buildInverted: function buildInverted( + samples, + stackTable, + frameTable, + stringTable, + options + ) { + function getOrAddFrameNode( + calls, + isLeaf, + frameKey, + inflatedFrame, + isMetaCategory, + leafTable + ) { + // Insert the inflated frame into the call tree at the current level. + let frameNode; + + // Leaf nodes have fan out much greater than non-leaf nodes, thus the + // use of a hash table. Otherwise, do linear search. + // + // Note that this method is very hot, thus the manual looping over + // Array.prototype.find. + if (isLeaf) { + frameNode = leafTable[frameKey]; + } else { + for (let i = 0; i < calls.length; i++) { + if (calls[i].key === frameKey) { + frameNode = calls[i]; + break; + } + } + } + + if (!frameNode) { + frameNode = new FrameNode(frameKey, inflatedFrame, isMetaCategory); + if (isLeaf) { + leafTable[frameKey] = frameNode; + } + calls.push(frameNode); + } + + return frameNode; + } + + const SAMPLE_STACK_SLOT = samples.schema.stack; + const SAMPLE_TIME_SLOT = samples.schema.time; + const SAMPLE_BYTESIZE_SLOT = samples.schema.size; + + const STACK_PREFIX_SLOT = stackTable.schema.prefix; + const STACK_FRAME_SLOT = stackTable.schema.frame; + + const getOrAddInflatedFrame = FrameUtils.getOrAddInflatedFrame; + + const samplesData = samples.data; + const stacksData = stackTable.data; + + // Caches. + const inflatedFrameCache = FrameUtils.getInflatedFrameCache(frameTable); + const leafTable = Object.create(null); + + const startTime = options.startTime; + const endTime = options.endTime; + const flattenRecursion = options.flattenRecursion; + + // Reused options object passed to InflatedFrame.prototype.getFrameKey. + const mutableFrameKeyOptions = { + contentOnly: options.contentOnly, + isRoot: false, + isLeaf: false, + isMetaCategoryOut: false, + }; + + let byteSize = 0; + for (let i = 0; i < samplesData.length; i++) { + const sample = samplesData[i]; + const sampleTime = sample[SAMPLE_TIME_SLOT]; + + if (SAMPLE_BYTESIZE_SLOT !== void 0) { + byteSize = sample[SAMPLE_BYTESIZE_SLOT]; + } + + // A sample's end time is considered to be its time of sampling. Its + // start time is the sampling time of the previous sample. + // + // Thus, we compare sampleTime <= start instead of < to filter out + // samples that end exactly at the start time. + if (!sampleTime || sampleTime <= startTime || sampleTime > endTime) { + continue; + } + + let stackIndex = sample[SAMPLE_STACK_SLOT]; + let calls = this.calls; + let prevCalls = this.calls; + let prevFrameKey; + let isLeaf = (mutableFrameKeyOptions.isLeaf = true); + const skipRoot = options.invertTree; + + // Inflate the stack and build the FrameNode call tree directly. + // + // In the profiler data, each frame's stack is referenced by an index + // into stackTable. + // + // Each entry in stackTable is a pair [ prefixIndex, frameIndex ]. The + // prefixIndex is itself an index into stackTable, referencing the + // prefix of the current stack (that is, the younger frames). In other + // words, the stackTable is encoded as a trie of the inverted + // callstack. The frameIndex is an index into frameTable, describing the + // frame at the current depth. + // + // This algorithm inflates each frame in the frame table while walking + // the stack trie as described above. + // + // The frame key is then computed from the inflated frame /and/ the + // current depth in the FrameNode call tree. That is, the frame key is + // not wholly determinable from just the inflated frame. + // + // For content frames, the frame key is just its location. For chrome + // frames, the key may be a metacategory or its location, depending on + // rendering options and its position in the FrameNode call tree. + // + // The frame key is then used to build up the inverted FrameNode call + // tree. + // + // Note that various filtering functions, such as filtering for content + // frames or flattening recursion, are inlined into the stack inflation + // loop. This is important for performance as it avoids intermediate + // structures and multiple passes. + while (stackIndex !== null) { + const stackEntry = stacksData[stackIndex]; + const frameIndex = stackEntry[STACK_FRAME_SLOT]; + + // Fetch the stack prefix (i.e. older frames) index. + stackIndex = stackEntry[STACK_PREFIX_SLOT]; + + // Do not include the (root) node in this sample, as the costs of each frame + // will make it clear to differentiate (root)->B vs (root)->A->B + // when a tree is inverted, a revert of bug 1147604 + if (stackIndex === null && skipRoot) { + break; + } + + // Inflate the frame. + const inflatedFrame = getOrAddInflatedFrame( + inflatedFrameCache, + frameIndex, + frameTable, + stringTable + ); + + // Compute the frame key. + mutableFrameKeyOptions.isRoot = stackIndex === null; + const frameKey = inflatedFrame.getFrameKey(mutableFrameKeyOptions); + + // An empty frame key means this frame should be skipped. + if (frameKey === "") { + continue; + } + + // If we shouldn't flatten the current frame into the previous one, advance a + // level in the call tree. + const shouldFlatten = flattenRecursion && frameKey === prevFrameKey; + if (!shouldFlatten) { + calls = prevCalls; + } + + const frameNode = getOrAddFrameNode( + calls, + isLeaf, + frameKey, + inflatedFrame, + mutableFrameKeyOptions.isMetaCategoryOut, + leafTable + ); + if (isLeaf) { + frameNode.youngestFrameSamples++; + frameNode._addOptimizations( + inflatedFrame.optimizations, + inflatedFrame.implementation, + sampleTime, + stringTable + ); + + if (byteSize) { + frameNode.youngestFrameByteSize += byteSize; + } + } + + // Don't overcount flattened recursive frames. + if (!shouldFlatten) { + frameNode.samples++; + if (byteSize) { + frameNode.byteSize += byteSize; + } + } + + prevFrameKey = frameKey; + prevCalls = frameNode.calls; + isLeaf = mutableFrameKeyOptions.isLeaf = false; + } + + this.samples++; + this.sampleTimes.push(sampleTime); + if (byteSize) { + this.byteSize += byteSize; + } + } + }, + + /** + * Uninverts the call tree after its having been built. + */ + _uninvert: function uninvert() { + function mergeOrAddFrameNode(calls, node, samples, size) { + // Unlike the inverted call tree, we don't use a root table for the top + // level, as in general, there are many fewer entry points than + // leaves. Instead, linear search is used regardless of level. + for (let i = 0; i < calls.length; i++) { + if (calls[i].key === node.key) { + const foundNode = calls[i]; + foundNode._merge(node, samples, size); + return foundNode.calls; + } + } + const copy = node._clone(samples, size); + calls.push(copy); + return copy.calls; + } + + const workstack = [{ node: this, level: 0 }]; + const spine = []; + let entry; + + // The new root. + const rootCalls = []; + + // Walk depth-first and keep the current spine (e.g., callstack). + do { + entry = workstack.pop(); + if (entry) { + spine[entry.level] = entry; + + const node = entry.node; + const calls = node.calls; + let callSamples = 0; + let callByteSize = 0; + + // Continue the depth-first walk. + for (let i = 0; i < calls.length; i++) { + workstack.push({ node: calls[i], level: entry.level + 1 }); + callSamples += calls[i].samples; + callByteSize += calls[i].byteSize; + } + + // The sample delta is used to distinguish stacks. + // + // Suppose we have the following stack samples: + // + // A -> B + // A -> C + // A + // + // The inverted tree is: + // + // A + // / \ + // B C + // + // with A.samples = 3, B.samples = 1, C.samples = 1. + // + // A is distinguished as being its own stack because + // A.samples - (B.samples + C.samples) > 0. + // + // Note that bottoming out is a degenerate where callSamples = 0. + + const samplesDelta = node.samples - callSamples; + const byteSizeDelta = node.byteSize - callByteSize; + if (samplesDelta > 0) { + // Reverse the spine and add them to the uninverted call tree. + let uninvertedCalls = rootCalls; + for (let level = entry.level; level > 0; level--) { + const callee = spine[level]; + uninvertedCalls = mergeOrAddFrameNode( + uninvertedCalls, + callee.node, + samplesDelta, + byteSizeDelta + ); + } + } + } + } while (entry); + + // Replace the toplevel calls with rootCalls, which now contains the + // uninverted roots. + this.calls = rootCalls; + }, + + /** + * Gets additional details about this node. + * @see FrameNode.prototype.getInfo for more information. + * + * @return object + */ + getInfo: function(options) { + return FrameUtils.getFrameInfo(this, options); + }, + + /** + * Mimicks the interface of FrameNode, and a ThreadNode can never have + * optimization data (at the moment, anyway), so provide a function + * to return null so we don't need to check if a frame node is a thread + * or not everytime we fetch optimization data. + * + * @return {null} + */ + + hasOptimizations: function() { + return null; + }, +}; + +/** + * A function call node in a tree. Represents a function call with a unique context, + * resulting in each FrameNode having its own row in the corresponding tree view. + * Take samples: + * A()->B()->C() + * A()->B() + * Q()->B() + * + * In inverted tree, A()->B()->C() would have one frame node, and A()->B() and + * Q()->B() would share a frame node. + * In an uninverted tree, A()->B()->C() and A()->B() would share a frame node, + * with Q()->B() having its own. + * + * In all cases, all the frame nodes originated from the same InflatedFrame. + * + * @param string frameKey + * The key associated with this frame. The key determines identity of + * the node. + * @param string location + * The location of this function call. Note that this isn't sanitized, + * so it may very well (not?) include the function name, url, etc. + * @param number line + * The line number inside the source containing this function call. + * @param number category + * The category type of this function call ("js", "graphics" etc.). + * @param number allocations + * The number of memory allocations performed in this frame. + * @param number isContent + * Whether this frame is content. + * @param boolean isMetaCategory + * Whether or not this is a platform node that should appear as a + * generalized meta category or not. + */ +function FrameNode( + frameKey, + { location, line, category, isContent }, + isMetaCategory +) { + this.key = frameKey; + this.location = location; + this.line = line; + this.youngestFrameSamples = 0; + this.samples = 0; + this.calls = []; + this.isContent = !!isContent; + this._optimizations = null; + this._tierData = []; + this._stringTable = null; + this.isMetaCategory = !!isMetaCategory; + this.category = category; + this.nodeType = "Frame"; + this.byteSize = 0; + this.youngestFrameByteSize = 0; +} + +FrameNode.prototype = { + /** + * Take optimization data observed for this frame. + * + * @param object optimizationSite + * Any JIT optimization information attached to the current + * sample. Lazily inflated via stringTable. + * @param number implementation + * JIT implementation used for this observed frame (baseline, ion); + * can be null indicating "interpreter" + * @param number time + * The time this optimization occurred. + * @param object stringTable + * The string table used to inflate the optimizationSite. + */ + _addOptimizations: function(site, implementation, time, stringTable) { + // Simply accumulate optimization sites for now. Processing is done lazily + // by JITOptimizations, if optimization information is actually displayed. + if (site) { + let opts = this._optimizations; + if (opts === null) { + opts = this._optimizations = []; + } + opts.push(site); + } + + if (!this._stringTable) { + this._stringTable = stringTable; + } + + // Record type of implementation used and the sample time + this._tierData.push({ implementation, time }); + }, + + _clone: function(samples, size) { + const newNode = new FrameNode(this.key, this, this.isMetaCategory); + newNode._merge(this, samples, size); + return newNode; + }, + + _merge: function(otherNode, samples, size) { + if (this === otherNode) { + return; + } + + this.samples += samples; + this.byteSize += size; + if (otherNode.youngestFrameSamples > 0) { + this.youngestFrameSamples += samples; + } + + if (otherNode.youngestFrameByteSize > 0) { + this.youngestFrameByteSize += otherNode.youngestFrameByteSize; + } + + if (this._stringTable === null) { + this._stringTable = otherNode._stringTable; + } + + if (otherNode._optimizations) { + if (!this._optimizations) { + this._optimizations = []; + } + const opts = this._optimizations; + const otherOpts = otherNode._optimizations; + for (let i = 0; i < otherOpts.length; i++) { + opts.push(otherOpts[i]); + } + } + + if (otherNode._tierData.length) { + const tierData = this._tierData; + const otherTierData = otherNode._tierData; + for (let i = 0; i < otherTierData.length; i++) { + tierData.push(otherTierData[i]); + } + tierData.sort((a, b) => a.time - b.time); + } + }, + + /** + * Returns the parsed location and additional data describing + * this frame. Uses cached data if possible. Takes the following + * options: + * + * @param {ThreadNode} options.root + * The root thread node to calculate relative costs. + * Generates [self|total] [duration|percentage] values. + * @param {boolean} options.allocations + * Generates `totalAllocations` and `selfAllocations`. + * + * @return object + * The computed { name, file, url, line } properties for this + * function call, as well as additional params if options specified. + */ + getInfo: function(options) { + return FrameUtils.getFrameInfo(this, options); + }, + + /** + * Returns whether or not the frame node has an JITOptimizations model. + * + * @return {Boolean} + */ + hasOptimizations: function() { + return !this.isMetaCategory && !!this._optimizations; + }, + + /** + * Returns the underlying JITOptimizations model representing + * the optimization attempts occuring in this frame. + * + * @return {JITOptimizations|null} + */ + getOptimizations: function() { + if (!this._optimizations) { + return null; + } + return new JITOptimizations(this._optimizations, this._stringTable); + }, + + /** + * Returns the tiers used overtime. + * + * @return {Array<object>} + */ + getTierData: function() { + return this._tierData; + }, +}; + +exports.ThreadNode = ThreadNode; +exports.FrameNode = FrameNode; diff --git a/devtools/client/performance/modules/logic/waterfall-utils.js b/devtools/client/performance/modules/logic/waterfall-utils.js new file mode 100644 index 0000000000..5fc7e768e1 --- /dev/null +++ b/devtools/client/performance/modules/logic/waterfall-utils.js @@ -0,0 +1,171 @@ +/* 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"; + +/** + * Utility functions for collapsing markers into a waterfall. + */ + +const { + MarkerBlueprintUtils, +} = require("devtools/client/performance/modules/marker-blueprint-utils"); + +/** + * Creates a parent marker, which functions like a regular marker, + * but is able to hold additional child markers. + * + * The marker is seeded with values from `marker`. + * @param object marker + * @return object + */ +function createParentNode(marker) { + return Object.assign({}, marker, { submarkers: [] }); +} + +/** + * Collapses markers into a tree-like structure. + * @param object rootNode + * @param array markersList + * @param array filter + */ +function collapseMarkersIntoNode({ rootNode, markersList, filter }) { + const { + getCurrentParentNode, + pushNode, + popParentNode, + } = createParentNodeFactory(rootNode); + + for (let i = 0, len = markersList.length; i < len; i++) { + const curr = markersList[i]; + + // If this marker type should not be displayed, just skip + if (!MarkerBlueprintUtils.shouldDisplayMarker(curr, filter)) { + continue; + } + + let parentNode = getCurrentParentNode(); + const blueprint = MarkerBlueprintUtils.getBlueprintFor(curr); + + const nestable = "nestable" in blueprint ? blueprint.nestable : true; + const collapsible = + "collapsible" in blueprint ? blueprint.collapsible : true; + + let finalized = false; + + // Extend the marker with extra properties needed in the marker tree + const extendedProps = { index: i }; + if (collapsible) { + extendedProps.submarkers = []; + } + Object.assign(curr, extendedProps); + + // If not nestible, just push it inside the root node. Additionally, + // markers originating outside the main thread are considered to be + // "never collapsible", to avoid confusion. + // A beter solution would be to collapse every marker with its siblings + // from the same thread, but that would require a thread id attached + // to all markers, which is potentially expensive and rather useless at + // the moment, since we don't really have that many OTMT markers. + if (!nestable || curr.isOffMainThread) { + pushNode(rootNode, curr); + continue; + } + + // First off, if any parent nodes exist, finish them off + // recursively upwards if this marker is outside their ranges and nestable. + while (!finalized && parentNode) { + // If this marker is eclipsed by the current parent marker, + // make it a child of the current parent and stop going upwards. + // If the markers aren't from the same process, attach them to the root + // node as well. Every process has its own main thread. + if ( + nestable && + curr.start >= parentNode.start && + curr.end <= parentNode.end && + curr.processType == parentNode.processType + ) { + pushNode(parentNode, curr); + finalized = true; + break; + } + + // If this marker is still nestable, but outside of the range + // of the current parent, iterate upwards on the next parent + // and finalize the current parent. + if (nestable) { + popParentNode(); + parentNode = getCurrentParentNode(); + continue; + } + } + + if (!finalized) { + pushNode(rootNode, curr); + } + } +} + +/** + * Takes a root marker node and creates a hash of functions used + * to manage the creation and nesting of additional parent markers. + * + * @param {object} root + * @return {object} + */ +function createParentNodeFactory(root) { + const parentMarkers = []; + const factory = { + /** + * Pops the most recent parent node off the stack, finalizing it. + * Sets the `end` time based on the most recent child if not defined. + */ + popParentNode: () => { + if (parentMarkers.length === 0) { + throw new Error("Cannot pop parent markers when none exist."); + } + + const lastParent = parentMarkers.pop(); + + // If this finished parent marker doesn't have an end time, + // so probably a synthesized marker, use the last marker's end time. + if (lastParent.end == void 0) { + lastParent.end = + lastParent.submarkers[lastParent.submarkers.length - 1].end; + } + + // If no children were ever pushed into this parent node, + // remove its submarkers so it behaves like a non collapsible + // node. + if (!lastParent.submarkers.length) { + delete lastParent.submarkers; + } + + return lastParent; + }, + + /** + * Returns the most recent parent node. + */ + getCurrentParentNode: () => + parentMarkers.length ? parentMarkers[parentMarkers.length - 1] : null, + + /** + * Push this marker into the most recent parent node. + */ + pushNode: (parent, marker) => { + parent.submarkers.push(marker); + + // If pushing a parent marker, track it as the top of + // the parent stack. + if (marker.submarkers) { + parentMarkers.push(marker); + } + }, + }; + + return factory; +} + +exports.createParentNode = createParentNode; +exports.collapseMarkersIntoNode = collapseMarkersIntoNode; diff --git a/devtools/client/performance/modules/marker-blueprint-utils.js b/devtools/client/performance/modules/marker-blueprint-utils.js new file mode 100644 index 0000000000..f249c0cb7b --- /dev/null +++ b/devtools/client/performance/modules/marker-blueprint-utils.js @@ -0,0 +1,110 @@ +/* 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 { + TIMELINE_BLUEPRINT, +} = require("devtools/client/performance/modules/markers"); + +/** + * This file contains utilities for parsing out the markers blueprint + * to generate strings to be displayed in the UI. + */ + +exports.MarkerBlueprintUtils = { + /** + * Takes a marker and a list of marker names that should be hidden, and + * determines if this marker should be filtered or not. + * + * @param object marker + * @return boolean + */ + shouldDisplayMarker: function(marker, hiddenMarkerNames) { + if (!hiddenMarkerNames || hiddenMarkerNames.length == 0) { + return true; + } + + // If this marker isn't yet defined in the blueprint, simply check if the + // entire category of "UNKNOWN" markers are supposed to be visible or not. + const isUnknown = !(marker.name in TIMELINE_BLUEPRINT); + if (isUnknown) { + return !hiddenMarkerNames.includes("UNKNOWN"); + } + + return !hiddenMarkerNames.includes(marker.name); + }, + + /** + * Takes a marker and returns the blueprint definition for that marker type, + * falling back to the UNKNOWN blueprint definition if undefined. + * + * @param object marker + * @return object + */ + getBlueprintFor: function(marker) { + return TIMELINE_BLUEPRINT[marker.name] || TIMELINE_BLUEPRINT.UNKNOWN; + }, + + /** + * Returns the label to display for a marker, based off the blueprints. + * + * @param object marker + * @return string + */ + getMarkerLabel: function(marker) { + const blueprint = this.getBlueprintFor(marker); + const dynamic = typeof blueprint.label === "function"; + const label = dynamic ? blueprint.label(marker) : blueprint.label; + return label; + }, + + /** + * Returns the generic label to display for a marker name. + * (e.g. "Function Call" for JS markers, rather than "setTimeout", etc.) + * + * @param string type + * @return string + */ + getMarkerGenericName: function(markerName) { + const blueprint = this.getBlueprintFor({ name: markerName }); + const dynamic = typeof blueprint.label === "function"; + const generic = dynamic ? blueprint.label() : blueprint.label; + + // If no class name found, attempt to throw a descriptive error as to + // how the marker implementor can fix this. + if (!generic) { + let message = `Could not find marker generic name for "${markerName}".`; + if (typeof blueprint.label === "function") { + message += + ` The following function must return a generic name string when no` + + ` marker passed: ${blueprint.label}`; + } else { + message += ` ${markerName}.label must be defined in the marker blueprint.`; + } + throw new Error(message); + } + + return generic; + }, + + /** + * Returns an array of objects with key/value pairs of what should be rendered + * in the marker details view. + * + * @param object marker + * @return array<object> + */ + getMarkerFields: function(marker) { + const blueprint = this.getBlueprintFor(marker); + const dynamic = typeof blueprint.fields === "function"; + const fields = dynamic ? blueprint.fields(marker) : blueprint.fields; + + return Object.entries(fields || {}) + .filter(([_, value]) => (dynamic ? true : value in marker)) + .map(([label, value]) => ({ + label, + value: dynamic ? value : marker[value], + })); + }, +}; diff --git a/devtools/client/performance/modules/marker-dom-utils.js b/devtools/client/performance/modules/marker-dom-utils.js new file mode 100644 index 0000000000..556f940da9 --- /dev/null +++ b/devtools/client/performance/modules/marker-dom-utils.js @@ -0,0 +1,275 @@ +/* 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"; + +/** + * This file contains utilities for creating DOM nodes for markers + * to be displayed in the UI. + */ + +const { L10N, PREFS } = require("devtools/client/performance/modules/global"); +const { + MarkerBlueprintUtils, +} = require("devtools/client/performance/modules/marker-blueprint-utils"); +const { getSourceNames } = require("devtools/client/shared/source-utils"); + +/** + * Utilites for creating elements for markers. + */ +exports.MarkerDOMUtils = { + /** + * Builds all the fields possible for the given marker. Returns an + * array of elements to be appended to a parent element. + * + * @param document doc + * @param object marker + * @return array<Node> + */ + buildFields: function(doc, marker) { + const fields = MarkerBlueprintUtils.getMarkerFields(marker); + return fields.map(({ label, value }) => + this.buildNameValueLabel(doc, label, value) + ); + }, + + /** + * Builds the label representing the marker's type. + * + * @param document doc + * @param object marker + * @return Node + */ + buildTitle: function(doc, marker) { + const blueprint = MarkerBlueprintUtils.getBlueprintFor(marker); + + const hbox = doc.createXULElement("hbox"); + hbox.setAttribute("align", "center"); + + const bullet = doc.createXULElement("hbox"); + bullet.className = `marker-details-bullet marker-color-${blueprint.colorName}`; + + const title = MarkerBlueprintUtils.getMarkerLabel(marker); + const label = doc.createXULElement("label"); + label.className = "marker-details-type"; + label.setAttribute("value", title); + + hbox.appendChild(bullet); + hbox.appendChild(label); + + return hbox; + }, + + /** + * Builds the label representing the marker's duration. + * + * @param document doc + * @param object marker + * @return Node + */ + buildDuration: function(doc, marker) { + const label = L10N.getStr("marker.field.duration"); + const start = L10N.getFormatStrWithNumbers("timeline.tick", marker.start); + const end = L10N.getFormatStrWithNumbers("timeline.tick", marker.end); + const duration = L10N.getFormatStrWithNumbers( + "timeline.tick", + marker.end - marker.start + ); + + const el = this.buildNameValueLabel(doc, label, duration); + el.classList.add("marker-details-duration"); + el.setAttribute("tooltiptext", `${start} → ${end}`); + + return el; + }, + + /** + * Builds labels for name:value pairs. + * E.g. "Start: 100ms", "Duration: 200ms", ... + * + * @param document doc + * @param string field + * @param string value + * @return Node + */ + buildNameValueLabel: function(doc, field, value) { + const hbox = doc.createXULElement("hbox"); + hbox.className = "marker-details-labelcontainer"; + + const nameLabel = doc.createXULElement("label"); + nameLabel.className = "plain marker-details-name-label"; + nameLabel.setAttribute("value", field); + hbox.appendChild(nameLabel); + + const valueLabel = doc.createXULElement("label"); + valueLabel.className = "plain marker-details-value-label"; + valueLabel.setAttribute("value", value); + hbox.appendChild(valueLabel); + + return hbox; + }, + + /** + * Builds a stack trace in an element. + * + * @param document doc + * @param object params + * An options object with the following members: + * - string type: string identifier for type of stack ("stack", "startStack" + or "endStack" + * - number frameIndex: the index of the topmost stack frame + * - array frames: array of stack frames + */ + buildStackTrace: function(doc, { type, frameIndex, frames }) { + const container = doc.createXULElement("vbox"); + container.className = "marker-details-stack"; + container.setAttribute("type", type); + + const nameLabel = doc.createXULElement("label"); + nameLabel.className = "plain marker-details-name-label"; + nameLabel.setAttribute("value", L10N.getStr(`marker.field.${type}`)); + container.appendChild(nameLabel); + + // Workaround for profiles that have looping stack traces. See + // bug 1246555. + let wasAsyncParent = false; + const seen = new Set(); + + while (frameIndex > 0) { + if (seen.has(frameIndex)) { + break; + } + seen.add(frameIndex); + + const frame = frames[frameIndex]; + const url = frame.source; + const displayName = frame.functionDisplayName; + const line = frame.line; + + // If the previous frame had an async parent, then the async + // cause is in this frame and should be displayed. + if (wasAsyncParent) { + const asyncStr = L10N.getFormatStr( + "marker.field.asyncStack", + frame.asyncCause + ); + const asyncBox = doc.createXULElement("hbox"); + const asyncLabel = doc.createXULElement("label"); + asyncLabel.className = "devtools-monospace"; + asyncLabel.setAttribute("value", asyncStr); + asyncBox.appendChild(asyncLabel); + container.appendChild(asyncBox); + wasAsyncParent = false; + } + + const hbox = doc.createXULElement("hbox"); + + if (displayName) { + const functionLabel = doc.createXULElement("label"); + functionLabel.className = "devtools-monospace"; + functionLabel.setAttribute("value", displayName); + hbox.appendChild(functionLabel); + } + + if (url) { + const linkNode = doc.createXULElement("a"); + linkNode.className = "waterfall-marker-location devtools-source-link"; + linkNode.href = url; + linkNode.draggable = false; + linkNode.setAttribute("title", url); + + const urlLabel = doc.createXULElement("label"); + urlLabel.className = "filename"; + urlLabel.setAttribute("value", getSourceNames(url).short); + linkNode.appendChild(urlLabel); + + const lineLabel = doc.createXULElement("label"); + lineLabel.className = "line-number"; + lineLabel.setAttribute("value", `:${line}`); + linkNode.appendChild(lineLabel); + + hbox.appendChild(linkNode); + + // Clicking here will bubble up to the parent, + // which handles the view source. + linkNode.setAttribute( + "data-action", + JSON.stringify({ + url: url, + line: line, + action: "view-source", + }) + ); + } + + if (!displayName && !url) { + const unknownLabel = doc.createXULElement("label"); + unknownLabel.setAttribute( + "value", + L10N.getStr("marker.value.unknownFrame") + ); + hbox.appendChild(unknownLabel); + } + + container.appendChild(hbox); + + if (frame.asyncParent) { + frameIndex = frame.asyncParent; + wasAsyncParent = true; + } else { + frameIndex = frame.parent; + } + } + + return container; + }, + + /** + * Builds any custom fields specific to the marker. + * + * @param document doc + * @param object marker + * @param object options + * @return array<Node> + */ + buildCustom: function(doc, marker, options) { + const elements = []; + + if (options.allocations && shouldShowAllocationsTrigger(marker)) { + const hbox = doc.createXULElement("hbox"); + hbox.className = "marker-details-customcontainer"; + + const label = doc.createXULElement("label"); + label.className = "custom-button"; + label.setAttribute("value", "Show allocation triggers"); + label.setAttribute("type", "show-allocations"); + label.setAttribute( + "data-action", + JSON.stringify({ + endTime: marker.start, + action: "show-allocations", + }) + ); + + hbox.appendChild(label); + elements.push(hbox); + } + + return elements; + }, +}; + +/** + * Takes a marker and determines if this marker should display + * the allocations trigger button. + * + * @param object marker + * @return boolean + */ +function shouldShowAllocationsTrigger(marker) { + if (marker.name == "GarbageCollection") { + const showTriggers = PREFS["show-triggers-for-gc-types"]; + return showTriggers.split(" ").includes(marker.causeName); + } + return false; +} diff --git a/devtools/client/performance/modules/marker-formatters.js b/devtools/client/performance/modules/marker-formatters.js new file mode 100644 index 0000000000..b4b9472259 --- /dev/null +++ b/devtools/client/performance/modules/marker-formatters.js @@ -0,0 +1,204 @@ +/* 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"; + +/** + * This file contains utilities for creating elements for markers to be displayed, + * and parsing out the blueprint to generate correct values for markers. + */ +const { L10N, PREFS } = require("devtools/client/performance/modules/global"); + +// String used to fill in platform data when it should be hidden. +const GECKO_SYMBOL = "(Gecko)"; + +/** + * Mapping of JS marker causes to a friendlier form. Only + * markers that are considered "from content" should be labeled here. + */ +const JS_MARKER_MAP = { + "<script> element": L10N.getStr("marker.label.javascript.scriptElement"), + "promise callback": L10N.getStr("marker.label.javascript.promiseCallback"), + "promise initializer": L10N.getStr("marker.label.javascript.promiseInit"), + "Worker runnable": L10N.getStr("marker.label.javascript.workerRunnable"), + "javascript: URI": L10N.getStr("marker.label.javascript.jsURI"), + // The difference between these two event handler markers are differences + // in their WebIDL implementation, so distinguishing them is not necessary. + EventHandlerNonNull: L10N.getStr("marker.label.javascript.eventHandler"), + "EventListener.handleEvent": L10N.getStr( + "marker.label.javascript.eventHandler" + ), + // These markers do not get L10N'd because they're JS names. + "setInterval handler": "setInterval", + "setTimeout handler": "setTimeout", + FrameRequestCallback: "requestAnimationFrame", +}; + +/** + * A series of formatters used by the blueprint. + */ +exports.Formatters = { + /** + * Uses the marker name as the label for markers that do not have + * a blueprint entry. Uses "Other" in the marker filter menu. + */ + UnknownLabel: function(marker = {}) { + return marker.name || L10N.getStr("marker.label.unknown"); + }, + + /* Group 0 - Reflow and Rendering pipeline */ + + StylesFields: function(marker) { + if ("isAnimationOnly" in marker) { + return { + [L10N.getStr("marker.field.isAnimationOnly")]: marker.isAnimationOnly, + }; + } + return null; + }, + + /* Group 1 - JS */ + + DOMEventFields: function(marker) { + const fields = Object.create(null); + + if ("type" in marker) { + fields[L10N.getStr("marker.field.DOMEventType")] = marker.type; + } + + if ("eventPhase" in marker) { + let label; + switch (marker.eventPhase) { + case Event.AT_TARGET: + label = L10N.getStr("marker.value.DOMEventTargetPhase"); + break; + case Event.CAPTURING_PHASE: + label = L10N.getStr("marker.value.DOMEventCapturingPhase"); + break; + case Event.BUBBLING_PHASE: + label = L10N.getStr("marker.value.DOMEventBubblingPhase"); + break; + } + fields[L10N.getStr("marker.field.DOMEventPhase")] = label; + } + + return fields; + }, + + JSLabel: function(marker = {}) { + const generic = L10N.getStr("marker.label.javascript"); + if ("causeName" in marker) { + return JS_MARKER_MAP[marker.causeName] || generic; + } + return generic; + }, + + JSFields: function(marker) { + if ("causeName" in marker && !JS_MARKER_MAP[marker.causeName]) { + const label = PREFS["show-platform-data"] + ? marker.causeName + : GECKO_SYMBOL; + return { + [L10N.getStr("marker.field.causeName")]: label, + }; + } + return null; + }, + + GCLabel: function(marker) { + if (!marker) { + return L10N.getStr("marker.label.garbageCollection2"); + } + // Only if a `nonincrementalReason` exists, do we want to label + // this as a non incremental GC event. + if ("nonincrementalReason" in marker) { + return L10N.getStr("marker.label.garbageCollection.nonIncremental"); + } + return L10N.getStr("marker.label.garbageCollection.incremental"); + }, + + GCFields: function(marker) { + const fields = Object.create(null); + + if ("causeName" in marker) { + const cause = marker.causeName; + const label = L10N.getStr(`marker.gcreason.label.${cause}`) || cause; + fields[L10N.getStr("marker.field.causeName")] = label; + } + + if ("nonincrementalReason" in marker) { + const label = marker.nonincrementalReason; + fields[L10N.getStr("marker.field.nonIncrementalCause")] = label; + } + + return fields; + }, + + MinorGCFields: function(marker) { + const fields = Object.create(null); + + if ("causeName" in marker) { + const cause = marker.causeName; + const label = L10N.getStr(`marker.gcreason.label.${cause}`) || cause; + fields[L10N.getStr("marker.field.causeName")] = label; + } + + fields[L10N.getStr("marker.field.type")] = L10N.getStr( + "marker.nurseryCollection" + ); + + return fields; + }, + + CycleCollectionFields: function(marker) { + const label = marker.name.replace(/nsCycleCollector::/g, ""); + return { + [L10N.getStr("marker.field.type")]: label, + }; + }, + + WorkerFields: function(marker) { + if ("workerOperation" in marker) { + const label = L10N.getStr(`marker.worker.${marker.workerOperation}`); + return { + [L10N.getStr("marker.field.type")]: label, + }; + } + return null; + }, + + MessagePortFields: function(marker) { + if ("messagePortOperation" in marker) { + const label = L10N.getStr( + `marker.messagePort.${marker.messagePortOperation}` + ); + return { + [L10N.getStr("marker.field.type")]: label, + }; + } + return null; + }, + + /* Group 2 - User Controlled */ + + ConsoleTimeFields: { + [L10N.getStr("marker.field.consoleTimerName")]: "causeName", + }, + + TimeStampFields: { + [L10N.getStr("marker.field.label")]: "causeName", + }, +}; + +/** + * Takes a main label (e.g. "Timestamp") and a property name (e.g. "causeName"), + * and returns a string that represents that property value for a marker if it + * exists (e.g. "Timestamp (rendering)"), or just the main label if it does not. + * + * @param string mainLabel + * @param string propName + */ +exports.Formatters.labelForProperty = function(mainLabel, propName) { + return (marker = {}) => + marker[propName] ? `${mainLabel} (${marker[propName]})` : mainLabel; +}; diff --git a/devtools/client/performance/modules/markers.js b/devtools/client/performance/modules/markers.js new file mode 100644 index 0000000000..dbcd661205 --- /dev/null +++ b/devtools/client/performance/modules/markers.js @@ -0,0 +1,180 @@ +/* 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 { L10N } = require("devtools/client/performance/modules/global"); +const { + Formatters, +} = require("devtools/client/performance/modules/marker-formatters"); + +/** + * A simple schema for mapping markers to the timeline UI. The keys correspond + * to marker names, while the values are objects with the following format: + * + * - group: The row index in the overview graph; multiple markers + * can be added on the same row. @see <overview.js/buildGraphImage> + * - label: The label used in the waterfall to identify the marker. Can be a + * string or just a function that accepts the marker and returns a + * string (if you want to use a dynamic property for the main label). + * If you use a function for a label, it *must* handle the case where + * no marker is provided, to get a generic label used to describe + * all markers of this type. + * - fields: The fields used in the marker details view to display more + * information about a currently selected marker. Can either be an + * object of fields, or simply a function that accepts the marker and + * returns such an object (if you want to use properties dynamically). + * For example, a field in the object such as { "Cause": "causeName" } + * would render something like `Cause: ${marker.causeName}` in the UI. + * - colorName: The label of the DevTools color used for this marker. If + * adding a new color, be sure to check that there's an entry + * for `.marker-color-graphs-{COLORNAME}` for the equivilent + * entry in "./devtools/client/themes/performance.css" + * - collapsible: Whether or not this marker can contain other markers it + * eclipses, and becomes collapsible to reveal its nestable + * children. Defaults to true. + * - nestable: Whether or not this marker can be nested inside an eclipsing + * collapsible marker. Defaults to true. + */ +const TIMELINE_BLUEPRINT = { + /* Default definition used for markers that occur but are not defined here. + * Should ultimately be defined, but this gives us room to work on the + * front end separately from the platform. */ + UNKNOWN: { + group: 2, + colorName: "graphs-grey", + label: Formatters.UnknownLabel, + }, + + /* Group 0 - Reflow and Rendering pipeline */ + + Styles: { + group: 0, + colorName: "graphs-purple", + label: L10N.getStr("marker.label.styles"), + fields: Formatters.StylesFields, + }, + StylesApplyChanges: { + group: 0, + colorName: "graphs-purple", + label: L10N.getStr("marker.label.stylesApplyChanges"), + }, + Reflow: { + group: 0, + colorName: "graphs-purple", + label: L10N.getStr("marker.label.reflow"), + }, + Paint: { + group: 0, + colorName: "graphs-green", + label: L10N.getStr("marker.label.paint"), + }, + Composite: { + group: 0, + colorName: "graphs-green", + label: L10N.getStr("marker.label.composite"), + }, + CompositeForwardTransaction: { + group: 0, + colorName: "graphs-bluegrey", + label: L10N.getStr("marker.label.compositeForwardTransaction"), + }, + + /* Group 1 - JS */ + + DOMEvent: { + group: 1, + colorName: "graphs-yellow", + label: L10N.getStr("marker.label.domevent"), + fields: Formatters.DOMEventFields, + }, + "document::DOMContentLoaded": { + group: 1, + colorName: "graphs-full-red", + label: "DOMContentLoaded", + }, + "document::Load": { + group: 1, + colorName: "graphs-full-blue", + label: "Load", + }, + Javascript: { + group: 1, + colorName: "graphs-yellow", + label: Formatters.JSLabel, + fields: Formatters.JSFields, + }, + "Parse HTML": { + group: 1, + colorName: "graphs-yellow", + label: L10N.getStr("marker.label.parseHTML"), + }, + "Parse XML": { + group: 1, + colorName: "graphs-yellow", + label: L10N.getStr("marker.label.parseXML"), + }, + GarbageCollection: { + group: 1, + colorName: "graphs-red", + label: Formatters.GCLabel, + fields: Formatters.GCFields, + }, + MinorGC: { + group: 1, + colorName: "graphs-red", + label: L10N.getStr("marker.label.minorGC"), + fields: Formatters.MinorGCFields, + }, + "nsCycleCollector::Collect": { + group: 1, + colorName: "graphs-red", + label: L10N.getStr("marker.label.cycleCollection"), + fields: Formatters.CycleCollectionFields, + }, + "nsCycleCollector::ForgetSkippable": { + group: 1, + colorName: "graphs-red", + label: L10N.getStr("marker.label.cycleCollection.forgetSkippable"), + fields: Formatters.CycleCollectionFields, + }, + Worker: { + group: 1, + colorName: "graphs-orange", + label: L10N.getStr("marker.label.worker"), + fields: Formatters.WorkerFields, + }, + MessagePort: { + group: 1, + colorName: "graphs-orange", + label: L10N.getStr("marker.label.messagePort"), + fields: Formatters.MessagePortFields, + }, + + /* Group 2 - User Controlled */ + + ConsoleTime: { + group: 2, + colorName: "graphs-blue", + label: Formatters.labelForProperty( + L10N.getStr("marker.label.consoleTime"), + "causeName" + ), + fields: Formatters.ConsoleTimeFields, + nestable: false, + collapsible: false, + }, + TimeStamp: { + group: 2, + colorName: "graphs-blue", + label: Formatters.labelForProperty( + L10N.getStr("marker.label.timestamp"), + "causeName" + ), + fields: Formatters.TimeStampFields, + collapsible: false, + }, +}; + +// Exported symbols. +exports.TIMELINE_BLUEPRINT = TIMELINE_BLUEPRINT; diff --git a/devtools/client/performance/modules/moz.build b/devtools/client/performance/modules/moz.build new file mode 100644 index 0000000000..c054669022 --- /dev/null +++ b/devtools/client/performance/modules/moz.build @@ -0,0 +1,22 @@ +# 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/. + +DIRS += [ + "logic", + "widgets", +] + +DevToolsModules( + "categories.js", + "constants.js", + "global.js", + "io.js", + "marker-blueprint-utils.js", + "marker-dom-utils.js", + "marker-formatters.js", + "markers.js", + "utils.js", + "waterfall-ticks.js", +) diff --git a/devtools/client/performance/modules/utils.js b/devtools/client/performance/modules/utils.js new file mode 100644 index 0000000000..ebd0ba2ccb --- /dev/null +++ b/devtools/client/performance/modules/utils.js @@ -0,0 +1,24 @@ +/* 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"; + +/* globals document */ + +/** + * React components grab the namespace of the element they are mounting to. This function + * takes a XUL element, and makes sure to create a properly namespaced HTML element to + * avoid React creating XUL elements. + * + * {XULElement} xulElement + * return {HTMLElement} div + */ + +exports.createHtmlMount = function(xulElement) { + const htmlElement = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "div" + ); + xulElement.appendChild(htmlElement); + return htmlElement; +}; diff --git a/devtools/client/performance/modules/waterfall-ticks.js b/devtools/client/performance/modules/waterfall-ticks.js new file mode 100644 index 0000000000..efc88001f3 --- /dev/null +++ b/devtools/client/performance/modules/waterfall-ticks.js @@ -0,0 +1,98 @@ +/* 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 HTML_NS = "http://www.w3.org/1999/xhtml"; + +const WATERFALL_BACKGROUND_TICKS_MULTIPLE = 5; // ms +const WATERFALL_BACKGROUND_TICKS_SCALES = 3; +const WATERFALL_BACKGROUND_TICKS_SPACING_MIN = 10; // px +const WATERFALL_BACKGROUND_TICKS_COLOR_RGB = [128, 136, 144]; +const WATERFALL_BACKGROUND_TICKS_OPACITY_MIN = 32; // byte +const WATERFALL_BACKGROUND_TICKS_OPACITY_ADD = 32; // byte + +const FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS = 100; + +/** + * Creates the background displayed on the marker's waterfall. + */ +function drawWaterfallBackground(doc, dataScale, waterfallWidth) { + const canvas = doc.createElementNS(HTML_NS, "canvas"); + const ctx = canvas.getContext("2d"); + + // Nuke the context. + const canvasWidth = (canvas.width = Math.max(waterfallWidth, 1)); + // Awww yeah, 1px, repeats on Y axis. + const canvasHeight = (canvas.height = 1); + + // Start over. + const imageData = ctx.createImageData(canvasWidth, canvasHeight); + const pixelArray = imageData.data; + + const buf = new ArrayBuffer(pixelArray.length); + const view8bit = new Uint8ClampedArray(buf); + const view32bit = new Uint32Array(buf); + + // Build new millisecond tick lines... + const [r, g, b] = WATERFALL_BACKGROUND_TICKS_COLOR_RGB; + let alphaComponent = WATERFALL_BACKGROUND_TICKS_OPACITY_MIN; + const tickInterval = findOptimalTickInterval({ + ticksMultiple: WATERFALL_BACKGROUND_TICKS_MULTIPLE, + ticksSpacingMin: WATERFALL_BACKGROUND_TICKS_SPACING_MIN, + dataScale: dataScale, + }); + + // Insert one pixel for each division on each scale. + for (let i = 1; i <= WATERFALL_BACKGROUND_TICKS_SCALES; i++) { + const increment = tickInterval * Math.pow(2, i); + for (let x = 0; x < canvasWidth; x += increment) { + const position = x | 0; + view32bit[position] = (alphaComponent << 24) | (b << 16) | (g << 8) | r; + } + alphaComponent += WATERFALL_BACKGROUND_TICKS_OPACITY_ADD; + } + + // Flush the image data and cache the waterfall background. + pixelArray.set(view8bit); + ctx.putImageData(imageData, 0, 0); + doc.mozSetImageElement("waterfall-background", canvas); + + return canvas; +} + +/** + * Finds the optimal tick interval between time markers in this timeline. + * + * @param number ticksMultiple + * @param number ticksSpacingMin + * @param number dataScale + * @return number + */ +function findOptimalTickInterval({ + ticksMultiple, + ticksSpacingMin, + dataScale, +}) { + let timingStep = ticksMultiple; + const maxIters = FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS; + let numIters = 0; + + if (dataScale > ticksSpacingMin) { + return dataScale; + } + + while (true) { + const scaledStep = dataScale * timingStep; + if (++numIters > maxIters) { + return scaledStep; + } + if (scaledStep < ticksSpacingMin) { + timingStep <<= 1; + continue; + } + return scaledStep; + } +} + +exports.TickUtils = { findOptimalTickInterval, drawWaterfallBackground }; diff --git a/devtools/client/performance/modules/widgets/graphs.js b/devtools/client/performance/modules/widgets/graphs.js new file mode 100644 index 0000000000..926fb43cf6 --- /dev/null +++ b/devtools/client/performance/modules/widgets/graphs.js @@ -0,0 +1,527 @@ +/* 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"; + +/** + * This file contains the base line graph that all Performance line graphs use. + */ + +const { extend } = require("devtools/shared/extend"); +const LineGraphWidget = require("devtools/client/shared/widgets/LineGraphWidget"); +const MountainGraphWidget = require("devtools/client/shared/widgets/MountainGraphWidget"); +const { CanvasGraphUtils } = require("devtools/client/shared/widgets/Graphs"); + +const EventEmitter = require("devtools/shared/event-emitter"); + +const { colorUtils } = require("devtools/shared/css/color"); +const { getColor } = require("devtools/client/shared/theme"); +const ProfilerGlobal = require("devtools/client/performance/modules/global"); +const { + MarkersOverview, +} = require("devtools/client/performance/modules/widgets/markers-overview"); +const { + createTierGraphDataFromFrameNode, +} = require("devtools/client/performance/modules/logic/jit"); + +/** + * For line graphs + */ +const HEIGHT = 35; // px +const STROKE_WIDTH = 1; // px +const DAMPEN_VALUES = 0.95; +const CLIPHEAD_LINE_COLOR = "#666"; +const SELECTION_LINE_COLOR = "#555"; +const SELECTION_BACKGROUND_COLOR_NAME = "graphs-blue"; +const FRAMERATE_GRAPH_COLOR_NAME = "graphs-green"; +const MEMORY_GRAPH_COLOR_NAME = "graphs-blue"; + +/** + * For timeline overview + */ +const MARKERS_GRAPH_HEADER_HEIGHT = 14; // px +const MARKERS_GRAPH_ROW_HEIGHT = 10; // px +const MARKERS_GROUP_VERTICAL_PADDING = 4; // px + +/** + * For optimization graph + */ +const OPTIMIZATIONS_GRAPH_RESOLUTION = 100; + +/** + * A base class for performance graphs to inherit from. + * + * @param Node parent + * The parent node holding the overview. + * @param string metric + * The unit of measurement for this graph. + */ +function PerformanceGraph(parent, metric) { + LineGraphWidget.call(this, parent, { metric }); + this.setTheme(); +} + +PerformanceGraph.prototype = extend(LineGraphWidget.prototype, { + strokeWidth: STROKE_WIDTH, + dampenValuesFactor: DAMPEN_VALUES, + fixedHeight: HEIGHT, + clipheadLineColor: CLIPHEAD_LINE_COLOR, + selectionLineColor: SELECTION_LINE_COLOR, + withTooltipArrows: false, + withFixedTooltipPositions: true, + + /** + * Disables selection and empties this graph. + */ + clearView: function() { + this.selectionEnabled = false; + this.dropSelection(); + this.setData([]); + }, + + /** + * Sets the theme via `theme` to either "light" or "dark", + * and updates the internal styling to match. Requires a redraw + * to see the effects. + */ + setTheme: function(theme) { + theme = theme || "light"; + const mainColor = getColor(this.mainColor || "graphs-blue", theme); + this.backgroundColor = getColor("body-background", theme); + this.strokeColor = mainColor; + this.backgroundGradientStart = colorUtils.setAlpha(mainColor, 0.2); + this.backgroundGradientEnd = colorUtils.setAlpha(mainColor, 0.2); + this.selectionBackgroundColor = colorUtils.setAlpha( + getColor(SELECTION_BACKGROUND_COLOR_NAME, theme), + 0.25 + ); + this.selectionStripesColor = "rgba(255, 255, 255, 0.1)"; + this.maximumLineColor = colorUtils.setAlpha(mainColor, 0.4); + this.averageLineColor = colorUtils.setAlpha(mainColor, 0.7); + this.minimumLineColor = colorUtils.setAlpha(mainColor, 0.9); + }, +}); + +/** + * Constructor for the framerate graph. Inherits from PerformanceGraph. + * + * @param Node parent + * The parent node holding the overview. + */ +function FramerateGraph(parent) { + PerformanceGraph.call(this, parent, ProfilerGlobal.L10N.getStr("graphs.fps")); +} + +FramerateGraph.prototype = extend(PerformanceGraph.prototype, { + mainColor: FRAMERATE_GRAPH_COLOR_NAME, + setPerformanceData: function({ duration, ticks }, resolution) { + this.dataDuration = duration; + return this.setDataFromTimestamps(ticks, resolution, duration); + }, +}); + +/** + * Constructor for the memory graph. Inherits from PerformanceGraph. + * + * @param Node parent + * The parent node holding the overview. + */ +function MemoryGraph(parent) { + PerformanceGraph.call( + this, + parent, + ProfilerGlobal.L10N.getStr("graphs.memory") + ); +} + +MemoryGraph.prototype = extend(PerformanceGraph.prototype, { + mainColor: MEMORY_GRAPH_COLOR_NAME, + setPerformanceData: function({ duration, memory }) { + this.dataDuration = duration; + return this.setData(memory); + }, +}); + +function TimelineGraph(parent, filter) { + MarkersOverview.call(this, parent, filter); +} + +TimelineGraph.prototype = extend(MarkersOverview.prototype, { + headerHeight: MARKERS_GRAPH_HEADER_HEIGHT, + rowHeight: MARKERS_GRAPH_ROW_HEIGHT, + groupPadding: MARKERS_GROUP_VERTICAL_PADDING, + setPerformanceData: MarkersOverview.prototype.setData, +}); + +/** + * Definitions file for GraphsController, indicating the constructor, + * selector and other meta for each of the graphs controller by + * GraphsController. + */ +const GRAPH_DEFINITIONS = { + memory: { + constructor: MemoryGraph, + selector: "#memory-overview", + }, + framerate: { + constructor: FramerateGraph, + selector: "#time-framerate", + }, + timeline: { + constructor: TimelineGraph, + selector: "#markers-overview", + primaryLink: true, + }, +}; + +/** + * A controller for orchestrating the performance's tool overview graphs. Constructs, + * syncs, toggles displays and defines the memory, framerate and timeline view. + * + * @param {object} definition + * @param {DOMElement} root + * @param {function} getFilter + * @param {function} getTheme + */ +function GraphsController({ definition, root, getFilter, getTheme }) { + this._graphs = {}; + this._enabled = new Set(); + this._definition = definition || GRAPH_DEFINITIONS; + this._root = root; + this._getFilter = getFilter; + this._getTheme = getTheme; + this._primaryLink = Object.keys(this._definition).filter( + name => this._definition[name].primaryLink + )[0]; + this.$ = root.ownerDocument.querySelector.bind(root.ownerDocument); + + EventEmitter.decorate(this); + this._onSelecting = this._onSelecting.bind(this); +} + +GraphsController.prototype = { + /** + * Returns the corresponding graph by `graphName`. + */ + get: function(graphName) { + return this._graphs[graphName]; + }, + + /** + * Iterates through all graphs and renders the data + * from a RecordingModel. Takes a resolution value used in + * some graphs. + * Saves rendering progress as a promise to be consumed by `destroy`, + * to wait for cleaning up rendering during destruction. + */ + async render(recordingData, resolution) { + // Get the previous render promise so we don't start rendering + // until the previous render cycle completes, which can occur + // especially when a recording is finished, and triggers a + // fresh rendering at a higher rate + await this._rendering; + + // Check after yielding to ensure we're not tearing down, + // as this can create a race condition in tests + if (this._destroyed) { + return; + } + + this._rendering = (async () => { + for (const graph of await this._getEnabled()) { + await graph.setPerformanceData(recordingData, resolution); + this.emit("rendered", graph.graphName); + } + })(); + await this._rendering; + }, + + /** + * Destroys the underlying graphs. + */ + async destroy() { + const primary = this._getPrimaryLink(); + + this._destroyed = true; + + if (primary) { + primary.off("selecting", this._onSelecting); + } + + // If there was rendering, wait until the most recent render cycle + // has finished + if (this._rendering) { + await this._rendering; + } + + for (const graph of this.getWidgets()) { + await graph.destroy(); + } + }, + + /** + * Applies the theme to the underlying graphs. Optionally takes + * a `redraw` boolean in the options to force redraw. + */ + setTheme: function(options = {}) { + const theme = options.theme || this._getTheme(); + for (const graph of this.getWidgets()) { + graph.setTheme(theme); + graph.refresh({ force: options.redraw }); + } + }, + + /** + * Sets up the graph, if needed. Returns a promise resolving + * to the graph if it is enabled once it's ready, or otherwise returns + * null if disabled. + */ + async isAvailable(graphName) { + if (!this._enabled.has(graphName)) { + return null; + } + + let graph = this.get(graphName); + + if (!graph) { + graph = await this._construct(graphName); + } + + await graph.ready(); + return graph; + }, + + /** + * Enable or disable a subgraph controlled by GraphsController. + * This determines what graphs are visible and get rendered. + */ + enable: function(graphName, isEnabled) { + const el = this.$(this._definition[graphName].selector); + el.classList[isEnabled ? "remove" : "add"]("hidden"); + + // If no status change, just return + if (this._enabled.has(graphName) === isEnabled) { + return; + } + if (isEnabled) { + this._enabled.add(graphName); + } else { + this._enabled.delete(graphName); + } + + // Invalidate our cache of ready-to-go graphs + this._enabledGraphs = null; + }, + + /** + * Disables all graphs controller by the GraphsController, and + * also hides the root element. This is a one way switch, and used + * when older platforms do not have any timeline data. + */ + disableAll: function() { + this._root.classList.add("hidden"); + // Hide all the subelements + Object.keys(this._definition).forEach(graphName => + this.enable(graphName, false) + ); + }, + + /** + * Sets a mapped selection on the graph that is the main controller + * for keeping the graphs' selections in sync. + */ + setMappedSelection: function(selection, { mapStart, mapEnd }) { + return this._getPrimaryLink().setMappedSelection(selection, { + mapStart, + mapEnd, + }); + }, + + /** + * Fetches the currently mapped selection. If graphs are not yet rendered, + * (which throws in Graphs.js), return null. + */ + getMappedSelection: function({ mapStart, mapEnd }) { + const primary = this._getPrimaryLink(); + if (primary && primary.hasData()) { + return primary.getMappedSelection({ mapStart, mapEnd }); + } + return null; + }, + + /** + * Returns an array of graphs that have been created, not necessarily + * enabled currently. + */ + getWidgets: function() { + return Object.keys(this._graphs).map(name => this._graphs[name]); + }, + + /** + * Drops the selection. + */ + dropSelection: function() { + if (this._getPrimaryLink()) { + return this._getPrimaryLink().dropSelection(); + } + return null; + }, + + /** + * Makes sure the selection is enabled or disabled in all the graphs. + */ + async selectionEnabled(enabled) { + for (const graph of await this._getEnabled()) { + graph.selectionEnabled = enabled; + } + }, + + /** + * Creates the graph `graphName` and initializes it. + */ + async _construct(graphName) { + const def = this._definition[graphName]; + const el = this.$(def.selector); + const filter = this._getFilter(); + const graph = (this._graphs[graphName] = new def.constructor(el, filter)); + graph.graphName = graphName; + + await graph.ready(); + + // Sync the graphs' animations and selections together + if (def.primaryLink) { + graph.on("selecting", this._onSelecting); + } else { + CanvasGraphUtils.linkAnimation(this._getPrimaryLink(), graph); + CanvasGraphUtils.linkSelection(this._getPrimaryLink(), graph); + } + + // Sets the container element's visibility based off of enabled status + el.classList[this._enabled.has(graphName) ? "remove" : "add"]("hidden"); + + this.setTheme(); + return graph; + }, + + /** + * Returns the main graph for this collection, that all graphs + * are bound to for syncing and selection. + */ + _getPrimaryLink: function() { + return this.get(this._primaryLink); + }, + + /** + * Emitted when a selection occurs. + */ + _onSelecting: function() { + this.emit("selecting"); + }, + + /** + * Resolves to an array with all graphs that are enabled, and + * creates them if needed. Different than just iterating over `this._graphs`, + * as those could be enabled. Uses caching, as rendering happens many times per second, + * compared to how often which graphs/features are changed (rarely). + */ + async _getEnabled() { + if (this._enabledGraphs) { + return this._enabledGraphs; + } + const enabled = []; + for (const graphName of this._enabled) { + const graph = await this.isAvailable(graphName); + if (graph) { + enabled.push(graph); + } + } + this._enabledGraphs = enabled; + return this._enabledGraphs; + }, +}; + +/** + * A base class for performance graphs to inherit from. + * + * @param Node parent + * The parent node holding the overview. + * @param string metric + * The unit of measurement for this graph. + */ +function OptimizationsGraph(parent) { + MountainGraphWidget.call(this, parent); + this.setTheme(); +} + +OptimizationsGraph.prototype = extend(MountainGraphWidget.prototype, { + async render(threadNode, frameNode) { + // Regardless if we draw or clear the graph, wait + // until it's ready. + await this.ready(); + + if (!threadNode || !frameNode) { + this.setData([]); + return; + } + + const { sampleTimes } = threadNode; + + if (!sampleTimes.length) { + this.setData([]); + return; + } + + // Take startTime/endTime from samples recorded, rather than + // using duration directly from threadNode, as the first sample that + // equals the startTime does not get recorded. + const startTime = sampleTimes[0]; + const endTime = sampleTimes[sampleTimes.length - 1]; + + const bucketSize = (endTime - startTime) / OPTIMIZATIONS_GRAPH_RESOLUTION; + const data = createTierGraphDataFromFrameNode( + frameNode, + sampleTimes, + bucketSize + ); + + // If for some reason we don't have data (like the frameNode doesn't + // have optimizations, but it shouldn't be at this point if it doesn't), + // log an error. + if (!data) { + console.error( + `FrameNode#${frameNode.location} does not have optimizations data to render.` + ); + return; + } + + this.dataOffsetX = startTime; + await this.setData(data); + }, + + /** + * Sets the theme via `theme` to either "light" or "dark", + * and updates the internal styling to match. Requires a redraw + * to see the effects. + */ + setTheme: function(theme) { + theme = theme || "light"; + + const interpreterColor = getColor("graphs-red", theme); + const baselineColor = getColor("graphs-blue", theme); + const ionColor = getColor("graphs-green", theme); + + this.format = [ + { color: interpreterColor }, + { color: baselineColor }, + { color: ionColor }, + ]; + + this.backgroundColor = getColor("sidebar-background", theme); + }, +}); + +exports.OptimizationsGraph = OptimizationsGraph; +exports.FramerateGraph = FramerateGraph; +exports.MemoryGraph = MemoryGraph; +exports.TimelineGraph = TimelineGraph; +exports.GraphsController = GraphsController; diff --git a/devtools/client/performance/modules/widgets/marker-details.js b/devtools/client/performance/modules/widgets/marker-details.js new file mode 100644 index 0000000000..2a8ce17ce4 --- /dev/null +++ b/devtools/client/performance/modules/widgets/marker-details.js @@ -0,0 +1,177 @@ +/* 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"; + +/** + * This file contains the rendering code for the marker sidebar. + */ + +const EventEmitter = require("devtools/shared/event-emitter"); +const { + MarkerDOMUtils, +} = require("devtools/client/performance/modules/marker-dom-utils"); + +/** + * A detailed view for one single marker. + * + * @param Node parent + * The parent node holding the view. + * @param Node splitter + * The splitter node that the resize event is bound to. + */ +function MarkerDetails(parent, splitter) { + EventEmitter.decorate(this); + + this._document = parent.ownerDocument; + this._parent = parent; + this._splitter = splitter; + + this._onClick = this._onClick.bind(this); + this._onSplitterMouseUp = this._onSplitterMouseUp.bind(this); + + this._parent.addEventListener("click", this._onClick); + this._splitter.addEventListener("mouseup", this._onSplitterMouseUp); + + this.hidden = true; +} + +MarkerDetails.prototype = { + /** + * Sets this view's width. + * @param number + */ + set width(value) { + this._parent.setAttribute("width", value); + }, + + /** + * Sets this view's width. + * @return number + */ + get width() { + return +this._parent.getAttribute("width"); + }, + + /** + * Sets this view's visibility. + * @param boolean + */ + set hidden(value) { + if (this._parent.hidden != value) { + this._parent.hidden = value; + this.emit("resize"); + } + }, + + /** + * Gets this view's visibility. + * @param boolean + */ + get hidden() { + return this._parent.hidden; + }, + + /** + * Clears the marker details from this view. + */ + empty: function() { + this._parent.innerHTML = ""; + }, + + /** + * Populates view with marker's details. + * + * @param object params + * An options object holding: + * - marker: The marker to display. + * - frames: Array of stack frame information; see stack.js. + * - allocations: Whether or not allocations were enabled for this + * recording. [optional] + */ + render: function(options) { + const { marker, frames } = options; + this.empty(); + + const elements = []; + elements.push(MarkerDOMUtils.buildTitle(this._document, marker)); + elements.push(MarkerDOMUtils.buildDuration(this._document, marker)); + MarkerDOMUtils.buildFields(this._document, marker).forEach(f => + elements.push(f) + ); + MarkerDOMUtils.buildCustom(this._document, marker, options).forEach(f => + elements.push(f) + ); + + // Build a stack element -- and use the "startStack" label if + // we have both a startStack and endStack. + if (marker.stack) { + const type = marker.endStack ? "startStack" : "stack"; + elements.push( + MarkerDOMUtils.buildStackTrace(this._document, { + frameIndex: marker.stack, + frames, + type, + }) + ); + } + if (marker.endStack) { + const type = "endStack"; + elements.push( + MarkerDOMUtils.buildStackTrace(this._document, { + frameIndex: marker.endStack, + frames, + type, + }) + ); + } + + elements.forEach(el => this._parent.appendChild(el)); + }, + + /** + * Handles click in the marker details view. Based on the target, + * can handle different actions -- only supporting view source links + * for the moment. + */ + _onClick: function(e) { + const data = findActionFromEvent(e.target, this._parent); + if (!data) { + return; + } + + this.emit(data.action, data); + }, + + /** + * Handles the "mouseup" event on the marker details view splitter. + */ + _onSplitterMouseUp: function() { + this.emit("resize"); + }, +}; + +/** + * Take an element from an event `target`, and ascend through + * the DOM, looking for an element with a `data-action` attribute. Return + * the parsed `data-action` value found, or null if none found before + * reaching the parent `container`. + * + * @param {Element} target + * @param {Element} container + * @return {?object} + */ +function findActionFromEvent(target, container) { + let el = target; + let action; + while (el !== container) { + action = el.getAttribute("data-action"); + if (action) { + return JSON.parse(action); + } + el = el.parentNode; + } + return null; +} + +exports.MarkerDetails = MarkerDetails; diff --git a/devtools/client/performance/modules/widgets/markers-overview.js b/devtools/client/performance/modules/widgets/markers-overview.js new file mode 100644 index 0000000000..ea762a371d --- /dev/null +++ b/devtools/client/performance/modules/widgets/markers-overview.js @@ -0,0 +1,256 @@ +/* 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"; + +/** + * This file contains the "markers overview" graph, which is a minimap of all + * the timeline data. Regions inside it may be selected, determining which + * markers are visible in the "waterfall". + */ + +const { extend } = require("devtools/shared/extend"); +const { + AbstractCanvasGraph, +} = require("devtools/client/shared/widgets/Graphs"); + +const { colorUtils } = require("devtools/shared/css/color"); +const { getColor } = require("devtools/client/shared/theme"); +const ProfilerGlobal = require("devtools/client/performance/modules/global"); +const { + MarkerBlueprintUtils, +} = require("devtools/client/performance/modules/marker-blueprint-utils"); +const { + TickUtils, +} = require("devtools/client/performance/modules/waterfall-ticks"); +const { + TIMELINE_BLUEPRINT, +} = require("devtools/client/performance/modules/markers"); + +const OVERVIEW_HEADER_HEIGHT = 14; // px +const OVERVIEW_ROW_HEIGHT = 11; // px + +const OVERVIEW_SELECTION_LINE_COLOR = "#666"; +const OVERVIEW_CLIPHEAD_LINE_COLOR = "#555"; + +const OVERVIEW_HEADER_TICKS_MULTIPLE = 100; // ms +const OVERVIEW_HEADER_TICKS_SPACING_MIN = 75; // px +const OVERVIEW_HEADER_TEXT_FONT_SIZE = 9; // px +const OVERVIEW_HEADER_TEXT_FONT_FAMILY = "sans-serif"; +const OVERVIEW_HEADER_TEXT_PADDING_LEFT = 6; // px +const OVERVIEW_HEADER_TEXT_PADDING_TOP = 1; // px +const OVERVIEW_MARKER_WIDTH_MIN = 4; // px +const OVERVIEW_GROUP_VERTICAL_PADDING = 5; // px + +/** + * An overview for the markers data. + * + * @param Node parent + * The parent node holding the overview. + * @param Array<String> filter + * List of names of marker types that should not be shown. + */ +function MarkersOverview(parent, filter = [], ...args) { + AbstractCanvasGraph.apply(this, [parent, "markers-overview", ...args]); + this.setTheme(); + this.setFilter(filter); +} + +MarkersOverview.prototype = extend(AbstractCanvasGraph.prototype, { + clipheadLineColor: OVERVIEW_CLIPHEAD_LINE_COLOR, + selectionLineColor: OVERVIEW_SELECTION_LINE_COLOR, + headerHeight: OVERVIEW_HEADER_HEIGHT, + rowHeight: OVERVIEW_ROW_HEIGHT, + groupPadding: OVERVIEW_GROUP_VERTICAL_PADDING, + + /** + * Compute the height of the overview. + */ + get fixedHeight() { + return this.headerHeight + this.rowHeight * this._numberOfGroups; + }, + + /** + * List of marker types that should not be shown in the graph. + */ + setFilter: function(filter) { + this._paintBatches = new Map(); + this._filter = filter; + this._groupMap = Object.create(null); + + const observedGroups = new Set(); + + for (const type in TIMELINE_BLUEPRINT) { + if (filter.includes(type)) { + continue; + } + this._paintBatches.set(type, { + definition: TIMELINE_BLUEPRINT[type], + batch: [], + }); + observedGroups.add(TIMELINE_BLUEPRINT[type].group); + } + + // Take our set of observed groups and order them and map + // the group numbers to fill in the holes via `_groupMap`. + // This normalizes our rows by removing rows that aren't used + // if filters are enabled. + let actualPosition = 0; + for (const groupNumber of Array.from(observedGroups).sort()) { + this._groupMap[groupNumber] = actualPosition++; + } + this._numberOfGroups = Object.keys(this._groupMap).length; + }, + + /** + * Disables selection and empties this graph. + */ + clearView: function() { + this.selectionEnabled = false; + this.dropSelection(); + this.setData({ duration: 0, markers: [] }); + }, + + /** + * Renders the graph's data source. + * @see AbstractCanvasGraph.prototype.buildGraphImage + */ + buildGraphImage: function() { + const { markers, duration } = this._data; + + const { canvas, ctx } = this._getNamedCanvas("markers-overview-data"); + const canvasWidth = this._width; + const canvasHeight = this._height; + + // Group markers into separate paint batches. This is necessary to + // draw all markers sharing the same style at once. + for (const marker of markers) { + // Again skip over markers that we're filtering -- we don't want them + // to be labeled as "Unknown" + if (!MarkerBlueprintUtils.shouldDisplayMarker(marker, this._filter)) { + continue; + } + + const markerType = + this._paintBatches.get(marker.name) || + this._paintBatches.get("UNKNOWN"); + markerType.batch.push(marker); + } + + // Calculate each row's height, and the time-based scaling. + + const groupHeight = this.rowHeight * this._pixelRatio; + const groupPadding = this.groupPadding * this._pixelRatio; + const headerHeight = this.headerHeight * this._pixelRatio; + const dataScale = (this.dataScaleX = canvasWidth / duration); + + // Draw the header and overview background. + + ctx.fillStyle = this.headerBackgroundColor; + ctx.fillRect(0, 0, canvasWidth, headerHeight); + + ctx.fillStyle = this.backgroundColor; + ctx.fillRect(0, headerHeight, canvasWidth, canvasHeight); + + // Draw the alternating odd/even group backgrounds. + + ctx.fillStyle = this.alternatingBackgroundColor; + ctx.beginPath(); + + for (let i = 0; i < this._numberOfGroups; i += 2) { + const top = headerHeight + i * groupHeight; + ctx.rect(0, top, canvasWidth, groupHeight); + } + + ctx.fill(); + + // Draw the timeline header ticks. + + const fontSize = OVERVIEW_HEADER_TEXT_FONT_SIZE * this._pixelRatio; + const fontFamily = OVERVIEW_HEADER_TEXT_FONT_FAMILY; + const textPaddingLeft = + OVERVIEW_HEADER_TEXT_PADDING_LEFT * this._pixelRatio; + const textPaddingTop = OVERVIEW_HEADER_TEXT_PADDING_TOP * this._pixelRatio; + + const tickInterval = TickUtils.findOptimalTickInterval({ + ticksMultiple: OVERVIEW_HEADER_TICKS_MULTIPLE, + ticksSpacingMin: OVERVIEW_HEADER_TICKS_SPACING_MIN, + dataScale: dataScale, + }); + + ctx.textBaseline = "middle"; + ctx.font = fontSize + "px " + fontFamily; + ctx.fillStyle = this.headerTextColor; + ctx.strokeStyle = this.headerTimelineStrokeColor; + ctx.beginPath(); + + for (let x = 0; x < canvasWidth; x += tickInterval) { + const lineLeft = x; + const textLeft = lineLeft + textPaddingLeft; + const time = Math.round(x / dataScale); + const label = ProfilerGlobal.L10N.getFormatStr("timeline.tick", time); + ctx.fillText(label, textLeft, headerHeight / 2 + textPaddingTop); + ctx.moveTo(lineLeft, 0); + ctx.lineTo(lineLeft, canvasHeight); + } + + ctx.stroke(); + + // Draw the timeline markers. + + for (const [, { definition, batch }] of this._paintBatches) { + const group = this._groupMap[definition.group]; + const top = headerHeight + group * groupHeight + groupPadding / 2; + const height = groupHeight - groupPadding; + + const color = getColor(definition.colorName, this.theme); + ctx.fillStyle = color; + ctx.beginPath(); + + for (const { start, end } of batch) { + const left = start * dataScale; + const width = Math.max( + (end - start) * dataScale, + OVERVIEW_MARKER_WIDTH_MIN + ); + ctx.rect(left, top, width, height); + } + + ctx.fill(); + + // Since all the markers in this batch (thus sharing the same style) have + // been drawn, empty it. The next time new markers will be available, + // they will be sorted and drawn again. + batch.length = 0; + } + + return canvas; + }, + + /** + * Sets the theme via `theme` to either "light" or "dark", + * and updates the internal styling to match. Requires a redraw + * to see the effects. + */ + setTheme: function(theme) { + this.theme = theme = theme || "light"; + this.backgroundColor = getColor("body-background", theme); + this.selectionBackgroundColor = colorUtils.setAlpha( + getColor("selection-background", theme), + 0.25 + ); + this.selectionStripesColor = colorUtils.setAlpha("#fff", 0.1); + this.headerBackgroundColor = getColor("body-background", theme); + this.headerTextColor = getColor("body-color", theme); + this.headerTimelineStrokeColor = colorUtils.setAlpha( + getColor("text-color-alt", theme), + 0.25 + ); + this.alternatingBackgroundColor = colorUtils.setAlpha( + getColor("body-color", theme), + 0.05 + ); + }, +}); + +exports.MarkersOverview = MarkersOverview; diff --git a/devtools/client/performance/modules/widgets/moz.build b/devtools/client/performance/modules/widgets/moz.build new file mode 100644 index 0000000000..d04890425c --- /dev/null +++ b/devtools/client/performance/modules/widgets/moz.build @@ -0,0 +1,11 @@ +# 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/. + +DevToolsModules( + "graphs.js", + "marker-details.js", + "markers-overview.js", + "tree-view.js", +) diff --git a/devtools/client/performance/modules/widgets/tree-view.js b/devtools/client/performance/modules/widgets/tree-view.js new file mode 100644 index 0000000000..79ad8229ff --- /dev/null +++ b/devtools/client/performance/modules/widgets/tree-view.js @@ -0,0 +1,461 @@ +/* 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"; + +/** + * This file contains the tree view, displaying all the samples and frames + * received from the proviler in a tree-like structure. + */ + +const { L10N } = require("devtools/client/performance/modules/global"); +const { extend } = require("devtools/shared/extend"); +const { + AbstractTreeItem, +} = require("resource://devtools/client/shared/widgets/AbstractTreeItem.jsm"); + +const URL_LABEL_TOOLTIP = L10N.getStr("table.url.tooltiptext"); +const VIEW_OPTIMIZATIONS_TOOLTIP = L10N.getStr( + "table.view-optimizations.tooltiptext2" +); + +const CALL_TREE_INDENTATION = 16; // px + +// Used for rendering values in cells +const FORMATTERS = { + TIME: value => + L10N.getFormatStr("table.ms2", L10N.numberWithDecimals(value, 2)), + PERCENT: value => + L10N.getFormatStr("table.percentage3", L10N.numberWithDecimals(value, 2)), + NUMBER: value => value || 0, + BYTESIZE: value => L10N.getFormatStr("table.bytes", value || 0), +}; + +/** + * Definitions for rendering cells. Triads of class name, property name from + * `frame.getInfo()`, and a formatter function. + */ +const CELLS = { + duration: ["duration", "totalDuration", FORMATTERS.TIME], + percentage: ["percentage", "totalPercentage", FORMATTERS.PERCENT], + selfDuration: ["self-duration", "selfDuration", FORMATTERS.TIME], + selfPercentage: ["self-percentage", "selfPercentage", FORMATTERS.PERCENT], + samples: ["samples", "samples", FORMATTERS.NUMBER], + + selfSize: ["self-size", "selfSize", FORMATTERS.BYTESIZE], + selfSizePercentage: [ + "self-size-percentage", + "selfSizePercentage", + FORMATTERS.PERCENT, + ], + selfCount: ["self-count", "selfCount", FORMATTERS.NUMBER], + selfCountPercentage: [ + "self-count-percentage", + "selfCountPercentage", + FORMATTERS.PERCENT, + ], + size: ["size", "totalSize", FORMATTERS.BYTESIZE], + sizePercentage: [ + "size-percentage", + "totalSizePercentage", + FORMATTERS.PERCENT, + ], + count: ["count", "totalCount", FORMATTERS.NUMBER], + countPercentage: [ + "count-percentage", + "totalCountPercentage", + FORMATTERS.PERCENT, + ], +}; +const CELL_TYPES = Object.keys(CELLS); + +const DEFAULT_SORTING_PREDICATE = (frameA, frameB) => { + const dataA = frameA.getDisplayedData(); + const dataB = frameB.getDisplayedData(); + const isAllocations = "totalSize" in dataA; + + if (isAllocations) { + if (this.inverted && dataA.selfSize !== dataB.selfSize) { + return dataA.selfSize < dataB.selfSize ? 1 : -1; + } + return dataA.totalSize < dataB.totalSize ? 1 : -1; + } + + if (this.inverted && dataA.selfPercentage !== dataB.selfPercentage) { + return dataA.selfPercentage < dataB.selfPercentage ? 1 : -1; + } + return dataA.totalPercentage < dataB.totalPercentage ? 1 : -1; +}; + +// depth +const DEFAULT_AUTO_EXPAND_DEPTH = 3; +const DEFAULT_VISIBLE_CELLS = { + duration: true, + percentage: true, + selfDuration: true, + selfPercentage: true, + samples: true, + function: true, + + // allocation columns + count: false, + selfCount: false, + size: false, + selfSize: false, + countPercentage: false, + selfCountPercentage: false, + sizePercentage: false, + selfSizePercentage: false, +}; + +/** + * An item in a call tree view, which looks like this: + * + * Time (ms) | Cost | Calls | Function + * ============================================================================ + * 1,000.00 | 100.00% | | ▼ (root) + * 500.12 | 50.01% | 300 | ▼ foo Categ. 1 + * 300.34 | 30.03% | 1500 | ▼ bar Categ. 2 + * 10.56 | 0.01% | 42 | ▶ call_with_children Categ. 3 + * 90.78 | 0.09% | 25 | call_without_children Categ. 4 + * + * Every instance of a `CallView` represents a row in the call tree. The same + * parent node is used for all rows. + * + * @param CallView caller + * The CallView considered the "caller" frame. This newly created + * instance will be represent the "callee". Should be null for root nodes. + * @param ThreadNode | FrameNode frame + * Details about this function, like { samples, duration, calls } etc. + * @param number level [optional] + * The indentation level in the call tree. The root node is at level 0. + * @param boolean hidden [optional] + * Whether this node should be hidden and not contribute to depth/level + * calculations. Defaults to false. + * @param boolean inverted [optional] + * Whether the call tree has been inverted (bottom up, rather than + * top-down). Defaults to false. + * @param function sortingPredicate [optional] + * The predicate used to sort the tree items when created. Defaults to + * the caller's `sortingPredicate` if a caller exists, otherwise defaults + * to DEFAULT_SORTING_PREDICATE. The two passed arguments are FrameNodes. + * @param number autoExpandDepth [optional] + * The depth to which the tree should automatically expand. Defualts to + * the caller's `autoExpandDepth` if a caller exists, otherwise defaults + * to DEFAULT_AUTO_EXPAND_DEPTH. + * @param object visibleCells + * An object specifying which cells are visible in the tree. Defaults to + * the caller's `visibleCells` if a caller exists, otherwise defaults + * to DEFAULT_VISIBLE_CELLS. + * @param boolean showOptimizationHint [optional] + * Whether or not to show an icon indicating if the frame has optimization + * data. + */ +function CallView({ + caller, + frame, + level, + hidden, + inverted, + sortingPredicate, + autoExpandDepth, + visibleCells, + showOptimizationHint, +}) { + AbstractTreeItem.call(this, { + parent: caller, + level: level | (0 - (hidden ? 1 : 0)), + }); + + if (sortingPredicate != null) { + this.sortingPredicate = sortingPredicate; + } else if (caller) { + this.sortingPredicate = caller.sortingPredicate; + } else { + this.sortingPredicate = DEFAULT_SORTING_PREDICATE; + } + + if (autoExpandDepth != null) { + this.autoExpandDepth = autoExpandDepth; + } else if (caller) { + this.autoExpandDepth = caller.autoExpandDepth; + } else { + this.autoExpandDepth = DEFAULT_AUTO_EXPAND_DEPTH; + } + + if (visibleCells != null) { + this.visibleCells = visibleCells; + } else if (caller) { + this.visibleCells = caller.visibleCells; + } else { + this.visibleCells = Object.create(DEFAULT_VISIBLE_CELLS); + } + + this.caller = caller; + this.frame = frame; + this.hidden = hidden; + this.inverted = inverted; + this.showOptimizationHint = showOptimizationHint; + + this._onUrlClick = this._onUrlClick.bind(this); +} + +CallView.prototype = extend(AbstractTreeItem.prototype, { + /** + * Creates the view for this tree node. + * @param Node document + * @param Node arrowNode + * @return Node + */ + _displaySelf: function(document, arrowNode) { + const frameInfo = this.getDisplayedData(); + const cells = []; + + for (const type of CELL_TYPES) { + if (this.visibleCells[type]) { + // Inline for speed, but pass in the formatted value via + // cell definition, as well as the element type. + cells.push( + this._createCell( + document, + CELLS[type][2](frameInfo[CELLS[type][1]]), + CELLS[type][0] + ) + ); + } + } + + if (this.visibleCells.function) { + cells.push( + this._createFunctionCell( + document, + arrowNode, + frameInfo.name, + frameInfo, + this.level + ) + ); + } + + const targetNode = document.createXULElement("hbox"); + targetNode.className = "call-tree-item"; + targetNode.setAttribute( + "origin", + frameInfo.isContent ? "content" : "chrome" + ); + targetNode.setAttribute("category", frameInfo.categoryData.abbrev || ""); + targetNode.setAttribute("tooltiptext", frameInfo.tooltiptext); + + if (this.hidden) { + targetNode.style.display = "none"; + } + + for (let i = 0; i < cells.length; i++) { + targetNode.appendChild(cells[i]); + } + + return targetNode; + }, + + /** + * Populates this node in the call tree with the corresponding "callees". + * These are defined in the `frame` data source for this call view. + * @param array:AbstractTreeItem children + */ + _populateSelf: function(children) { + const newLevel = this.level + 1; + + for (const newFrame of this.frame.calls) { + children.push( + new CallView({ + caller: this, + frame: newFrame, + level: newLevel, + inverted: this.inverted, + }) + ); + } + + // Sort the "callees" asc. by samples, before inserting them in the tree, + // if no other sorting predicate was specified on this on the root item. + children.sort(this.sortingPredicate.bind(this)); + }, + + /** + * Functions creating each cell in this call view. + * Invoked by `_displaySelf`. + */ + _createCell: function(doc, value, type) { + const cell = doc.createXULElement("description"); + cell.className = "plain call-tree-cell"; + cell.setAttribute("type", type); + cell.setAttribute("crop", "end"); + // Add a tabulation to the cell text in case it's is selected and copied. + cell.textContent = value + "\t"; + return cell; + }, + + _createFunctionCell: function( + doc, + arrowNode, + frameName, + frameInfo, + frameLevel + ) { + const cell = doc.createXULElement("hbox"); + cell.className = "call-tree-cell"; + cell.style.marginInlineStart = frameLevel * CALL_TREE_INDENTATION + "px"; + cell.setAttribute("type", "function"); + cell.appendChild(arrowNode); + + // Render optimization hint if this frame has opt data. + if ( + this.root.showOptimizationHint && + frameInfo.hasOptimizations && + !frameInfo.isMetaCategory + ) { + const icon = doc.createXULElement("description"); + icon.setAttribute("tooltiptext", VIEW_OPTIMIZATIONS_TOOLTIP); + icon.className = "opt-icon"; + cell.appendChild(icon); + } + + // Don't render a name label node if there's no function name. A different + // location label node will be rendered instead. + if (frameName) { + const nameNode = doc.createXULElement("description"); + nameNode.className = "plain call-tree-name"; + nameNode.textContent = frameName; + cell.appendChild(nameNode); + } + + // Don't render detailed labels for meta category frames + if (!frameInfo.isMetaCategory) { + this._appendFunctionDetailsCells(doc, cell, frameInfo); + } + + // Don't render an expando-arrow for leaf nodes. + const hasDescendants = Object.keys(this.frame.calls).length > 0; + if (!hasDescendants) { + arrowNode.setAttribute("invisible", ""); + } + + // Add a line break to the last description of the row in case it's selected + // and copied. + const lastDescription = cell.querySelector("description:last-of-type"); + lastDescription.textContent = lastDescription.textContent + "\n"; + + // Add spaces as frameLevel indicators in case the row is selected and + // copied. These spaces won't be displayed in the cell content. + const firstDescription = cell.querySelector("description:first-of-type"); + const levelIndicator = frameLevel > 0 ? " ".repeat(frameLevel) : ""; + firstDescription.textContent = + levelIndicator + firstDescription.textContent; + + return cell; + }, + + _appendFunctionDetailsCells: function(doc, cell, frameInfo) { + if (frameInfo.fileName) { + const urlNode = doc.createXULElement("description"); + urlNode.className = "plain call-tree-url"; + urlNode.textContent = frameInfo.fileName; + urlNode.setAttribute( + "tooltiptext", + URL_LABEL_TOOLTIP + " → " + frameInfo.url + ); + urlNode.addEventListener("mousedown", this._onUrlClick); + cell.appendChild(urlNode); + } + + if (frameInfo.line) { + const lineNode = doc.createXULElement("description"); + lineNode.className = "plain call-tree-line"; + lineNode.textContent = ":" + frameInfo.line; + cell.appendChild(lineNode); + } + + if (frameInfo.column) { + const columnNode = doc.createXULElement("description"); + columnNode.className = "plain call-tree-column"; + columnNode.textContent = ":" + frameInfo.column; + cell.appendChild(columnNode); + } + + if (frameInfo.host) { + const hostNode = doc.createXULElement("description"); + hostNode.className = "plain call-tree-host"; + hostNode.textContent = frameInfo.host; + cell.appendChild(hostNode); + } + + if (frameInfo.categoryData.label) { + const categoryNode = doc.createXULElement("description"); + categoryNode.className = "plain call-tree-category"; + categoryNode.style.color = frameInfo.categoryData.color; + categoryNode.textContent = frameInfo.categoryData.label; + cell.appendChild(categoryNode); + } + }, + + /** + * Gets the data displayed about this tree item, based on the FrameNode + * model associated with this view. + * + * @return object + */ + getDisplayedData: function() { + if (this._cachedDisplayedData) { + return this._cachedDisplayedData; + } + + this._cachedDisplayedData = this.frame.getInfo({ + root: this.root.frame, + allocations: this.visibleCells.count || this.visibleCells.selfCount, + }); + + return this._cachedDisplayedData; + + /** + * When inverting call tree, the costs and times are dependent on position + * in the tree. We must only count leaf nodes with self cost, and total costs + * dependent on how many times the leaf node was found with a full stack path. + * + * Total | Self | Calls | Function + * ============================================================================ + * 100% | 100% | 100 | ▼ C + * 50% | 0% | 50 | ▼ B + * 50% | 0% | 50 | ▼ A + * 50% | 0% | 50 | ▼ B + * + * Every instance of a `CallView` represents a row in the call tree. The same + * container node is used for all rows. + */ + }, + + /** + * Toggles the category information hidden or visible. + * @param boolean visible + */ + toggleCategories: function(visible) { + if (!visible) { + this.container.setAttribute("categories-hidden", ""); + } else { + this.container.removeAttribute("categories-hidden"); + } + }, + + /** + * Handler for the "click" event on the url node of this call view. + */ + _onUrlClick: function(e) { + e.preventDefault(); + e.stopPropagation(); + // Only emit for left click events + if (e.button === 0) { + this.root.emit("link", this); + } + }, +}); + +exports.CallView = CallView; diff --git a/devtools/client/performance/moz.build b/devtools/client/performance/moz.build new file mode 100644 index 0000000000..bb8d3ac342 --- /dev/null +++ b/devtools/client/performance/moz.build @@ -0,0 +1,25 @@ +# 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/. + +DIRS += [ + "components", + "modules", + "test", + "views", +] + +DevToolsModules( + "events.js", + "initializer.js", + "panel.js", + "performance-controller.js", + "performance-view.js", +) + +BROWSER_CHROME_MANIFESTS += ["test/browser.ini"] +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.ini"] + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Performance Tools (Profiler/Timeline)") diff --git a/devtools/client/performance/panel.js b/devtools/client/performance/panel.js new file mode 100644 index 0000000000..28fef793f0 --- /dev/null +++ b/devtools/client/performance/panel.js @@ -0,0 +1,162 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); + +function PerformancePanel(iframeWindow, toolbox) { + this.panelWin = iframeWindow; + this.toolbox = toolbox; + this._targetAvailablePromise = Promise.resolve(); + + this._onTargetAvailable = this._onTargetAvailable.bind(this); + + EventEmitter.decorate(this); +} + +exports.PerformancePanel = PerformancePanel; + +PerformancePanel.prototype = { + /** + * Open is effectively an asynchronous constructor. + * + * @return object + * A promise that is resolved when the Performance tool + * completes opening. + */ + async open() { + if (this._opening) { + return this._opening; + } + + this._checkRecordingStatus = this._checkRecordingStatus.bind(this); + + const { PerformanceController, EVENTS } = this.panelWin; + PerformanceController.on( + EVENTS.RECORDING_ADDED, + this._checkRecordingStatus + ); + PerformanceController.on( + EVENTS.RECORDING_STATE_CHANGE, + this._checkRecordingStatus + ); + + // In case that the target is switched across process, the corresponding front also + // will be changed. In order to detect that, watch the change. + // Also, we wait for `watchTargets` to end. Indeed the function `_onTargetAvailable + // will be called synchronously with current target as a parameter by + // the `watchTargets` function. + // So this `await` waits for initialization with current target, happening + // in `_onTargetAvailable`. + await this.toolbox.targetList.watchTargets( + [this.toolbox.targetList.TYPES.FRAME], + this._onTargetAvailable + ); + + // Fire this once incase we have an in-progress recording (console profile) + // that caused this start up, and no state change yet, so we can highlight the + // tab if we need. + this._checkRecordingStatus(); + + this.isReady = true; + this.emit("ready"); + + this._opening = new Promise(resolve => { + resolve(this); + }); + return this._opening; + }, + + // DevToolPanel API + + get target() { + return this.toolbox.target; + }, + + async destroy() { + // Make sure this panel is not already destroyed. + if (this._destroyed) { + return; + } + + const { PerformanceController, PerformanceView, EVENTS } = this.panelWin; + PerformanceController.off( + EVENTS.RECORDING_ADDED, + this._checkRecordingStatus + ); + PerformanceController.off( + EVENTS.RECORDING_STATE_CHANGE, + this._checkRecordingStatus + ); + + this.toolbox.targetList.unwatchTargets( + [this.toolbox.targetList.TYPES.FRAME], + this._onTargetAvailable + ); + await PerformanceController.destroy(); + await PerformanceView.destroy(); + PerformanceController.disableFrontEventListeners(); + + this.emit("destroyed"); + this._destroyed = true; + }, + + _checkRecordingStatus: function() { + if (this.panelWin.PerformanceController.isRecording()) { + this.toolbox.highlightTool("performance"); + } else { + this.toolbox.unhighlightTool("performance"); + } + }, + + /** + * This function executes actual logic for the target-switching. + * + * @param {TargetFront} - targetFront + * As we are watching only FRAME type for this panel, + * the target should be a instance of BrowsingContextTarget. + */ + async _handleTargetAvailable({ targetFront }) { + if (targetFront.isTopLevel) { + const { PerformanceController, PerformanceView } = this.panelWin; + const performanceFront = await targetFront.getFront("performance"); + + if (!this._isPanelInitialized) { + await PerformanceController.initialize( + this.toolbox, + targetFront, + performanceFront + ); + await PerformanceView.initialize(); + PerformanceController.enableFrontEventListeners(); + this._isPanelInitialized = true; + } else { + const isRecording = PerformanceController.isRecording(); + if (isRecording) { + await PerformanceController.stopRecording(); + } + + PerformanceView.resetBufferStatus(); + PerformanceController.updateFronts(targetFront, performanceFront); + + if (isRecording) { + await PerformanceController.startRecording(); + } + } + } + }, + + /** + * This function is called for every target is available. + */ + _onTargetAvailable(parameters) { + // As this function is called asynchronous, while previous processing, this might be + // called. Thus, we wait until finishing previous one before starting next. + this._targetAvailablePromise = this._targetAvailablePromise.then(() => + this._handleTargetAvailable(parameters) + ); + + return this._targetAvailablePromise; + }, +}; diff --git a/devtools/client/performance/performance-controller.js b/devtools/client/performance/performance-controller.js new file mode 100644 index 0000000000..1fb7682661 --- /dev/null +++ b/devtools/client/performance/performance-controller.js @@ -0,0 +1,542 @@ +/* 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/. */ +/* globals $ */ +"use strict"; + +const { PrefObserver } = require("devtools/client/shared/prefs"); + +// Events emitted by various objects in the panel. +const EVENTS = require("devtools/client/performance/events"); + +const Services = require("Services"); +const EventEmitter = require("devtools/shared/event-emitter"); +const flags = require("devtools/shared/flags"); + +// Logic modules +const { + PerformanceTelemetry, +} = require("devtools/client/performance/modules/logic/telemetry"); +const { + PerformanceView, +} = require("devtools/client/performance/performance-view"); +const { DetailsView } = require("devtools/client/performance/views/details"); +const { + RecordingsView, +} = require("devtools/client/performance/views/recordings"); +const { ToolbarView } = require("devtools/client/performance/views/toolbar"); + +/** + * Functions handling target-related lifetime events and + * UI interaction. + */ +const PerformanceController = { + _recordings: [], + _currentRecording: null, + + /** + * Listen for events emitted by the current tab target and + * main UI events. + */ + async initialize(toolbox, targetFront, performanceFront) { + this.toolbox = toolbox; + this.target = targetFront; + this.front = performanceFront; + + this._telemetry = new PerformanceTelemetry(this); + this.startRecording = this.startRecording.bind(this); + this.stopRecording = this.stopRecording.bind(this); + this.importRecording = this.importRecording.bind(this); + this.exportRecording = this.exportRecording.bind(this); + this.clearRecordings = this.clearRecordings.bind(this); + this._onRecordingSelectFromView = this._onRecordingSelectFromView.bind( + this + ); + this._onPrefChanged = this._onPrefChanged.bind(this); + this._onThemeChanged = this._onThemeChanged.bind(this); + this._onDetailsViewSelected = this._onDetailsViewSelected.bind(this); + this._onProfilerStatus = this._onProfilerStatus.bind(this); + this._onRecordingStarted = this._emitRecordingStateChange.bind( + this, + "recording-started" + ); + this._onRecordingStopping = this._emitRecordingStateChange.bind( + this, + "recording-stopping" + ); + this._onRecordingStopped = this._emitRecordingStateChange.bind( + this, + "recording-stopped" + ); + + // Store data regarding if e10s is enabled. + this._e10s = Services.appinfo.browserTabsRemoteAutostart; + this._setMultiprocessAttributes(); + + this._prefs = require("devtools/client/performance/modules/global").PREFS; + this._prefs.registerObserver(); + this._prefs.on("pref-changed", this._onPrefChanged); + + ToolbarView.on(EVENTS.UI_PREF_CHANGED, this._onPrefChanged); + PerformanceView.on(EVENTS.UI_START_RECORDING, this.startRecording); + PerformanceView.on(EVENTS.UI_STOP_RECORDING, this.stopRecording); + PerformanceView.on(EVENTS.UI_IMPORT_RECORDING, this.importRecording); + PerformanceView.on(EVENTS.UI_CLEAR_RECORDINGS, this.clearRecordings); + RecordingsView.on(EVENTS.UI_EXPORT_RECORDING, this.exportRecording); + RecordingsView.on( + EVENTS.UI_RECORDING_SELECTED, + this._onRecordingSelectFromView + ); + DetailsView.on( + EVENTS.UI_DETAILS_VIEW_SELECTED, + this._onDetailsViewSelected + ); + + this._prefObserver = new PrefObserver("devtools."); + this._prefObserver.on("devtools.theme", this._onThemeChanged); + }, + + /** + * Remove events handled by the PerformanceController + */ + destroy: function() { + this._prefs.off("pref-changed", this._onPrefChanged); + this._prefs.unregisterObserver(); + + ToolbarView.off(EVENTS.UI_PREF_CHANGED, this._onPrefChanged); + PerformanceView.off(EVENTS.UI_START_RECORDING, this.startRecording); + PerformanceView.off(EVENTS.UI_STOP_RECORDING, this.stopRecording); + PerformanceView.off(EVENTS.UI_IMPORT_RECORDING, this.importRecording); + PerformanceView.off(EVENTS.UI_CLEAR_RECORDINGS, this.clearRecordings); + RecordingsView.off(EVENTS.UI_EXPORT_RECORDING, this.exportRecording); + RecordingsView.off( + EVENTS.UI_RECORDING_SELECTED, + this._onRecordingSelectFromView + ); + DetailsView.off( + EVENTS.UI_DETAILS_VIEW_SELECTED, + this._onDetailsViewSelected + ); + + this._prefObserver.off("devtools.theme", this._onThemeChanged); + this._prefObserver.destroy(); + + this._telemetry.destroy(); + }, + + updateFronts(targetFront, performanceFront) { + this.target = targetFront; + this.front = performanceFront; + this.enableFrontEventListeners(); + }, + + /** + * Enables front event listeners. + * + * The rationale behind this is given by the async intialization of all the + * frontend components. Even though the panel is considered "open" only after + * both the controller and the view are created, and even though their + * initialization is sequential (controller, then view), the controller might + * start handling backend events before the view finishes if the event + * listeners are added too soon. + */ + enableFrontEventListeners: function() { + this.front.on("profiler-status", this._onProfilerStatus); + this.front.on("recording-started", this._onRecordingStarted); + this.front.on("recording-stopping", this._onRecordingStopping); + this.front.on("recording-stopped", this._onRecordingStopped); + }, + + /** + * Disables front event listeners. + */ + disableFrontEventListeners: function() { + this.front.off("profiler-status", this._onProfilerStatus); + this.front.off("recording-started", this._onRecordingStarted); + this.front.off("recording-stopping", this._onRecordingStopping); + this.front.off("recording-stopped", this._onRecordingStopped); + }, + + /** + * Returns the current devtools theme. + */ + getTheme: function() { + return Services.prefs.getCharPref("devtools.theme"); + }, + + /** + * Get a boolean preference setting from `prefName` via the underlying + * OptionsView in the ToolbarView. This preference is guaranteed to be + * displayed in the UI. + * + * @param string prefName + * @return boolean + */ + getOption: function(prefName) { + return ToolbarView.optionsView.getPref(prefName); + }, + + /** + * Get a preference setting from `prefName`. This preference can be of + * any type and might not be displayed in the UI. + * + * @param string prefName + * @return any + */ + getPref: function(prefName) { + return this._prefs[prefName]; + }, + + /** + * Set a preference setting from `prefName`. This preference can be of + * any type and might not be displayed in the UI. + * + * @param string prefName + * @param any prefValue + */ + setPref: function(prefName, prefValue) { + this._prefs[prefName] = prefValue; + }, + + /** + * Checks whether or not a new recording is supported by the PerformanceFront. + * @return Promise:boolean + */ + async canCurrentlyRecord() { + const hasActor = await this.target.hasActor("performance"); + if (!hasActor) { + return true; + } + return (await this.front.canCurrentlyRecord()).success; + }, + + /** + * Starts recording with the PerformanceFront. + */ + async startRecording() { + const options = { + withMarkers: true, + withTicks: this.getOption("enable-framerate"), + withMemory: this.getOption("enable-memory"), + withFrames: true, + withGCEvents: true, + withAllocations: this.getOption("enable-allocations"), + allocationsSampleProbability: this.getPref("memory-sample-probability"), + allocationsMaxLogLength: this.getPref("memory-max-log-length"), + bufferSize: this.getPref("profiler-buffer-size"), + sampleFrequency: this.getPref("profiler-sample-frequency"), + }; + + const recordingStarted = await this.front.startRecording(options); + + // In some cases, like when the target has a private browsing tab, + // recording is not currently supported because of the profiler module. + // Present a notification in this case alerting the user of this issue. + if (!recordingStarted) { + this.emit(EVENTS.BACKEND_FAILED_AFTER_RECORDING_START); + PerformanceView.setState("unavailable"); + } else { + this.emit(EVENTS.BACKEND_READY_AFTER_RECORDING_START); + } + }, + + /** + * Stops recording with the PerformanceFront. + */ + async stopRecording() { + const recording = this.getLatestManualRecording(); + + if (!this.front.isDestroyed()) { + await this.front.stopRecording(recording); + } else { + // As the front was destroyed, we do stop sequence manually without the actor. + recording._recording = false; + recording._completed = true; + await this._onRecordingStopped(recording); + } + + this.emit(EVENTS.BACKEND_READY_AFTER_RECORDING_STOP); + }, + + /** + * Saves the given recording to a file. Emits `EVENTS.RECORDING_EXPORTED` + * when the file was saved. + * + * @param PerformanceRecording recording + * The model that holds the recording data. + * @param nsIFile file + * The file to stream the data into. + */ + async exportRecording(recording, file) { + await recording.exportRecording(file); + this.emit(EVENTS.RECORDING_EXPORTED, recording, file); + }, + + /** + * Clears all completed recordings from the list as well as the current non-console + * recording. Emits `EVENTS.RECORDING_DELETED` when complete so other components can + * clean up. + */ + async clearRecordings() { + for (let i = this._recordings.length - 1; i >= 0; i--) { + const model = this._recordings[i]; + if (!model.isConsole() && model.isRecording()) { + await this.stopRecording(); + } + // If last recording is not recording, but finalizing itself, + // wait for that to finish + if (!model.isRecording() && !model.isCompleted()) { + await this.waitForStateChangeOnRecording(model, "recording-stopped"); + } + // If recording is completed, + // clean it up from UI and remove it from the _recordings array. + if (model.isCompleted()) { + this.emit(EVENTS.RECORDING_DELETED, model); + this._recordings.splice(i, 1); + } + } + if (this._recordings.length > 0) { + if (!this._recordings.includes(this.getCurrentRecording())) { + this.setCurrentRecording(this._recordings[0]); + } + } else { + this.setCurrentRecording(null); + } + }, + + /** + * Loads a recording from a file, adding it to the recordings list. Emits + * `EVENTS.RECORDING_IMPORTED` when the file was loaded. + * + * @param nsIFile file + * The file to import the data from. + */ + async importRecording(file) { + const recording = await this.front.importRecording(file); + this._addRecordingIfUnknown(recording); + + this.emit(EVENTS.RECORDING_IMPORTED, recording); + }, + + /** + * Sets the currently active PerformanceRecording. Should rarely be called directly, + * as RecordingsView handles this when manually selected a recording item. Exceptions + * are when clearing the view. + * @param PerformanceRecording recording + */ + setCurrentRecording: function(recording) { + if (this._currentRecording !== recording) { + this._currentRecording = recording; + this.emit(EVENTS.RECORDING_SELECTED, recording); + } + }, + + /** + * Gets the currently active PerformanceRecording. + * @return PerformanceRecording + */ + getCurrentRecording: function() { + return this._currentRecording; + }, + + /** + * Get most recently added recording that was triggered manually (via UI). + * @return PerformanceRecording + */ + getLatestManualRecording: function() { + for (let i = this._recordings.length - 1; i >= 0; i--) { + const model = this._recordings[i]; + if (!model.isConsole() && !model.isImported()) { + return this._recordings[i]; + } + } + return null; + }, + + /** + * Fired from RecordingsView, we listen on the PerformanceController so we can + * set it here and re-emit on the controller, where all views can listen. + */ + _onRecordingSelectFromView: function(recording) { + this.setCurrentRecording(recording); + }, + + /** + * Fired when the ToolbarView fires a PREF_CHANGED event. + * with the value. + */ + _onPrefChanged: function(prefName, prefValue) { + this.emit(EVENTS.PREF_CHANGED, prefName, prefValue); + }, + + /* + * Called when the developer tools theme changes. + */ + _onThemeChanged: function() { + const newValue = Services.prefs.getCharPref("devtools.theme"); + this.emit(EVENTS.THEME_CHANGED, newValue); + }, + + _onProfilerStatus: function(status) { + this.emit(EVENTS.RECORDING_PROFILER_STATUS_UPDATE, status); + }, + + _emitRecordingStateChange(eventName, recordingModel) { + this._addRecordingIfUnknown(recordingModel); + this.emit(EVENTS.RECORDING_STATE_CHANGE, eventName, recordingModel); + }, + + /** + * Stores a recording internally. + * + * @param {PerformanceRecordingFront} recording + */ + _addRecordingIfUnknown: function(recording) { + if (!this._recordings.includes(recording)) { + this._recordings.push(recording); + this.emit(EVENTS.RECORDING_ADDED, recording); + } + }, + + /** + * Takes a recording and returns a value between 0 and 1 indicating how much + * of the buffer is used. + */ + getBufferUsageForRecording: function(recording) { + return this.front.getBufferUsageForRecording(recording); + }, + + /** + * Returns a boolean indicating if any recordings are currently in progress or not. + */ + isRecording: function() { + return this._recordings.some(r => r.isRecording()); + }, + + /** + * Returns the internal store of recording models. + */ + getRecordings: function() { + return this._recordings; + }, + + /** + * Returns traits from the front. + */ + getTraits: function() { + return this.front.traits; + }, + + viewSourceInDebugger(url, line, column) { + // Currently, the line and column values are strings, so we have to convert + // them to numbers before passing them on to the toolbox. + return this.toolbox.viewSourceInDebugger(url, +line, +column); + }, + + /** + * Utility method taking a string or an array of strings of feature names (like + * "withAllocations" or "withMarkers"), and returns whether or not the current + * recording supports that feature, based off of UI preferences and server support. + * + * @option {Array<string>|string} features + * A string or array of strings indicating what configuration is needed on the + * recording model, like `withTicks`, or `withMemory`. + * + * @return boolean + */ + isFeatureSupported: function(features) { + if (!features) { + return true; + } + + const recording = this.getCurrentRecording(); + if (!recording) { + return false; + } + + const config = recording.getConfiguration(); + return [].concat(features).every(f => config[f]); + }, + + /** + * Takes an array of PerformanceRecordingFronts and adds them to the internal + * store of the UI. Used by the toolbox to lazily seed recordings that + * were observed before the panel was loaded in the scenario where `console.profile()` + * is used before the tool is loaded. + * + * @param {Array<PerformanceRecordingFront>} recordings + */ + populateWithRecordings: function(recordings = []) { + for (const recording of recordings) { + PerformanceController._addRecordingIfUnknown(recording); + } + this.emit(EVENTS.RECORDINGS_SEEDED); + }, + + /** + * Returns an object with `supported` and `enabled` properties indicating + * whether or not the platform is capable of turning on e10s and whether or not + * it's already enabled, respectively. + * + * @return {object} + */ + getMultiprocessStatus: function() { + // If testing, set enabled to true so we have realtime rendering tests + // in non-e10s. This function is overridden wholesale in tests + // when we want to test multiprocess support + // specifically. + if (flags.testing) { + return { enabled: true }; + } + // This is only checked on tool startup -- requires a restart if + // e10s subsequently enabled. + const enabled = this._e10s; + return { enabled }; + }, + + /** + * Takes a PerformanceRecording and a state, and waits for + * the event to be emitted from the front for that recording. + * + * @param {PerformanceRecordingFront} recording + * @param {string} expectedState + * @return {Promise} + */ + async waitForStateChangeOnRecording(recording, expectedState) { + await new Promise(resolve => { + this.on(EVENTS.RECORDING_STATE_CHANGE, function handler(state, model) { + if (state === expectedState && model === recording) { + this.off(EVENTS.RECORDING_STATE_CHANGE, handler); + resolve(); + } + }); + }); + }, + + /** + * Called on init, sets an `e10s` attribute on the main view container with + * "disabled" if e10s is possible on the platform and just not on, or "unsupported" + * if e10s is not possible on the platform. If e10s is on, no attribute is set. + */ + _setMultiprocessAttributes: function() { + const { enabled } = this.getMultiprocessStatus(); + if (!enabled) { + $("#performance-view").setAttribute("e10s", "disabled"); + } + }, + + /** + * Pipes EVENTS.UI_DETAILS_VIEW_SELECTED to the PerformanceController. + */ + _onDetailsViewSelected: function(...data) { + this.emit(EVENTS.UI_DETAILS_VIEW_SELECTED, ...data); + }, + + toString: () => "[object PerformanceController]", +}; + +/** + * Convenient way of emitting events from the controller. + */ +EventEmitter.decorate(PerformanceController); +exports.PerformanceController = PerformanceController; diff --git a/devtools/client/performance/performance-view.js b/devtools/client/performance/performance-view.js new file mode 100644 index 0000000000..98775327b6 --- /dev/null +++ b/devtools/client/performance/performance-view.js @@ -0,0 +1,490 @@ +/* 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/. */ +/* globals $, $$, PerformanceController */ +"use strict"; + +const EventEmitter = require("devtools/shared/event-emitter"); + +const React = require("devtools/client/shared/vendor/react"); +const ReactDOM = require("devtools/client/shared/vendor/react-dom"); + +const RecordingControls = React.createFactory( + require("devtools/client/performance/components/RecordingControls") +); +const RecordingButton = React.createFactory( + require("devtools/client/performance/components/RecordingButton") +); + +const EVENTS = require("devtools/client/performance/events"); +const PerformanceUtils = require("devtools/client/performance/modules/utils"); +const { DetailsView } = require("devtools/client/performance/views/details"); +const { OverviewView } = require("devtools/client/performance/views/overview"); +const { + RecordingsView, +} = require("devtools/client/performance/views/recordings"); +const { ToolbarView } = require("devtools/client/performance/views/toolbar"); + +const { L10N } = require("devtools/client/performance/modules/global"); +/** + * Master view handler for the performance tool. + */ +var PerformanceView = { + _state: null, + + // Set to true if the front emits a "buffer-status" event, indicating + // that the server has support for determining buffer status. + _bufferStatusSupported: false, + + // Mapping of state to selectors for different properties and their values, + // from the main profiler view. Used in `PerformanceView.setState()` + states: { + unavailable: [ + { + sel: "#performance-view", + opt: "selectedPanel", + val: () => $("#unavailable-notice"), + }, + { + sel: "#performance-view-content", + opt: "hidden", + val: () => true, + }, + ], + empty: [ + { + sel: "#performance-view", + opt: "selectedPanel", + val: () => $("#empty-notice"), + }, + { + sel: "#performance-view-content", + opt: "hidden", + val: () => true, + }, + ], + recording: [ + { + sel: "#performance-view", + opt: "selectedPanel", + val: () => $("#performance-view-content"), + }, + { + sel: "#performance-view-content", + opt: "hidden", + val: () => false, + }, + { + sel: "#details-pane-container", + opt: "selectedPanel", + val: () => $("#recording-notice"), + }, + ], + "console-recording": [ + { + sel: "#performance-view", + opt: "selectedPanel", + val: () => $("#performance-view-content"), + }, + { + sel: "#performance-view-content", + opt: "hidden", + val: () => false, + }, + { + sel: "#details-pane-container", + opt: "selectedPanel", + val: () => $("#console-recording-notice"), + }, + ], + recorded: [ + { + sel: "#performance-view", + opt: "selectedPanel", + val: () => $("#performance-view-content"), + }, + { + sel: "#performance-view-content", + opt: "hidden", + val: () => false, + }, + { + sel: "#details-pane-container", + opt: "selectedPanel", + val: () => $("#details-pane"), + }, + ], + loading: [ + { + sel: "#performance-view", + opt: "selectedPanel", + val: () => $("#performance-view-content"), + }, + { + sel: "#performance-view-content", + opt: "hidden", + val: () => false, + }, + { + sel: "#details-pane-container", + opt: "selectedPanel", + val: () => $("#loading-notice"), + }, + ], + }, + + /** + * Sets up the view with event binding and main subviews. + */ + async initialize() { + this._onRecordButtonClick = this._onRecordButtonClick.bind(this); + this._onImportButtonClick = this._onImportButtonClick.bind(this); + this._onClearButtonClick = this._onClearButtonClick.bind(this); + this._onRecordingSelected = this._onRecordingSelected.bind(this); + this._onProfilerStatusUpdated = this._onProfilerStatusUpdated.bind(this); + this._onRecordingStateChange = this._onRecordingStateChange.bind(this); + this._onNewRecordingFailed = this._onNewRecordingFailed.bind(this); + + // Bind to controller events to unlock the record button + PerformanceController.on( + EVENTS.RECORDING_SELECTED, + this._onRecordingSelected + ); + PerformanceController.on( + EVENTS.RECORDING_PROFILER_STATUS_UPDATE, + this._onProfilerStatusUpdated + ); + PerformanceController.on( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStateChange + ); + PerformanceController.on( + EVENTS.RECORDING_ADDED, + this._onRecordingStateChange + ); + PerformanceController.on( + EVENTS.BACKEND_FAILED_AFTER_RECORDING_START, + this._onNewRecordingFailed + ); + + if (await PerformanceController.canCurrentlyRecord()) { + this.setState("empty"); + } else { + this.setState("unavailable"); + } + + // Initialize the ToolbarView first, because other views may need access + // to the OptionsView via the controller, to read prefs. + await ToolbarView.initialize(); + await RecordingsView.initialize(); + await OverviewView.initialize(); + await DetailsView.initialize(); + + // DE-XUL: Begin migrating the toolbar to React. Temporarily hold state here. + this._recordingControlsState = { + onRecordButtonClick: this._onRecordButtonClick, + onImportButtonClick: this._onImportButtonClick, + onClearButtonClick: this._onClearButtonClick, + isRecording: false, + isDisabled: false, + }; + // Mount to an HTML element. + const { createHtmlMount } = PerformanceUtils; + this._recordingControlsMount = createHtmlMount( + $("#recording-controls-mount") + ); + this._recordingButtonsMounts = Array.from( + $$(".recording-button-mount") + ).map(createHtmlMount); + + this._renderRecordingControls(); + }, + + /** + * DE-XUL: Render the recording controls and buttons using React. + */ + _renderRecordingControls: function() { + ReactDOM.render( + RecordingControls(this._recordingControlsState), + this._recordingControlsMount + ); + for (const button of this._recordingButtonsMounts) { + ReactDOM.render(RecordingButton(this._recordingControlsState), button); + } + }, + + /** + * Unbinds events and destroys subviews. + */ + async destroy() { + PerformanceController.off( + EVENTS.RECORDING_SELECTED, + this._onRecordingSelected + ); + PerformanceController.off( + EVENTS.RECORDING_PROFILER_STATUS_UPDATE, + this._onProfilerStatusUpdated + ); + PerformanceController.off( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStateChange + ); + PerformanceController.off( + EVENTS.RECORDING_ADDED, + this._onRecordingStateChange + ); + PerformanceController.off( + EVENTS.BACKEND_FAILED_AFTER_RECORDING_START, + this._onNewRecordingFailed + ); + + await ToolbarView.destroy(); + await RecordingsView.destroy(); + await OverviewView.destroy(); + await DetailsView.destroy(); + }, + + /** + * Sets the state of the profiler view. Possible options are "unavailable", + * "empty", "recording", "console-recording", "recorded". + */ + setState: function(state) { + // Make sure that the focus isn't captured on a hidden iframe. This fixes a + // XUL bug where shortcuts stop working. + const iframes = window.document.querySelectorAll("iframe"); + for (const iframe of iframes) { + iframe.blur(); + } + window.focus(); + + const viewConfig = this.states[state]; + if (!viewConfig) { + throw new Error(`Invalid state for PerformanceView: ${state}`); + } + for (const { sel, opt, val } of viewConfig) { + for (const el of $$(sel)) { + el[opt] = val(); + } + } + + this._state = state; + + if (state === "console-recording") { + const recording = PerformanceController.getCurrentRecording(); + let label = recording.getLabel() || ""; + + // Wrap the label in quotes if it exists for the commands. + label = label ? `"${label}"` : ""; + + const startCommand = $( + ".console-profile-recording-notice .console-profile-command" + ); + const stopCommand = $( + ".console-profile-stop-notice .console-profile-command" + ); + + startCommand.value = `console.profile(${label})`; + stopCommand.value = `console.profileEnd(${label})`; + } + + this.updateBufferStatus(); + this.emit(EVENTS.UI_STATE_CHANGED, state); + }, + + /** + * Returns the state of the PerformanceView. + */ + getState: function() { + return this._state; + }, + + /** + * Reset the displayed buffer status. + * Called for every target-switching. + */ + resetBufferStatus() { + this._bufferStatusSupported = false; + $("#details-pane-container").removeAttribute("buffer-status"); + }, + + /** + * Updates the displayed buffer status. + */ + updateBufferStatus: function() { + // If we've never seen a "buffer-status" event from the front, ignore + // and keep the buffer elements hidden. + if (!this._bufferStatusSupported) { + return; + } + + const recording = PerformanceController.getCurrentRecording(); + if (!recording || !recording.isRecording()) { + return; + } + + const bufferUsage = + PerformanceController.getBufferUsageForRecording(recording) || 0; + + // Normalize to a percentage value + const percent = Math.floor(bufferUsage * 100); + + const $container = $("#details-pane-container"); + const $bufferLabel = $(".buffer-status-message", $container.selectedPanel); + + // Be a little flexible on the buffer status, although not sure how + // this could happen, as RecordingModel clamps. + if (percent >= 99) { + $container.setAttribute("buffer-status", "full"); + } else { + $container.setAttribute("buffer-status", "in-progress"); + } + + $bufferLabel.value = L10N.getFormatStr("profiler.bufferFull", percent); + this.emit(EVENTS.UI_RECORDING_PROFILER_STATUS_RENDERED, percent); + }, + + /** + * Toggles the `locked` attribute on the record buttons based + * on `lock`. + * + * @param {boolean} lock + */ + _lockRecordButtons: function(lock) { + this._recordingControlsState.isLocked = lock; + this._renderRecordingControls(); + }, + + /* + * Toggles the `checked` attribute on the record buttons based + * on `activate`. + * + * @param {boolean} activate + */ + _toggleRecordButtons: function(activate) { + this._recordingControlsState.isRecording = activate; + this._renderRecordingControls(); + }, + + /** + * When a recording has started. + */ + _onRecordingStateChange: function() { + const currentRecording = PerformanceController.getCurrentRecording(); + const recordings = PerformanceController.getRecordings(); + + this._toggleRecordButtons( + !!recordings.find(r => !r.isConsole() && r.isRecording()) + ); + this._lockRecordButtons( + !!recordings.find(r => !r.isConsole() && r.isFinalizing()) + ); + + if (currentRecording && currentRecording.isFinalizing()) { + this.setState("loading"); + } + if (currentRecording && currentRecording.isCompleted()) { + this.setState("recorded"); + } + if (currentRecording && currentRecording.isRecording()) { + this.updateBufferStatus(); + } + }, + + /** + * When starting a recording has failed. + */ + _onNewRecordingFailed: function() { + this._lockRecordButtons(false); + this._toggleRecordButtons(false); + }, + + /** + * Handler for clicking the clear button. + */ + _onClearButtonClick: function(e) { + this.emit(EVENTS.UI_CLEAR_RECORDINGS); + }, + + /** + * Handler for clicking the record button. + */ + _onRecordButtonClick: function(e) { + if (this._recordingControlsState.isRecording) { + this.emit(EVENTS.UI_STOP_RECORDING); + } else { + this._lockRecordButtons(true); + this._toggleRecordButtons(true); + this.emit(EVENTS.UI_START_RECORDING); + } + }, + + /** + * Handler for clicking the import button. + */ + _onImportButtonClick: function(e) { + const fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init( + window, + L10N.getStr("recordingsList.importDialogTitle"), + Ci.nsIFilePicker.modeOpen + ); + fp.appendFilter( + L10N.getStr("recordingsList.saveDialogJSONFilter"), + "*.json" + ); + fp.appendFilter(L10N.getStr("recordingsList.saveDialogAllFilter"), "*.*"); + + fp.open(rv => { + if (rv == Ci.nsIFilePicker.returnOK) { + this.emit(EVENTS.UI_IMPORT_RECORDING, fp.file); + } + }); + }, + + /** + * Fired when a recording is selected. Used to toggle the profiler view state. + */ + _onRecordingSelected: function(recording) { + if (!recording) { + this.setState("empty"); + } else if (recording.isRecording() && recording.isConsole()) { + this.setState("console-recording"); + } else if (recording.isRecording()) { + this.setState("recording"); + } else { + this.setState("recorded"); + } + }, + + /** + * Fired when the controller has updated information on the buffer's status. + * Update the buffer status display if shown. + */ + _onProfilerStatusUpdated: function(profilerStatus) { + // We only care about buffer status here, so check to see + // if it has position. + if (!profilerStatus || profilerStatus.position === void 0) { + return; + } + // If this is our first buffer event, set the status and add a class + if (!this._bufferStatusSupported) { + this._bufferStatusSupported = true; + $("#details-pane-container").setAttribute("buffer-status", "in-progress"); + } + + if (!this.getState("recording") && !this.getState("console-recording")) { + return; + } + + this.updateBufferStatus(); + }, + + toString: () => "[object PerformanceView]", +}; + +/** + * Convenient way of emitting events from the view. + */ +EventEmitter.decorate(PerformanceView); + +exports.PerformanceView = PerformanceView; diff --git a/devtools/client/performance/test/.eslintrc.js b/devtools/client/performance/test/.eslintrc.js new file mode 100644 index 0000000000..3d0bd99e1b --- /dev/null +++ b/devtools/client/performance/test/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + extends: "../../../.eslintrc.mochitests.js", +}; diff --git a/devtools/client/performance/test/browser.ini b/devtools/client/performance/test/browser.ini new file mode 100644 index 0000000000..c85ae87ce9 --- /dev/null +++ b/devtools/client/performance/test/browser.ini @@ -0,0 +1,148 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +skip-if = os == 'linux' && e10s && (asan || debug) # Bug 1254821 +support-files = + doc_allocs.html + doc_innerHTML.html + doc_markers.html + doc_simple-test.html + doc_worker.html + js_simpleWorker.js + head.js + !/devtools/client/shared/test/telemetry-test-helpers.js + +[browser_aaa-run-first-leaktest.js] +[browser_perf-button-states.js] +[browser_perf-calltree-js-categories.js] +skip-if = (os == 'win' && os_version == '10.0' && bits == 64 && !asan) # Bug 1466377 +[browser_perf-calltree-js-columns.js] +[browser_perf-calltree-js-events.js] +fail-if = a11y_checks # bug 1687790 call-tree-item is not accessible +[browser_perf-calltree-memory-columns.js] +[browser_perf-console-record-01.js] +[browser_perf-console-record-02.js] +[browser_perf-console-record-03.js] +[browser_perf-console-record-04.js] +[browser_perf-console-record-05.js] +[browser_perf-console-record-06.js] +[browser_perf-console-record-07.js] +[browser_perf-console-record-08.js] +[browser_perf-console-record-09.js] +[browser_perf-details-01-toggle.js] +[browser_perf-details-02-utility-fun.js] +[browser_perf-details-03-without-allocations.js] +[browser_perf-details-04-toolbar-buttons.js] +[browser_perf-details-05-preserve-view.js] +[browser_perf-details-06-rerender-on-selection.js] +[browser_perf-details-07-bleed-events.js] +[browser_perf-details-render-00-waterfall.js] +[browser_perf-details-render-01-js-calltree.js] +[browser_perf-details-render-02-js-flamegraph.js] +[browser_perf-details-render-03-memory-calltree.js] +[browser_perf-details-render-04-memory-flamegraph.js] +[browser_perf-docload.js] +[browser_perf-fission-switch-target.js] +[browser_perf-highlighted.js] +[browser_perf-loading-01.js] +[browser_perf-loading-02.js] +[browser_perf-marker-details.js] +disabled=TODO bug 1256350 +[browser_perf-options-01-toggle-throw.js] +[browser_perf-options-02-toggle-throw-alt.js] +[browser_perf-options-03-toggle-meta.js] +[browser_perf-options-enable-framerate-01.js] +[browser_perf-options-enable-framerate-02.js] +[browser_perf-options-enable-memory-01.js] +[browser_perf-options-enable-memory-02.js] +[browser_perf-options-flatten-tree-recursion-01.js] +[browser_perf-options-flatten-tree-recursion-02.js] +[browser_perf-options-invert-call-tree-01.js] +[browser_perf-options-invert-call-tree-02.js] +[browser_perf-options-invert-flame-graph-01.js] +[browser_perf-options-invert-flame-graph-02.js] +[browser_perf-options-propagate-allocations.js] +[browser_perf-options-propagate-profiler.js] +[browser_perf-options-show-idle-blocks-01.js] +[browser_perf-options-show-idle-blocks-02.js] +[browser_perf-options-show-jit-optimizations.js] +disabled=TODO bug 1256350 +[browser_perf-options-show-platform-data-01.js] +[browser_perf-options-show-platform-data-02.js] +[browser_perf-overview-render-01.js] +[browser_perf-overview-render-02.js] +[browser_perf-overview-render-03.js] +[browser_perf-overview-render-04.js] +[browser_perf-overview-selection-01.js] +[browser_perf-overview-selection-02.js] +[browser_perf-overview-selection-03.js] +[browser_perf-overview-time-interval.js] +[browser_perf-private-browsing.js] +disabled=TODO bug 1256350 +[browser_perf-range-changed-render.js] +[browser_perf-recording-notices-01.js] +[browser_perf-recording-notices-02.js] +[browser_perf-recording-notices-03.js] +skip-if = (debug && (bits == 32)) # debug 32 bit: bug 1273374 +[browser_perf-recording-notices-04.js] +[browser_perf-recording-notices-05.js] +[browser_perf-recording-selected-01.js] +[browser_perf-recording-selected-02.js] +[browser_perf-recording-selected-03.js] +[browser_perf-recording-selected-04.js] +[browser_perf-recordings-clear-01.js] +[browser_perf-recordings-clear-02.js] +[browser_perf-recordings-io-01.js] +disabled=TODO bug 1256350 +[browser_perf-recordings-io-02.js] +disabled=TODO bug 1256350 +[browser_perf-recordings-io-03.js] +disabled=TODO bug 1256350 +[browser_perf-recordings-io-04.js] +disabled=TODO bug 1256350 +[browser_perf-recordings-io-05.js] +disabled=TODO bug 1256350 +[browser_perf-recordings-io-06.js] +disabled=TODO bug 1256350 +[browser_perf-refresh.js] +[browser_perf-states.js] +[browser_perf-telemetry-01.js] +[browser_perf-telemetry-02.js] +[browser_perf-telemetry-03.js] +[browser_perf-telemetry-04.js] +[browser_perf-theme-toggle.js] +disabled=TODO bug 1256350 +[browser_perf-tree-abstract-01.js] +skip-if = (verify && debug && (os == 'win' || os == 'mac')) +[browser_perf-tree-abstract-02.js] +skip-if = (verify && debug && (os == 'win' || os == 'mac')) +[browser_perf-tree-abstract-03.js] +skip-if = (verify && debug && (os == 'win' || os == 'mac')) +[browser_perf-tree-abstract-04.js] +skip-if = (verify && debug && (os == 'win' || os == 'mac')) +[browser_perf-tree-abstract-05.js] +[browser_perf-tree-view-01.js] +[browser_perf-tree-view-02.js] +[browser_perf-tree-view-03.js] +[browser_perf-tree-view-04.js] +[browser_perf-tree-view-05.js] +[browser_perf-tree-view-06.js] +[browser_perf-tree-view-07.js] +[browser_perf-tree-view-08.js] +[browser_perf-tree-view-09.js] +[browser_perf-tree-view-10.js] +[browser_perf-tree-view-11.js] +disabled=TODO bug 1256350 +[browser_perf-ui-recording.js] +[browser_timeline-filters-01.js] +disabled=TODO bug 1256350 +[browser_timeline-filters-02.js] +disabled=TODO bug 1256350 +[browser_timeline-waterfall-background.js] +[browser_timeline-waterfall-generic.js] +[browser_timeline-waterfall-rerender.js] +disabled=TODO bug 1256350 +[browser_timeline-waterfall-sidebar.js] +disabled=TODO bug 1256350 +[browser_timeline-waterfall-workers.js] +disabled=TODO bug 1256350 diff --git a/devtools/client/performance/test/browser_aaa-run-first-leaktest.js b/devtools/client/performance/test/browser_aaa-run-first-leaktest.js new file mode 100644 index 0000000000..b7d5735c72 --- /dev/null +++ b/devtools/client/performance/test/browser_aaa-run-first-leaktest.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the performance tool leaks on initialization and sudden destruction. + * You can also use this initialization format as a template for other tests. + */ + +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); + +add_task(async function() { + const { target, toolbox, panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + ok(target, "Should have a target available."); + ok(toolbox, "Should have a toolbox available."); + ok(panel, "Should have a panel available."); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-button-states.js b/devtools/client/performance/test/browser_perf-button-states.js new file mode 100644 index 0000000000..aeb74b3915 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-button-states.js @@ -0,0 +1,105 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the recording button states are set as expected. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { + $, + $$, + EVENTS, + PerformanceController, + PerformanceView, + } = panel.panelWin; + + const recordButton = $("#main-record-button"); + + checkRecordButtonsStates(false, false); + + const uiStartClick = once(PerformanceView, EVENTS.UI_START_RECORDING); + const recordingStarted = once( + PerformanceController, + EVENTS.RECORDING_STATE_CHANGE, + { + expectedArgs: ["recording-started"], + } + ); + const backendStartReady = once( + PerformanceController, + EVENTS.BACKEND_READY_AFTER_RECORDING_START + ); + const uiStateRecording = once(PerformanceView, EVENTS.UI_STATE_CHANGED, { + expectedArgs: ["recording"], + }); + + await click(recordButton); + await uiStartClick; + + checkRecordButtonsStates(true, true); + + await recordingStarted; + + checkRecordButtonsStates(true, false); + + await backendStartReady; + await uiStateRecording; + + const uiStopClick = once(PerformanceView, EVENTS.UI_STOP_RECORDING); + const recordingStopped = once( + PerformanceController, + EVENTS.RECORDING_STATE_CHANGE, + { + expectedArgs: ["recording-stopped"], + } + ); + const backendStopReady = once( + PerformanceController, + EVENTS.BACKEND_READY_AFTER_RECORDING_STOP + ); + const uiStateRecorded = once(PerformanceView, EVENTS.UI_STATE_CHANGED, { + expectedArgs: ["recorded"], + }); + + await click(recordButton); + await uiStopClick; + await recordingStopped; + + checkRecordButtonsStates(false, false); + + await backendStopReady; + await uiStateRecorded; + + await teardownToolboxAndRemoveTab(panel); + + function checkRecordButtonsStates(checked, locked) { + for (const button of $$(".record-button")) { + is( + button.classList.contains("checked"), + checked, + "The record button checked state should be " + checked + ); + is( + button.disabled, + locked, + "The record button locked state should be " + locked + ); + } + } +}); diff --git a/devtools/client/performance/test/browser_perf-calltree-js-categories.js b/devtools/client/performance/test/browser_perf-calltree-js-categories.js new file mode 100644 index 0000000000..cdcdbaf920 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-calltree-js-categories.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the categories are shown in the js call tree when + * platform data is enabled. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_SHOW_PLATFORM_DATA_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + busyWait, +} = require("devtools/client/performance/test/helpers/wait-utils"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, $, $$, DetailsView, JsCallTreeView } = panel.panelWin; + + // Enable platform data to show the categories in the tree. + Services.prefs.setBoolPref(UI_SHOW_PLATFORM_DATA_PREF, true); + + await startRecording(panel); + // To show the `Gecko` category in the tree. + await busyWait(100); + await stopRecording(panel); + + const rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + await DetailsView.selectView("js-calltree"); + await rendered; + + is( + $(".call-tree-cells-container").hasAttribute("categories-hidden"), + false, + "The call tree cells container should show the categories now." + ); + ok( + geckoCategoryPresent($$), + "A category node with the text `Gecko` is displayed in the tree." + ); + + // Disable platform data to hide the categories. + Services.prefs.setBoolPref(UI_SHOW_PLATFORM_DATA_PREF, false); + + is( + $(".call-tree-cells-container").getAttribute("categories-hidden"), + "", + "The call tree cells container should hide the categories now." + ); + ok( + !geckoCategoryPresent($$), + "A category node with the text `Gecko` doesn't exist in the tree anymore." + ); + + await teardownToolboxAndRemoveTab(panel); +}); + +function geckoCategoryPresent($$) { + for (const elem of $$(".call-tree-category")) { + if (elem.textContent.trim() == "Gecko") { + return true; + } + } + return false; +} diff --git a/devtools/client/performance/test/browser_perf-calltree-js-columns.js b/devtools/client/performance/test/browser_perf-calltree-js-columns.js new file mode 100644 index 0000000000..8fd330c36b --- /dev/null +++ b/devtools/client/performance/test/browser_perf-calltree-js-columns.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the js call tree view renders the correct columns. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_SHOW_PLATFORM_DATA_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + busyWait, +} = require("devtools/client/performance/test/helpers/wait-utils"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, $, $$, DetailsView, JsCallTreeView } = panel.panelWin; + + // Enable platform data to show the platform functions in the tree. + Services.prefs.setBoolPref(UI_SHOW_PLATFORM_DATA_PREF, true); + + await startRecording(panel); + // To show the `busyWait` function in the tree. + await busyWait(100); + await stopRecording(panel); + + const rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + await DetailsView.selectView("js-calltree"); + await rendered; + + ok( + DetailsView.isViewSelected(JsCallTreeView), + "The call tree is now selected." + ); + + testCells($, $$, { + duration: true, + percentage: true, + allocations: false, + "self-duration": true, + "self-percentage": true, + "self-allocations": false, + samples: true, + function: true, + }); + + await teardownToolboxAndRemoveTab(panel); +}); + +function testCells($, $$, visibleCells) { + for (const cell in visibleCells) { + if (visibleCells[cell]) { + ok( + $(`.call-tree-cell[type=${cell}]`), + `At least one ${cell} column was visible in the tree.` + ); + } else { + ok( + !$(`.call-tree-cell[type=${cell}]`), + `No ${cell} columns were visible in the tree.` + ); + } + } + + is( + $$(".call-tree-cell", $(".call-tree-item")).length, + Object.keys(visibleCells).filter(e => visibleCells[e]).length, + "The correct number of cells were found in the tree." + ); +} diff --git a/devtools/client/performance/test/browser_perf-calltree-js-events.js b/devtools/client/performance/test/browser_perf-calltree-js-events.js new file mode 100644 index 0000000000..b9651bba49 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-calltree-js-events.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the call tree up/down events work for js calltrees. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + synthesizeProfile, +} = require("devtools/client/performance/test/helpers/synth-utils"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { + EVENTS, + $, + DetailsView, + OverviewView, + JsCallTreeView, + } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + const rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + await DetailsView.selectView("js-calltree"); + await rendered; + + // Mock the profile used so we can get a deterministic tree created. + const profile = synthesizeProfile(); + const threadNode = new ThreadNode( + profile.threads[0], + OverviewView.getTimeInterval() + ); + JsCallTreeView._populateCallTree(threadNode); + JsCallTreeView.emit(EVENTS.UI_JS_CALL_TREE_RENDERED); + + const firstTreeItem = $("#js-calltree-view .call-tree-item"); + + // DE-XUL: There are focus issues with XUL. Focus first, then synthesize the clicks + // so that keyboard events work correctly. + firstTreeItem.focus(); + + let count = 0; + const onFocus = () => count++; + JsCallTreeView.on("focus", onFocus); + + await click(firstTreeItem); + + key("VK_DOWN"); + key("VK_DOWN"); + key("VK_DOWN"); + key("VK_DOWN"); + + JsCallTreeView.off("focus", onFocus); + is(count, 4, "Several focus events are fired for the calltree."); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-calltree-memory-columns.js b/devtools/client/performance/test/browser_perf-calltree-memory-columns.js new file mode 100644 index 0000000000..5abcf857a3 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-calltree-memory-columns.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the memory call tree view renders the correct columns. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_ALLOCATIONS_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, $, $$, DetailsView, MemoryCallTreeView } = panel.panelWin; + + // Enable allocations to test. + Services.prefs.setBoolPref(UI_ENABLE_ALLOCATIONS_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + const rendered = once( + MemoryCallTreeView, + EVENTS.UI_MEMORY_CALL_TREE_RENDERED + ); + await DetailsView.selectView("memory-calltree"); + await rendered; + + ok( + DetailsView.isViewSelected(MemoryCallTreeView), + "The call tree is now selected." + ); + + testCells($, $$, { + duration: false, + percentage: false, + count: true, + "count-percentage": true, + size: true, + "size-percentage": true, + "self-duration": false, + "self-percentage": false, + "self-count": true, + "self-count-percentage": true, + "self-size": true, + "self-size-percentage": true, + samples: false, + function: true, + }); + + await teardownToolboxAndRemoveTab(panel); +}); + +function testCells($, $$, visibleCells) { + for (const cell in visibleCells) { + if (visibleCells[cell]) { + ok( + $(`.call-tree-cell[type=${cell}]`), + `At least one ${cell} column was visible in the tree.` + ); + } else { + ok( + !$(`.call-tree-cell[type=${cell}]`), + `No ${cell} columns were visible in the tree.` + ); + } + } + + is( + $$(".call-tree-cell", $(".call-tree-item")).length, + Object.keys(visibleCells).filter(e => visibleCells[e]).length, + "The correct number of cells were found in the tree." + ); +} diff --git a/devtools/client/performance/test/browser_perf-console-record-01.js b/devtools/client/performance/test/browser_perf-console-record-01.js new file mode 100644 index 0000000000..4a3c12630d --- /dev/null +++ b/devtools/client/performance/test/browser_perf-console-record-01.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler is populated by console recordings that have finished + * before it was opened. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInTab, + initConsoleInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + waitUntil, +} = require("devtools/client/performance/test/helpers/wait-utils"); +const { + getSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { target, console } = await initConsoleInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + await console.profile("rust"); + await console.profileEnd("rust"); + + const { panel } = await initPerformanceInTab({ tab: target.localTab }); + const { PerformanceController, WaterfallView } = panel.panelWin; + + await waitUntil(() => PerformanceController.getRecordings().length == 1); + await waitUntil(() => WaterfallView.wasRenderedAtLeastOnce); + + const recordings = PerformanceController.getRecordings(); + is(recordings.length, 1, "One recording found in the performance panel."); + is(recordings[0].isConsole(), true, "Recording came from console.profile."); + is(recordings[0].getLabel(), "rust", "Correct label in the recording model."); + + const selected = getSelectedRecording(panel); + + is( + selected, + recordings[0], + "The profile from console should be selected as it's the only one." + ); + is( + selected.getLabel(), + "rust", + "The profile label for the first recording is correct." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-console-record-02.js b/devtools/client/performance/test/browser_perf-console-record-02.js new file mode 100644 index 0000000000..4e6f590bef --- /dev/null +++ b/devtools/client/performance/test/browser_perf-console-record-02.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler is populated by in-progress console recordings + * when it is opened. + */ + +const { Constants } = require("devtools/client/performance/modules/constants"); +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInTab, + initConsoleInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + waitForRecordingStoppedEvents, +} = require("devtools/client/performance/test/helpers/actions"); +const { + waitUntil, +} = require("devtools/client/performance/test/helpers/wait-utils"); +const { + times, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + getSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { target, console } = await initConsoleInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + await console.profile("rust"); + await console.profile("rust2"); + + const { panel } = await initPerformanceInTab({ tab: target.localTab }); + const { EVENTS, PerformanceController, OverviewView } = panel.panelWin; + + await waitUntil(() => PerformanceController.getRecordings().length == 2); + + const recordings = PerformanceController.getRecordings(); + is(recordings.length, 2, "Two recordings found in the performance panel."); + is( + recordings[0].isConsole(), + true, + "Recording came from console.profile (1)." + ); + is( + recordings[0].getLabel(), + "rust", + "Correct label in the recording model (1)." + ); + is(recordings[0].isRecording(), true, "Recording is still recording (1)."); + is( + recordings[1].isConsole(), + true, + "Recording came from console.profile (2)." + ); + is( + recordings[1].getLabel(), + "rust2", + "Correct label in the recording model (2)." + ); + is(recordings[1].isRecording(), true, "Recording is still recording (2)."); + + const selected = getSelectedRecording(panel); + is( + selected, + recordings[0], + "The first console recording should be selected." + ); + is( + selected.getLabel(), + "rust", + "The profile label for the first recording is correct." + ); + + // Ensure overview is still rendering. + await times(OverviewView, EVENTS.UI_OVERVIEW_RENDERED, 3, { + expectedArgs: [Constants.FRAMERATE_GRAPH_LOW_RES_INTERVAL], + }); + + let stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + }); + await console.profileEnd("rust"); + await stopped; + + stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when a finished recording is selected + skipWaitingForOverview: true, + skipWaitingForSubview: true, + }); + await console.profileEnd("rust2"); + await stopped; + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-console-record-03.js b/devtools/client/performance/test/browser_perf-console-record-03.js new file mode 100644 index 0000000000..287de27f5b --- /dev/null +++ b/devtools/client/performance/test/browser_perf-console-record-03.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler is populated by in-progress console recordings, and + * also console recordings that have finished before it was opened. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInTab, + initConsoleInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + waitForRecordingStoppedEvents, +} = require("devtools/client/performance/test/helpers/actions"); +const { + waitUntil, +} = require("devtools/client/performance/test/helpers/wait-utils"); +const { + getSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { target, console } = await initConsoleInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + await console.profile("rust"); + await console.profileEnd("rust"); + await console.profile("rust2"); + + const { panel } = await initPerformanceInTab({ tab: target.localTab }); + const { PerformanceController, WaterfallView } = panel.panelWin; + + await waitUntil(() => PerformanceController.getRecordings().length == 2); + await waitUntil(() => WaterfallView.wasRenderedAtLeastOnce); + + const recordings = PerformanceController.getRecordings(); + is(recordings.length, 2, "Two recordings found in the performance panel."); + is( + recordings[0].isConsole(), + true, + "Recording came from console.profile (1)." + ); + is( + recordings[0].getLabel(), + "rust", + "Correct label in the recording model (1)." + ); + is(recordings[0].isRecording(), false, "Recording is still recording (1)."); + is( + recordings[1].isConsole(), + true, + "Recording came from console.profile (2)." + ); + is( + recordings[1].getLabel(), + "rust2", + "Correct label in the recording model (2)." + ); + is(recordings[1].isRecording(), true, "Recording is still recording (2)."); + + const selected = getSelectedRecording(panel); + is( + selected, + recordings[0], + "The first console recording should be selected." + ); + is( + selected.getLabel(), + "rust", + "The profile label for the first recording is correct." + ); + + const stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when a finished recording is selected + skipWaitingForOverview: true, + skipWaitingForSubview: true, + }); + await console.profileEnd("rust2"); + await stopped; + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-console-record-04.js b/devtools/client/performance/test/browser_perf-console-record-04.js new file mode 100644 index 0000000000..48c78d5273 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-console-record-04.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the profiler can handle creation and stopping of console profiles + * after being opened. + */ + +const { Constants } = require("devtools/client/performance/modules/constants"); +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInTab, + initConsoleInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + waitForRecordingStartedEvents, + waitForRecordingStoppedEvents, +} = require("devtools/client/performance/test/helpers/actions"); +const { + times, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + getSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { target, console } = await initConsoleInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { panel } = await initPerformanceInTab({ tab: target.localTab }); + const { EVENTS, PerformanceController, OverviewView } = panel.panelWin; + + const started = waitForRecordingStartedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + }); + await console.profile("rust"); + await started; + + const recordings = PerformanceController.getRecordings(); + is(recordings.length, 1, "One recording found in the performance panel."); + is(recordings[0].isConsole(), true, "Recording came from console.profile."); + is(recordings[0].getLabel(), "rust", "Correct label in the recording model."); + is(recordings[0].isRecording(), true, "Recording is still recording."); + + const selected = getSelectedRecording(panel); + is( + selected, + recordings[0], + "The profile from console should be selected as it's the only one." + ); + is( + selected.getLabel(), + "rust", + "The profile label for the first recording is correct." + ); + + // Ensure overview is still rendering. + await times(OverviewView, EVENTS.UI_OVERVIEW_RENDERED, 3, { + expectedArgs: [Constants.FRAMERATE_GRAPH_LOW_RES_INTERVAL], + }); + + const stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + }); + await console.profileEnd("rust"); + await stopped; + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-console-record-05.js b/devtools/client/performance/test/browser_perf-console-record-05.js new file mode 100644 index 0000000000..41cfdf63d8 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-console-record-05.js @@ -0,0 +1,131 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that multiple recordings with the same label (non-overlapping) appear + * in the recording list. + */ + +const { Constants } = require("devtools/client/performance/modules/constants"); +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInTab, + initConsoleInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + waitForRecordingStartedEvents, + waitForRecordingStoppedEvents, +} = require("devtools/client/performance/test/helpers/actions"); +const { + times, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + getSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { target, console } = await initConsoleInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { panel } = await initPerformanceInTab({ tab: target.localTab }); + const { EVENTS, PerformanceController, OverviewView } = panel.panelWin; + + let started = waitForRecordingStartedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + }); + await console.profile("rust"); + await started; + + let recordings = PerformanceController.getRecordings(); + is(recordings.length, 1, "One recording found in the performance panel."); + is( + recordings[0].isConsole(), + true, + "Recording came from console.profile (1)." + ); + is( + recordings[0].getLabel(), + "rust", + "Correct label in the recording model (1)." + ); + is(recordings[0].isRecording(), true, "Recording is still recording (1)."); + + let selected = getSelectedRecording(panel); + is( + selected, + recordings[0], + "The profile from console should be selected as it's the only one." + ); + is( + selected.getLabel(), + "rust", + "The profile label for the first recording is correct." + ); + + // Ensure overview is still rendering. + await times(OverviewView, EVENTS.UI_OVERVIEW_RENDERED, 3, { + expectedArgs: [Constants.FRAMERATE_GRAPH_LOW_RES_INTERVAL], + }); + + let stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + }); + await console.profileEnd("rust"); + await stopped; + + started = waitForRecordingStartedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when an in-progress recording is selected + skipWaitingForOverview: true, + // the view state won't switch to "console-recording" unless the new + // in-progress recording is selected, which won't happen + skipWaitingForViewState: true, + }); + await console.profile("rust"); + await started; + + recordings = PerformanceController.getRecordings(); + is(recordings.length, 2, "Two recordings found in the performance panel."); + is( + recordings[1].isConsole(), + true, + "Recording came from console.profile (2)." + ); + is( + recordings[1].getLabel(), + "rust", + "Correct label in the recording model (2)." + ); + is(recordings[1].isRecording(), true, "Recording is still recording (2)."); + + selected = getSelectedRecording(panel); + is( + selected, + recordings[0], + "The profile from console should still be selected" + ); + is( + selected.getLabel(), + "rust", + "The profile label for the first recording is correct." + ); + + stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when a finished recording is selected + skipWaitingForOverview: true, + skipWaitingForSubview: true, + }); + await console.profileEnd("rust"); + await stopped; + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-console-record-06.js b/devtools/client/performance/test/browser_perf-console-record-06.js new file mode 100644 index 0000000000..f2bc8ea458 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-console-record-06.js @@ -0,0 +1,125 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that console recordings can overlap (not completely nested). + */ + +const { Constants } = require("devtools/client/performance/modules/constants"); +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInTab, + initConsoleInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + waitForRecordingStartedEvents, + waitForRecordingStoppedEvents, +} = require("devtools/client/performance/test/helpers/actions"); +const { + times, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + getSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { target, console } = await initConsoleInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { panel } = await initPerformanceInTab({ tab: target.localTab }); + const { EVENTS, PerformanceController, OverviewView } = panel.panelWin; + + let started = waitForRecordingStartedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + }); + await console.profile("rust"); + await started; + + let recordings = PerformanceController.getRecordings(); + is(recordings.length, 1, "A recording found in the performance panel."); + is( + getSelectedRecording(panel), + recordings[0], + "The first console recording should be selected." + ); + + // Ensure overview is still rendering. + await times(OverviewView, EVENTS.UI_OVERVIEW_RENDERED, 3, { + expectedArgs: [Constants.FRAMERATE_GRAPH_LOW_RES_INTERVAL], + }); + + started = waitForRecordingStartedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when an in-progress recording is selected + skipWaitingForOverview: true, + // the view state won't switch to "console-recording" unless the new + // in-progress recording is selected, which won't happen + skipWaitingForViewState: true, + }); + await console.profile("golang"); + await started; + + recordings = PerformanceController.getRecordings(); + is(recordings.length, 2, "Two recordings found in the performance panel."); + is( + getSelectedRecording(panel), + recordings[0], + "The first console recording should still be selected." + ); + + // Ensure overview is still rendering. + await times(OverviewView, EVENTS.UI_OVERVIEW_RENDERED, 3, { + expectedArgs: [Constants.FRAMERATE_GRAPH_LOW_RES_INTERVAL], + }); + + let stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + }); + await console.profileEnd("rust"); + await stopped; + + recordings = PerformanceController.getRecordings(); + is(recordings.length, 2, "Two recordings found in the performance panel."); + is( + getSelectedRecording(panel), + recordings[0], + "The first console recording should still be selected." + ); + is( + recordings[0].isRecording(), + false, + "The first console recording should no longer be recording." + ); + + stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when a finished recording is selected + skipWaitingForOverview: true, + skipWaitingForSubview: true, + }); + await console.profileEnd("golang"); + await stopped; + + recordings = PerformanceController.getRecordings(); + is(recordings.length, 2, "Two recordings found in the performance panel."); + is( + getSelectedRecording(panel), + recordings[0], + "The first console recording should still be selected." + ); + is( + recordings[1].isRecording(), + false, + "The second console recording should no longer be recording." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-console-record-07.js b/devtools/client/performance/test/browser_perf-console-record-07.js new file mode 100644 index 0000000000..16fc0de781 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-console-record-07.js @@ -0,0 +1,247 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that a call to console.profileEnd() with no label ends the + * most recent console recording, and console.profileEnd() with a label that + * does not match any pending recordings does nothing. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInTab, + initConsoleInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + waitForRecordingStartedEvents, + waitForRecordingStoppedEvents, +} = require("devtools/client/performance/test/helpers/actions"); +const { + idleWait, +} = require("devtools/client/performance/test/helpers/wait-utils"); +const { + getSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { target, console } = await initConsoleInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { panel } = await initPerformanceInTab({ tab: target.localTab }); + const { PerformanceController } = panel.panelWin; + + let started = waitForRecordingStartedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + }); + await console.profile(); + await started; + + started = waitForRecordingStartedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when an in-progress recording is selected + skipWaitingForOverview: true, + // the view state won't switch to "console-recording" unless the new + // in-progress recording is selected, which won't happen + skipWaitingForViewState: true, + }); + await console.profile("1"); + await started; + + started = waitForRecordingStartedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when an in-progress recording is selected + skipWaitingForOverview: true, + // the view state won't switch to "console-recording" unless the new + // in-progress recording is selected, which won't happen + skipWaitingForViewState: true, + }); + await console.profile("2"); + await started; + + let recordings = PerformanceController.getRecordings(); + let selected = getSelectedRecording(panel); + is(recordings.length, 3, "Three recordings found in the performance panel."); + is(recordings[0].getLabel(), "", "Checking label of recording 1"); + is(recordings[1].getLabel(), "1", "Checking label of recording 2"); + is(recordings[2].getLabel(), "2", "Checking label of recording 3"); + is( + selected, + recordings[0], + "The first console recording should be selected." + ); + + is( + recordings[0].isRecording(), + true, + "All recordings should now be started. (1)" + ); + is( + recordings[1].isRecording(), + true, + "All recordings should now be started. (2)" + ); + is( + recordings[2].isRecording(), + true, + "All recordings should now be started. (3)" + ); + + let stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when a finished recording is selected + skipWaitingForOverview: true, + skipWaitingForSubview: true, + // the view state won't switch to "recorded" unless the new + // finished recording is selected, which won't happen + skipWaitingForViewState: true, + }); + await console.profileEnd(); + await stopped; + + selected = getSelectedRecording(panel); + recordings = PerformanceController.getRecordings(); + is(recordings.length, 3, "Three recordings found in the performance panel."); + is( + selected, + recordings[0], + "The first console recording should still be selected." + ); + + is( + recordings[0].isRecording(), + true, + "The not most recent recording should not stop " + + "when calling console.profileEnd with no args." + ); + is( + recordings[1].isRecording(), + true, + "The not most recent recording should not stop " + + "when calling console.profileEnd with no args." + ); + is( + recordings[2].isRecording(), + false, + "Only the most recent recording should stop " + + "when calling console.profileEnd with no args." + ); + + info("Trying to `profileEnd` a non-existent console recording."); + console.profileEnd("fxos"); + await idleWait(1000); + + selected = getSelectedRecording(panel); + recordings = PerformanceController.getRecordings(); + is(recordings.length, 3, "Three recordings found in the performance panel."); + is( + selected, + recordings[0], + "The first console recording should still be selected." + ); + + is( + recordings[0].isRecording(), + true, + "The first recording should not be ended yet." + ); + is( + recordings[1].isRecording(), + true, + "The second recording should not be ended yet." + ); + is( + recordings[2].isRecording(), + false, + "The third recording should still be ended." + ); + + stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when a finished recording is selected + skipWaitingForOverview: true, + skipWaitingForSubview: true, + // the view state won't switch to "recorded" unless the new + // finished recording is selected, which won't happen + skipWaitingForViewState: true, + }); + await console.profileEnd(); + await stopped; + + selected = getSelectedRecording(panel); + recordings = PerformanceController.getRecordings(); + is(recordings.length, 3, "Three recordings found in the performance panel."); + is( + selected, + recordings[0], + "The first console recording should still be selected." + ); + + is( + recordings[0].isRecording(), + true, + "The first recording should not be ended yet." + ); + is( + recordings[1].isRecording(), + false, + "The second recording should not be ended yet." + ); + is( + recordings[2].isRecording(), + false, + "The third recording should still be ended." + ); + + stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + }); + await console.profileEnd(); + await stopped; + + selected = getSelectedRecording(panel); + recordings = PerformanceController.getRecordings(); + is(recordings.length, 3, "Three recordings found in the performance panel."); + is( + selected, + recordings[0], + "The first console recording should be selected." + ); + + is( + recordings[0].isRecording(), + false, + "All recordings should now be ended. (1)" + ); + is( + recordings[1].isRecording(), + false, + "All recordings should now be ended. (2)" + ); + is( + recordings[2].isRecording(), + false, + "All recordings should now be ended. (3)" + ); + + info("Trying to `profileEnd` with no pending recordings."); + console.profileEnd(); + await idleWait(1000); + + ok( + true, + "Calling console.profileEnd() with no argument and no pending recordings " + + "does not throw." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-console-record-08.js b/devtools/client/performance/test/browser_perf-console-record-08.js new file mode 100644 index 0000000000..9ac2f731f9 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-console-record-08.js @@ -0,0 +1,311 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler can correctly handle simultaneous console and manual + * recordings (via `console.profile` and clicking the record button). + */ + +const { Constants } = require("devtools/client/performance/modules/constants"); +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInTab, + initConsoleInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + waitForRecordingStartedEvents, + waitForRecordingStoppedEvents, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, + times, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + setSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +/** + * The following are bit flag constants that are used to represent the state of a + * recording. + */ + +// Represents a manually recorded profile, if a user hit the record button. +const MANUAL = 0; +// Represents a recorded profile from console.profile(). +const CONSOLE = 1; +// Represents a profile that is currently recording. +const RECORDING = 2; +// Represents a profile that is currently selected. +const SELECTED = 4; + +/** + * Utility function to provide a meaningful inteface for testing that the bits + * match for the recording state. + * @param {integer} expected - The expected bit values packed in an integer. + * @param {integer} actual - The actual bit values packed in an integer. + */ +function hasBitFlag(expected, actual) { + return !!(expected & actual); +} + +add_task(async function() { + // This test seems to take a very long time to finish on Linux VMs. + requestLongerTimeout(4); + + const { target, console } = await initConsoleInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { panel } = await initPerformanceInTab({ tab: target.localTab }); + const { EVENTS, PerformanceController, OverviewView } = panel.panelWin; + + info("Recording 1 - Starting console.profile()..."); + let started = waitForRecordingStartedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + }); + await console.profile("rust"); + await started; + testRecordings(PerformanceController, [CONSOLE + SELECTED + RECORDING]); + + info("Recording 2 - Starting manual recording..."); + await startRecording(panel); + testRecordings(PerformanceController, [ + CONSOLE + RECORDING, + MANUAL + RECORDING + SELECTED, + ]); + + info('Recording 3 - Starting console.profile("3")...'); + started = waitForRecordingStartedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when an in-progress recording is selected + skipWaitingForOverview: true, + // the view state won't switch to "console-recording" unless the new + // in-progress recording is selected, which won't happen + skipWaitingForViewState: true, + }); + await console.profile("3"); + await started; + testRecordings(PerformanceController, [ + CONSOLE + RECORDING, + MANUAL + RECORDING + SELECTED, + CONSOLE + RECORDING, + ]); + + info('Recording 4 - Starting console.profile("4")...'); + started = waitForRecordingStartedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when an in-progress recording is selected + skipWaitingForOverview: true, + // the view state won't switch to "console-recording" unless the new + // in-progress recording is selected, which won't happen + skipWaitingForViewState: true, + }); + await console.profile("4"); + await started; + testRecordings(PerformanceController, [ + CONSOLE + RECORDING, + MANUAL + RECORDING + SELECTED, + CONSOLE + RECORDING, + CONSOLE + RECORDING, + ]); + + info("Recording 4 - Ending console.profileEnd()..."); + let stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when a finished recording is selected + skipWaitingForOverview: true, + skipWaitingForSubview: true, + // the view state won't switch to "recorded" unless the new + // finished recording is selected, which won't happen + skipWaitingForViewState: true, + }); + await console.profileEnd(); + await stopped; + testRecordings(PerformanceController, [ + CONSOLE + RECORDING, + MANUAL + RECORDING + SELECTED, + CONSOLE + RECORDING, + CONSOLE, + ]); + + info("Recording 4 - Select last recording..."); + let recordingSelected = once( + PerformanceController, + EVENTS.RECORDING_SELECTED + ); + setSelectedRecording(panel, 3); + await recordingSelected; + testRecordings(PerformanceController, [ + CONSOLE + RECORDING, + MANUAL + RECORDING, + CONSOLE + RECORDING, + CONSOLE + SELECTED, + ]); + ok( + !OverviewView.isRendering(), + "Stop rendering overview when a completed recording is selected." + ); + + info("Recording 2 - Stop manual recording."); + + await stopRecording(panel); + testRecordings(PerformanceController, [ + CONSOLE + RECORDING, + MANUAL + SELECTED, + CONSOLE + RECORDING, + CONSOLE, + ]); + ok( + !OverviewView.isRendering(), + "Stop rendering overview when a completed recording is selected." + ); + + info("Recording 1 - Select first recording."); + recordingSelected = once(PerformanceController, EVENTS.RECORDING_SELECTED); + setSelectedRecording(panel, 0); + await recordingSelected; + testRecordings(PerformanceController, [ + CONSOLE + RECORDING + SELECTED, + MANUAL, + CONSOLE + RECORDING, + CONSOLE, + ]); + ok( + OverviewView.isRendering(), + "Should be rendering overview a recording in progress is selected." + ); + + // Ensure overview is still rendering. + await times(OverviewView, EVENTS.UI_OVERVIEW_RENDERED, 3, { + expectedArgs: [Constants.FRAMERATE_GRAPH_LOW_RES_INTERVAL], + }); + + info("Ending console.profileEnd()..."); + stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when a finished recording is selected + skipWaitingForOverview: true, + skipWaitingForSubview: true, + // the view state won't switch to "recorded" unless the new + // finished recording is selected, which won't happen + skipWaitingForViewState: true, + }); + await console.profileEnd(); + await stopped; + testRecordings(PerformanceController, [ + CONSOLE + RECORDING + SELECTED, + MANUAL, + CONSOLE, + CONSOLE, + ]); + ok( + OverviewView.isRendering(), + "Should be rendering overview a recording in progress is selected." + ); + + // Ensure overview is still rendering. + await times(OverviewView, EVENTS.UI_OVERVIEW_RENDERED, 3, { + expectedArgs: [Constants.FRAMERATE_GRAPH_LOW_RES_INTERVAL], + }); + + info("Recording 5 - Start one more manual recording."); + await startRecording(panel); + testRecordings(PerformanceController, [ + CONSOLE + RECORDING, + MANUAL, + CONSOLE, + CONSOLE, + MANUAL + RECORDING + SELECTED, + ]); + ok( + OverviewView.isRendering(), + "Should be rendering overview a recording in progress is selected." + ); + + // Ensure overview is still rendering. + await times(OverviewView, EVENTS.UI_OVERVIEW_RENDERED, 3, { + expectedArgs: [Constants.FRAMERATE_GRAPH_LOW_RES_INTERVAL], + }); + + info("Recording 5 - Stop manual recording."); + await stopRecording(panel); + testRecordings(PerformanceController, [ + CONSOLE + RECORDING, + MANUAL, + CONSOLE, + CONSOLE, + MANUAL + SELECTED, + ]); + ok( + !OverviewView.isRendering(), + "Stop rendering overview when a completed recording is selected." + ); + + info("Recording 1 - Ending console.profileEnd()..."); + stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when a finished recording is selected + skipWaitingForOverview: true, + skipWaitingForSubview: true, + // the view state won't switch to "recorded" unless the new + // in-progress recording is selected, which won't happen + skipWaitingForViewState: true, + }); + await console.profileEnd(); + await stopped; + testRecordings(PerformanceController, [ + CONSOLE, + MANUAL, + CONSOLE, + CONSOLE, + MANUAL + SELECTED, + ]); + ok( + !OverviewView.isRendering(), + "Stop rendering overview when a completed recording is selected." + ); + + await teardownToolboxAndRemoveTab(panel); +}); + +function testRecordings(controller, expectedBitFlags) { + const recordings = controller.getRecordings(); + const current = controller.getCurrentRecording(); + is( + recordings.length, + expectedBitFlags.length, + "Expected number of recordings." + ); + + recordings.forEach((recording, i) => { + const expected = expectedBitFlags[i]; + is( + recording.isConsole(), + hasBitFlag(expected, CONSOLE), + `Recording ${i + 1} has expected console state.` + ); + is( + recording.isRecording(), + hasBitFlag(expected, RECORDING), + `Recording ${i + 1} has expected console state.` + ); + is( + recording == current, + hasBitFlag(expected, SELECTED), + `Recording ${i + 1} has expected selected state.` + ); + }); +} diff --git a/devtools/client/performance/test/browser_perf-console-record-09.js b/devtools/client/performance/test/browser_perf-console-record-09.js new file mode 100644 index 0000000000..1f394fda7c --- /dev/null +++ b/devtools/client/performance/test/browser_perf-console-record-09.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that an error is not thrown when clearing out the recordings if there's + * an in-progress console profile and that console profiles are not cleared + * if in progress. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInTab, + initConsoleInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + waitForRecordingStartedEvents, +} = require("devtools/client/performance/test/helpers/actions"); +const { + idleWait, +} = require("devtools/client/performance/test/helpers/wait-utils"); + +add_task(async function() { + const { target, console } = await initConsoleInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { panel } = await initPerformanceInTab({ tab: target.localTab }); + const { PerformanceController } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + info("Starting console.profile()..."); + const started = waitForRecordingStartedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + // only emitted when an in-progress recording is selected + skipWaitingForOverview: true, + // the view state won't switch to "console-recording" unless the new + // in-progress recording is selected, which won't happen + skipWaitingForViewState: true, + }); + await console.profile(); + await started; + + await PerformanceController.clearRecordings(); + let recordings = PerformanceController.getRecordings(); + is(recordings.length, 1, "One recording found in the performance panel."); + is(recordings[0].isConsole(), true, "Recording came from console.profile."); + is(recordings[0].getLabel(), "", "Correct label in the recording model."); + is( + PerformanceController.getCurrentRecording(), + recordings[0], + "There current recording should be the first one." + ); + + info("Attempting to end console.profileEnd()..."); + await console.profileEnd(); + await idleWait(1000); + + ok( + true, + "Stopping an in-progress console profile after clearing recordings does not throw." + ); + + await PerformanceController.clearRecordings(); + recordings = PerformanceController.getRecordings(); + is(recordings.length, 0, "No recordings found"); + is( + PerformanceController.getCurrentRecording(), + null, + "There should be no current recording." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-details-01-toggle.js b/devtools/client/performance/test/browser_perf-details-01-toggle.js new file mode 100644 index 0000000000..2d8e95b0f2 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-details-01-toggle.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the details view toggles subviews. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + command, +} = require("devtools/client/performance/test/helpers/input-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, $, DetailsView } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + info("Checking views on startup."); + checkViews(DetailsView, $, "waterfall"); + + // Select calltree view. + let viewChanged = once(DetailsView, EVENTS.UI_DETAILS_VIEW_SELECTED, { + spreadArgs: true, + }); + command($("toolbarbutton[data-view='js-calltree']")); + let [viewName] = await viewChanged; + is(viewName, "js-calltree", "UI_DETAILS_VIEW_SELECTED fired with view name"); + checkViews(DetailsView, $, "js-calltree"); + + // Select js flamegraph view. + viewChanged = once(DetailsView, EVENTS.UI_DETAILS_VIEW_SELECTED, { + spreadArgs: true, + }); + command($("toolbarbutton[data-view='js-flamegraph']")); + [viewName] = await viewChanged; + is( + viewName, + "js-flamegraph", + "UI_DETAILS_VIEW_SELECTED fired with view name" + ); + checkViews(DetailsView, $, "js-flamegraph"); + + // Select waterfall view. + viewChanged = once(DetailsView, EVENTS.UI_DETAILS_VIEW_SELECTED, { + spreadArgs: true, + }); + command($("toolbarbutton[data-view='waterfall']")); + [viewName] = await viewChanged; + is(viewName, "waterfall", "UI_DETAILS_VIEW_SELECTED fired with view name"); + checkViews(DetailsView, $, "waterfall"); + + await teardownToolboxAndRemoveTab(panel); +}); + +function checkViews(DetailsView, $, currentView) { + for (const viewName in DetailsView.components) { + const button = $(`toolbarbutton[data-view="${viewName}"]`); + + is( + DetailsView.el.selectedPanel.id, + DetailsView.components[currentView].id, + `DetailsView correctly has ${currentView} selected.` + ); + + if (viewName == currentView) { + ok(button.getAttribute("checked"), `${viewName} button checked.`); + } else { + ok(!button.getAttribute("checked"), `${viewName} button not checked.`); + } + } +} diff --git a/devtools/client/performance/test/browser_perf-details-02-utility-fun.js b/devtools/client/performance/test/browser_perf-details-02-utility-fun.js new file mode 100644 index 0000000000..79a516b9aa --- /dev/null +++ b/devtools/client/performance/test/browser_perf-details-02-utility-fun.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the details view utility functions work as advertised. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { + EVENTS, + DetailsView, + WaterfallView, + JsCallTreeView, + JsFlameGraphView, + } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + ok( + DetailsView.isViewSelected(WaterfallView), + "The waterfall view is selected by default in the details view." + ); + + // Select js calltree view. + let selected = once(DetailsView, EVENTS.UI_DETAILS_VIEW_SELECTED); + await DetailsView.selectView("js-calltree"); + await selected; + + ok( + DetailsView.isViewSelected(JsCallTreeView), + "The js calltree view is now selected in the details view." + ); + + // Select js flamegraph view. + selected = once(DetailsView, EVENTS.UI_DETAILS_VIEW_SELECTED); + await DetailsView.selectView("js-flamegraph"); + await selected; + + ok( + DetailsView.isViewSelected(JsFlameGraphView), + "The js flamegraph view is now selected in the details view." + ); + + // Select waterfall view. + selected = once(DetailsView, EVENTS.UI_DETAILS_VIEW_SELECTED); + await DetailsView.selectView("waterfall"); + await selected; + + ok( + DetailsView.isViewSelected(WaterfallView), + "The waterfall view is now selected in the details view." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-details-03-without-allocations.js b/devtools/client/performance/test/browser_perf-details-03-without-allocations.js new file mode 100644 index 0000000000..c76cde4c8e --- /dev/null +++ b/devtools/client/performance/test/browser_perf-details-03-without-allocations.js @@ -0,0 +1,176 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the details view hides the allocations buttons when a recording + * does not have allocations data ("withAllocations": false), and that when an + * allocations panel is selected to a panel that does not have allocations goes + * to a default panel instead. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_ALLOCATIONS_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + setSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { + EVENTS, + $, + DetailsView, + WaterfallView, + MemoryCallTreeView, + MemoryFlameGraphView, + } = panel.panelWin; + + const flameBtn = $("toolbarbutton[data-view='memory-flamegraph']"); + const callBtn = $("toolbarbutton[data-view='memory-calltree']"); + + // Disable allocations to prevent recording them. + Services.prefs.setBoolPref(UI_ENABLE_ALLOCATIONS_PREF, false); + + await startRecording(panel); + await stopRecording(panel); + + ok( + DetailsView.isViewSelected(WaterfallView), + "The waterfall view is selected by default in the details view." + ); + + // Re-enable allocations to test. + Services.prefs.setBoolPref(UI_ENABLE_ALLOCATIONS_PREF, true); + + // The toolbar buttons will always be hidden when a recording isn't available, + // so make sure we have one that's finished. + await startRecording(panel); + await stopRecording(panel); + + ok( + DetailsView.isViewSelected(WaterfallView), + "The waterfall view is still selected in the details view." + ); + + is( + callBtn.hidden, + false, + "The `memory-calltree` button is shown when recording has memory data." + ); + is( + flameBtn.hidden, + false, + "The `memory-flamegraph` button is shown when recording has memory data." + ); + + let selected = once(DetailsView, EVENTS.UI_DETAILS_VIEW_SELECTED); + let rendered = once(MemoryCallTreeView, EVENTS.UI_MEMORY_CALL_TREE_RENDERED); + DetailsView.selectView("memory-calltree"); + await selected; + await rendered; + + ok( + DetailsView.isViewSelected(MemoryCallTreeView), + "The memory call tree view can now be selected." + ); + + selected = once(DetailsView, EVENTS.UI_DETAILS_VIEW_SELECTED); + rendered = once(MemoryFlameGraphView, EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED); + DetailsView.selectView("memory-flamegraph"); + await selected; + await rendered; + + ok( + DetailsView.isViewSelected(MemoryFlameGraphView), + "The memory flamegraph view can now be selected." + ); + + // Select the first recording with no memory data. + selected = once(DetailsView, EVENTS.UI_DETAILS_VIEW_SELECTED); + rendered = once(WaterfallView, EVENTS.UI_WATERFALL_RENDERED); + setSelectedRecording(panel, 0); + await selected; + await rendered; + + ok( + DetailsView.isViewSelected(WaterfallView), + "The waterfall view is now selected " + + "when switching back to a recording that does not have memory data." + ); + + is( + callBtn.hidden, + true, + "The `memory-calltree` button is hidden when recording has no memory data." + ); + is( + flameBtn.hidden, + true, + "The `memory-flamegraph` button is hidden when recording has no memory data." + ); + + // Go back to the recording with memory data. + rendered = once(WaterfallView, EVENTS.UI_WATERFALL_RENDERED); + setSelectedRecording(panel, 1); + await rendered; + + ok( + DetailsView.isViewSelected(WaterfallView), + "The waterfall view is still selected in the details view." + ); + + is( + callBtn.hidden, + false, + "The `memory-calltree` button is shown when recording has memory data." + ); + is( + flameBtn.hidden, + false, + "The `memory-flamegraph` button is shown when recording has memory data." + ); + + selected = once(DetailsView, EVENTS.UI_DETAILS_VIEW_SELECTED); + rendered = once(MemoryCallTreeView, EVENTS.UI_MEMORY_CALL_TREE_RENDERED); + DetailsView.selectView("memory-calltree"); + await selected; + await rendered; + + ok( + DetailsView.isViewSelected(MemoryCallTreeView), + "The memory call tree view can be " + + "selected again after going back to the view with memory data." + ); + + selected = once(DetailsView, EVENTS.UI_DETAILS_VIEW_SELECTED); + rendered = once(MemoryFlameGraphView, EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED); + DetailsView.selectView("memory-flamegraph"); + await selected; + await rendered; + + ok( + DetailsView.isViewSelected(MemoryFlameGraphView), + "The memory flamegraph view can " + + "be selected again after going back to the view with memory data." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-details-04-toolbar-buttons.js b/devtools/client/performance/test/browser_perf-details-04-toolbar-buttons.js new file mode 100644 index 0000000000..34784ea7bd --- /dev/null +++ b/devtools/client/performance/test/browser_perf-details-04-toolbar-buttons.js @@ -0,0 +1,253 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the details view hides the toolbar buttons when a recording + * doesn't exist or is in progress. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + setSelectedRecording, + getSelectedRecordingIndex, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, $, PerformanceController, WaterfallView } = panel.panelWin; + + const waterfallBtn = $("toolbarbutton[data-view='waterfall']"); + const jsFlameBtn = $("toolbarbutton[data-view='js-flamegraph']"); + const jsCallBtn = $("toolbarbutton[data-view='js-calltree']"); + const memFlameBtn = $("toolbarbutton[data-view='memory-flamegraph']"); + const memCallBtn = $("toolbarbutton[data-view='memory-calltree']"); + + is( + waterfallBtn.hidden, + true, + "The `waterfall` button is hidden when tool starts." + ); + is( + jsFlameBtn.hidden, + true, + "The `js-flamegraph` button is hidden when tool starts." + ); + is( + jsCallBtn.hidden, + true, + "The `js-calltree` button is hidden when tool starts." + ); + is( + memFlameBtn.hidden, + true, + "The `memory-flamegraph` button is hidden when tool starts." + ); + is( + memCallBtn.hidden, + true, + "The `memory-calltree` button is hidden when tool starts." + ); + + await startRecording(panel); + + is( + waterfallBtn.hidden, + true, + "The `waterfall` button is hidden when recording starts." + ); + is( + jsFlameBtn.hidden, + true, + "The `js-flamegraph` button is hidden when recording starts." + ); + is( + jsCallBtn.hidden, + true, + "The `js-calltree` button is hidden when recording starts." + ); + is( + memFlameBtn.hidden, + true, + "The `memory-flamegraph` button is hidden when recording starts." + ); + is( + memCallBtn.hidden, + true, + "The `memory-calltree` button is hidden when recording starts." + ); + + await stopRecording(panel); + + is( + waterfallBtn.hidden, + false, + "The `waterfall` button is visible when recording ends." + ); + is( + jsFlameBtn.hidden, + false, + "The `js-flamegraph` button is visible when recording ends." + ); + is( + jsCallBtn.hidden, + false, + "The `js-calltree` button is visible when recording ends." + ); + is( + memFlameBtn.hidden, + true, + "The `memory-flamegraph` button is hidden when recording ends." + ); + is( + memCallBtn.hidden, + true, + "The `memory-calltree` button is hidden when recording ends." + ); + + await startRecording(panel); + + is( + waterfallBtn.hidden, + true, + "The `waterfall` button is hidden when another recording starts." + ); + is( + jsFlameBtn.hidden, + true, + "The `js-flamegraph` button is hidden when another recording starts." + ); + is( + jsCallBtn.hidden, + true, + "The `js-calltree` button is hidden when another recording starts." + ); + is( + memFlameBtn.hidden, + true, + "The `memory-flamegraph` button is hidden when another recording starts." + ); + is( + memCallBtn.hidden, + true, + "The `memory-calltree` button is hidden when another recording starts." + ); + + let selected = once(PerformanceController, EVENTS.RECORDING_SELECTED); + let rendered = once(WaterfallView, EVENTS.UI_WATERFALL_RENDERED); + setSelectedRecording(panel, 0); + await selected; + await rendered; + + let selectedIndex = getSelectedRecordingIndex(panel); + is(selectedIndex, 0, "The first recording was selected again."); + + is( + waterfallBtn.hidden, + false, + "The `waterfall` button is visible when first recording selected." + ); + is( + jsFlameBtn.hidden, + false, + "The `js-flamegraph` button is visible when first recording selected." + ); + is( + jsCallBtn.hidden, + false, + "The `js-calltree` button is visible when first recording selected." + ); + is( + memFlameBtn.hidden, + true, + "The `memory-flamegraph` button is hidden when first recording selected." + ); + is( + memCallBtn.hidden, + true, + "The `memory-calltree` button is hidden when first recording selected." + ); + + selected = once(PerformanceController, EVENTS.RECORDING_SELECTED); + setSelectedRecording(panel, 1); + await selected; + + selectedIndex = getSelectedRecordingIndex(panel); + is(selectedIndex, 1, "The second recording was selected again."); + + is( + waterfallBtn.hidden, + true, + "The `waterfall button` still is hidden when second recording selected." + ); + is( + jsFlameBtn.hidden, + true, + "The `js-flamegraph button` still is hidden when second recording selected." + ); + is( + jsCallBtn.hidden, + true, + "The `js-calltree button` still is hidden when second recording selected." + ); + is( + memFlameBtn.hidden, + true, + "The `memory-flamegraph button` still is hidden when second recording selected." + ); + is( + memCallBtn.hidden, + true, + "The `memory-calltree button` still is hidden when second recording selected." + ); + + rendered = once(WaterfallView, EVENTS.UI_WATERFALL_RENDERED); + await stopRecording(panel); + await rendered; + + selectedIndex = getSelectedRecordingIndex(panel); + is(selectedIndex, 1, "The second recording is still selected."); + + is( + waterfallBtn.hidden, + false, + "The `waterfall` button is visible when second recording finished." + ); + is( + jsFlameBtn.hidden, + false, + "The `js-flamegraph` button is visible when second recording finished." + ); + is( + jsCallBtn.hidden, + false, + "The `js-calltree` button is visible when second recording finished." + ); + is( + memFlameBtn.hidden, + true, + "The `memory-flamegraph` button is hidden when second recording finished." + ); + is( + memCallBtn.hidden, + true, + "The `memory-calltree` button is hidden when second recording finished." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-details-05-preserve-view.js b/devtools/client/performance/test/browser_perf-details-05-preserve-view.js new file mode 100644 index 0000000000..515f405d9e --- /dev/null +++ b/devtools/client/performance/test/browser_perf-details-05-preserve-view.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the same details view is selected after recordings are cleared + * and a new recording starts. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { + EVENTS, + PerformanceController, + DetailsView, + JsCallTreeView, + } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + const selected = once(DetailsView, EVENTS.UI_DETAILS_VIEW_SELECTED); + const rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + await DetailsView.selectView("js-calltree"); + await selected; + await rendered; + + ok( + DetailsView.isViewSelected(JsCallTreeView), + "The js calltree view is now selected in the details view." + ); + + const cleared = once(PerformanceController, EVENTS.RECORDING_SELECTED, { + expectedArgs: [null], + }); + await PerformanceController.clearRecordings(); + await cleared; + + await startRecording(panel); + await stopRecording(panel, { + expectedViewClass: "JsCallTreeView", + expectedViewEvent: "UI_JS_CALL_TREE_RENDERED", + }); + + ok( + DetailsView.isViewSelected(JsCallTreeView), + "The js calltree view is still selected in the details view." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-details-06-rerender-on-selection.js b/devtools/client/performance/test/browser_perf-details-06-rerender-on-selection.js new file mode 100644 index 0000000000..d60afb9eab --- /dev/null +++ b/devtools/client/performance/test/browser_perf-details-06-rerender-on-selection.js @@ -0,0 +1,105 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that when flame chart views scroll to change selection, + * other detail views are rerendered. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + scrollCanvasGraph, + HORIZONTAL_AXIS, +} = require("devtools/client/performance/test/helpers/input-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { + EVENTS, + OverviewView, + DetailsView, + WaterfallView, + JsCallTreeView, + JsFlameGraphView, + } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + const waterfallRendered = once(WaterfallView, EVENTS.UI_WATERFALL_RENDERED); + OverviewView.setTimeInterval({ startTime: 10, endTime: 20 }); + await waterfallRendered; + + // Select the call tree to make sure it's initialized and ready to receive + // redrawing requests once reselected. + const callTreeRendered = once( + JsCallTreeView, + EVENTS.UI_JS_CALL_TREE_RENDERED + ); + await DetailsView.selectView("js-calltree"); + await callTreeRendered; + + // Switch to the flamegraph and perform a scroll over the visualization. + // The waterfall and call tree should get rerendered when reselected. + const flamegraphRendered = once( + JsFlameGraphView, + EVENTS.UI_JS_FLAMEGRAPH_RENDERED + ); + await DetailsView.selectView("js-flamegraph"); + await flamegraphRendered; + + const overviewRangeSelected = once( + OverviewView, + EVENTS.UI_OVERVIEW_RANGE_SELECTED + ); + const waterfallRerendered = once(WaterfallView, EVENTS.UI_WATERFALL_RENDERED); + const callTreeRerendered = once( + JsCallTreeView, + EVENTS.UI_JS_CALL_TREE_RENDERED + ); + + once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED).then(() => { + ok( + false, + "FlameGraphView should not publicly rerender, the internal state " + + "and drawing should be handled by the underlying widget." + ); + }); + + // Reset the range to full view, trigger a "selection" event as if + // our mouse has done this + scrollCanvasGraph(JsFlameGraphView.graph, { + axis: HORIZONTAL_AXIS, + wheel: 200, + x: 10, + }); + + await overviewRangeSelected; + ok(true, "Overview range was changed."); + + await DetailsView.selectView("waterfall"); + await waterfallRerendered; + ok(true, "Waterfall rerendered by flame graph changing interval."); + + await DetailsView.selectView("js-calltree"); + await callTreeRerendered; + ok(true, "CallTree rerendered by flame graph changing interval."); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-details-07-bleed-events.js b/devtools/client/performance/test/browser_perf-details-07-bleed-events.js new file mode 100644 index 0000000000..c5202666f1 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-details-07-bleed-events.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that events don't bleed between detail views. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, DetailsView, JsCallTreeView } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + // The waterfall should render by default, and we want to make + // sure that the render events don't bleed between detail views + // so test that's the case after both views have been created. + const callTreeRendered = once( + JsCallTreeView, + EVENTS.UI_JS_CALL_TREE_RENDERED + ); + await DetailsView.selectView("js-calltree"); + await callTreeRendered; + + const waterfallSelected = once(DetailsView, EVENTS.UI_DETAILS_VIEW_SELECTED); + await DetailsView.selectView("waterfall"); + await waterfallSelected; + + once(JsCallTreeView, EVENTS.UI_WATERFALL_RENDERED).then(() => + ok(false, "JsCallTreeView should not receive UI_WATERFALL_RENDERED event.") + ); + + await startRecording(panel); + await stopRecording(panel); + + const callTreeRerendered = once( + JsCallTreeView, + EVENTS.UI_JS_CALL_TREE_RENDERED + ); + await DetailsView.selectView("js-calltree"); + await callTreeRerendered; + + ok(true, "Test passed."); + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-details-render-00-waterfall.js b/devtools/client/performance/test/browser_perf-details-render-00-waterfall.js new file mode 100644 index 0000000000..8b73e8c06f --- /dev/null +++ b/devtools/client/performance/test/browser_perf-details-render-00-waterfall.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the waterfall view renders content after recording. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { DetailsView, WaterfallView } = panel.panelWin; + + await startRecording(panel); + // Already waits for EVENTS.UI_WATERFALL_RENDERED. + await stopRecording(panel); + + ok( + DetailsView.isViewSelected(WaterfallView), + "The waterfall view is selected by default in the details view." + ); + + ok(true, "WaterfallView rendered after recording is stopped."); + + await startRecording(panel); + // Already waits for EVENTS.UI_WATERFALL_RENDERED. + await stopRecording(panel); + + ok( + DetailsView.isViewSelected(WaterfallView), + "The waterfall view is still selected in the details view." + ); + + ok( + true, + "WaterfallView rendered again after recording completed a second time." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-details-render-01-js-calltree.js b/devtools/client/performance/test/browser_perf-details-render-01-js-calltree.js new file mode 100644 index 0000000000..b74cd7c087 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-details-render-01-js-calltree.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the js call tree view renders content after recording. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, DetailsView, JsCallTreeView } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + const rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + await DetailsView.selectView("js-calltree"); + await rendered; + + ok(true, "JsCallTreeView rendered after recording is stopped."); + + await startRecording(panel); + await stopRecording(panel, { + expectedViewClass: "JsCallTreeView", + expectedViewEvent: "UI_JS_CALL_TREE_RENDERED", + }); + + ok( + true, + "JsCallTreeView rendered again after recording completed a second time." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-details-render-02-js-flamegraph.js b/devtools/client/performance/test/browser_perf-details-render-02-js-flamegraph.js new file mode 100644 index 0000000000..fef2be5da5 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-details-render-02-js-flamegraph.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the js flamegraph view renders content after recording. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, DetailsView, JsFlameGraphView } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + const rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + await DetailsView.selectView("js-flamegraph"); + await rendered; + + ok(true, "JsFlameGraphView rendered after recording is stopped."); + + await startRecording(panel); + await stopRecording(panel, { + expectedViewClass: "JsFlameGraphView", + expectedViewEvent: "UI_JS_FLAMEGRAPH_RENDERED", + }); + + ok( + true, + "JsFlameGraphView rendered again after recording completed a second time." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-details-render-03-memory-calltree.js b/devtools/client/performance/test/browser_perf-details-render-03-memory-calltree.js new file mode 100644 index 0000000000..7c58fb789e --- /dev/null +++ b/devtools/client/performance/test/browser_perf-details-render-03-memory-calltree.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the memory call tree view renders content after recording. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_ALLOCATIONS_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, DetailsView, MemoryCallTreeView } = panel.panelWin; + + // Enable allocations to test. + Services.prefs.setBoolPref(UI_ENABLE_ALLOCATIONS_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + const rendered = once( + MemoryCallTreeView, + EVENTS.UI_MEMORY_CALL_TREE_RENDERED + ); + await DetailsView.selectView("memory-calltree"); + await rendered; + + ok(true, "MemoryCallTreeView rendered after recording is stopped."); + + await startRecording(panel); + await stopRecording(panel, { + expectedViewClass: "MemoryCallTreeView", + expectedViewEvent: "UI_MEMORY_CALL_TREE_RENDERED", + }); + + ok( + true, + "MemoryCallTreeView rendered again after recording completed a second time." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-details-render-04-memory-flamegraph.js b/devtools/client/performance/test/browser_perf-details-render-04-memory-flamegraph.js new file mode 100644 index 0000000000..7a44974326 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-details-render-04-memory-flamegraph.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the memory call tree view renders content after recording. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_ALLOCATIONS_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, DetailsView, MemoryFlameGraphView } = panel.panelWin; + + // Enable allocations to test. + Services.prefs.setBoolPref(UI_ENABLE_ALLOCATIONS_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + const rendered = once( + MemoryFlameGraphView, + EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED + ); + await DetailsView.selectView("memory-flamegraph"); + await rendered; + + ok(true, "MemoryFlameGraphView rendered after recording is stopped."); + + await startRecording(panel); + await stopRecording(panel, { + expectedViewClass: "MemoryFlameGraphView", + expectedViewEvent: "UI_MEMORY_FLAMEGRAPH_RENDERED", + }); + + ok( + true, + "MemoryFlameGraphView rendered again after recording completed a second time." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-docload.js b/devtools/client/performance/test/browser_perf-docload.js new file mode 100644 index 0000000000..141248bcb6 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-docload.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the sidebar is updated with "DOMContentLoaded" and "load" markers. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, + reload, +} = require("devtools/client/performance/test/helpers/actions"); +const { + waitUntil, +} = require("devtools/client/performance/test/helpers/wait-utils"); + +add_task(async function() { + const { panel, target } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { PerformanceController } = panel.panelWin; + + await startRecording(panel); + await reload(target); + + await waitUntil(() => { + // Wait until we get the necessary markers. + const markers = PerformanceController.getCurrentRecording().getMarkers(); + if ( + !markers.some(m => m.name == "document::DOMContentLoaded") || + !markers.some(m => m.name == "document::Load") + ) { + return false; + } + + ok( + markers.filter(m => m.name == "document::DOMContentLoaded").length == 1, + "There should only be one `DOMContentLoaded` marker." + ); + ok( + markers.filter(m => m.name == "document::Load").length == 1, + "There should only be one `load` marker." + ); + + return true; + }); + + await stopRecording(panel); + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-fission-switch-target.js b/devtools/client/performance/test/browser_perf-fission-switch-target.js new file mode 100644 index 0000000000..4b93ad6cce --- /dev/null +++ b/devtools/client/performance/test/browser_perf-fission-switch-target.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Test behavior while target-switching. + */ + +const { + MAIN_PROCESS_URL, + SIMPLE_URL: CONTENT_PROCESS_URL, +} = require("devtools/client/performance/test/helpers/urls"); +const { + BrowserTestUtils, +} = require("resource://testing-common/BrowserTestUtils.jsm"); +const { + addTab, +} = require("devtools/client/performance/test/helpers/tab-utils"); +const { + initPerformanceInTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); + +add_task(async function() { + info("Open a page running on content process"); + const tab = await addTab({ + url: CONTENT_PROCESS_URL, + win: window, + }); + + info("Open the performance panel"); + const { panel } = await initPerformanceInTab({ tab }); + const { PerformanceController, PerformanceView, EVENTS } = panel.panelWin; + + info("Start recording"); + await startRecording(panel); + await PerformanceView.once(EVENTS.UI_RECORDING_PROFILER_STATUS_RENDERED); + + info("Navigate to a page running on main process"); + BrowserTestUtils.loadURI(tab.linkedBrowser, MAIN_PROCESS_URL); + await PerformanceView.once(EVENTS.UI_RECORDING_PROFILER_STATUS_RENDERED); + + info("Return to a page running on content process again"); + BrowserTestUtils.loadURI(tab.linkedBrowser, CONTENT_PROCESS_URL); + await PerformanceView.once(EVENTS.UI_RECORDING_PROFILER_STATUS_RENDERED); + + info("Stop recording"); + await stopRecording(panel); + + const recordings = PerformanceController.getRecordings(); + is(recordings.length, 3, "Have a record for every target-switching"); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-gc-snap.js b/devtools/client/performance/test/browser_perf-gc-snap.js new file mode 100644 index 0000000000..9e42476b59 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-gc-snap.js @@ -0,0 +1,146 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/* eslint-disable */ +/** + * Tests that the marker details on GC markers displays allocation + * buttons and snaps to the correct range + */ +async function spawnTest() { + let { panel } = await initPerformance(ALLOCS_URL); + let { $, $$, EVENTS, PerformanceController, OverviewView, DetailsView, WaterfallView, MemoryCallTreeView } = panel.panelWin; + let EPSILON = 0.00001; + + Services.prefs.setBoolPref(ALLOCATIONS_PREF, true); + + await startRecording(panel); + await idleWait(1000); + await stopRecording(panel); + + injectGCMarkers(PerformanceController, WaterfallView); + + // Select everything + let rendered = WaterfallView.once(EVENTS.UI_WATERFALL_RENDERED); + OverviewView.setTimeInterval({ startTime: 0, endTime: Number.MAX_VALUE }); + await rendered; + + let bars = $$(".waterfall-marker-bar"); + let gcMarkers = PerformanceController.getCurrentRecording().getMarkers(); + ok(gcMarkers.length === 9, "should have 9 GC markers"); + ok(bars.length === 9, "should have 9 GC markers rendered"); + + /** + * Check when it's the second marker of the first GC cycle. + */ + + let targetMarker = gcMarkers[1]; + let targetBar = bars[1]; + info(`Clicking GC Marker of type ${targetMarker.causeName} ${targetMarker.start}:${targetMarker.end}`); + await EventUtils.sendMouseEvent({ type: "mousedown" }, targetBar); + let showAllocsButton; + // On slower machines this can not be found immediately? + await waitUntil(() => showAllocsButton = $("#waterfall-details .custom-button[type='show-allocations']")); + ok(showAllocsButton, "GC buttons when allocations are enabled"); + + rendered = once(MemoryCallTreeView, EVENTS.UI_MEMORY_CALL_TREE_RENDERED); + await EventUtils.sendMouseEvent({ type: "click" }, showAllocsButton); + await rendered; + + is(OverviewView.getTimeInterval().startTime, 0, "When clicking first GC, should use 0 as start time"); + within(OverviewView.getTimeInterval().endTime, targetMarker.start, EPSILON, "Correct end time range"); + + let duration = PerformanceController.getCurrentRecording().getDuration(); + rendered = once(WaterfallView, EVENTS.UI_WATERFALL_RENDERED); + OverviewView.setTimeInterval({ startTime: 0, endTime: duration }); + await DetailsView.selectView("waterfall"); + await rendered; + + /** + * Check when there is a previous GC cycle + */ + + bars = $$(".waterfall-marker-bar"); + targetMarker = gcMarkers[4]; + targetBar = bars[4]; + + info(`Clicking GC Marker of type ${targetMarker.causeName} ${targetMarker.start}:${targetMarker.end}`); + await EventUtils.sendMouseEvent({ type: "mousedown" }, targetBar); + // On slower machines this can not be found immediately? + await waitUntil(() => showAllocsButton = $("#waterfall-details .custom-button[type='show-allocations']")); + ok(showAllocsButton, "GC buttons when allocations are enabled"); + + rendered = once(MemoryCallTreeView, EVENTS.UI_MEMORY_CALL_TREE_RENDERED); + await EventUtils.sendMouseEvent({ type: "click" }, showAllocsButton); + await rendered; + + within(OverviewView.getTimeInterval().startTime, gcMarkers[2].end, EPSILON, + "selection start range is last marker from previous GC cycle."); + within(OverviewView.getTimeInterval().endTime, targetMarker.start, EPSILON, + "selection end range is current GC marker's start time"); + + /** + * Now with allocations disabled + */ + + // Reselect the entire recording -- due to bug 1196945, the new recording + // won't reset the selection + duration = PerformanceController.getCurrentRecording().getDuration(); + rendered = once(WaterfallView, EVENTS.UI_WATERFALL_RENDERED); + OverviewView.setTimeInterval({ startTime: 0, endTime: duration }); + await rendered; + + Services.prefs.setBoolPref(ALLOCATIONS_PREF, false); + await startRecording(panel); + rendered = once(WaterfallView, EVENTS.UI_WATERFALL_RENDERED); + await stopRecording(panel); + await rendered; + + injectGCMarkers(PerformanceController, WaterfallView); + + // Select everything + rendered = WaterfallView.once(EVENTS.UI_WATERFALL_RENDERED); + OverviewView.setTimeInterval({ startTime: 0, endTime: Number.MAX_VALUE }); + await rendered; + + ok(true, "WaterfallView rendered after recording is stopped."); + + bars = $$(".waterfall-marker-bar"); + gcMarkers = PerformanceController.getCurrentRecording().getMarkers(); + + await EventUtils.sendMouseEvent({ type: "mousedown" }, bars[0]); + showAllocsButton = $("#waterfall-details .custom-button[type='show-allocations']"); + ok(!showAllocsButton, "No GC buttons when allocations are disabled"); + + + await teardown(panel); + finish(); +} + +function injectGCMarkers(controller, waterfall) { + // Push some fake GC markers into the recording + let realMarkers = controller.getCurrentRecording().getMarkers(); + // Invalidate marker cache + waterfall._cache.delete(realMarkers); + realMarkers.length = 0; + for (let gcMarker of GC_MARKERS) { + realMarkers.push(gcMarker); + } +} + +var GC_MARKERS = [ + { causeName: "TOO_MUCH_MALLOC", cycle: 1 }, + { causeName: "TOO_MUCH_MALLOC", cycle: 1 }, + { causeName: "TOO_MUCH_MALLOC", cycle: 1 }, + { causeName: "ALLOC_TRIGGER", cycle: 2 }, + { causeName: "ALLOC_TRIGGER", cycle: 2 }, + { causeName: "ALLOC_TRIGGER", cycle: 2 }, + { causeName: "SET_NEW_DOCUMENT", cycle: 3 }, + { causeName: "SET_NEW_DOCUMENT", cycle: 3 }, + { causeName: "SET_NEW_DOCUMENT", cycle: 3 }, +].map((marker, i) => { + marker.name = "GarbageCollection"; + marker.start = 50 + (i * 10); + marker.end = marker.start + 9; + return marker; +}); +/* eslint-enable */ diff --git a/devtools/client/performance/test/browser_perf-highlighted.js b/devtools/client/performance/test/browser_perf-highlighted.js new file mode 100644 index 0000000000..ad7e5333d8 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-highlighted.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the toolbox tab for performance is highlighted when recording, + * whether already loaded, or via console.profile with an unloaded performance tools. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInTab, + initConsoleInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + waitUntil, +} = require("devtools/client/performance/test/helpers/wait-utils"); + +add_task(async function() { + const { target, toolbox, console } = await initConsoleInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const tab = toolbox.doc.getElementById("toolbox-tab-performance"); + + await console.profile("rust"); + await waitUntil(() => tab.classList.contains("highlighted")); + + ok( + tab.classList.contains("highlighted"), + "Performance tab is highlighted during " + + "recording from console.profile when unloaded." + ); + + await console.profileEnd("rust"); + await waitUntil(() => !tab.classList.contains("highlighted")); + + ok( + !tab.classList.contains("highlighted"), + "Performance tab is no longer highlighted when console.profile recording finishes." + ); + + const { panel } = await initPerformanceInTab({ tab: target.localTab }); + + await startRecording(panel); + + ok( + tab.classList.contains("highlighted"), + "Performance tab is highlighted during recording while in performance tool." + ); + + await stopRecording(panel); + + ok( + !tab.classList.contains("highlighted"), + "Performance tab is no longer highlighted when recording finishes." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-loading-01.js b/devtools/client/performance/test/browser_perf-loading-01.js new file mode 100644 index 0000000000..02f97c3a64 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-loading-01.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the recordings view shows the right label while recording, after + * recording, and once the record has loaded. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + getSelectedRecording, + getDurationLabelText, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, PerformanceController } = panel.panelWin; + const { L10N } = require("devtools/client/performance/modules/global"); + + await startRecording(panel); + + is( + getDurationLabelText(panel, 0), + L10N.getStr("recordingsList.recordingLabel"), + "The duration node should show the 'recording' message while recording" + ); + + const recordingStopping = once( + PerformanceController, + EVENTS.RECORDING_STATE_CHANGE, + { + expectedArgs: ["recording-stopping"], + } + ); + const recordingStopped = once( + PerformanceController, + EVENTS.RECORDING_STATE_CHANGE, + { + expectedArgs: ["recording-stopped"], + } + ); + const everythingStopped = stopRecording(panel); + + await recordingStopping; + is( + getDurationLabelText(panel, 0), + L10N.getStr("recordingsList.loadingLabel"), + "The duration node should show the 'loading' message while stopping" + ); + + await recordingStopped; + const selected = getSelectedRecording(panel); + is( + getDurationLabelText(panel, 0), + L10N.getFormatStr( + "recordingsList.durationLabel", + selected.getDuration().toFixed(0) + ), + "The duration node should show the duration after the record has stopped" + ); + + await everythingStopped; + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-loading-02.js b/devtools/client/performance/test/browser_perf-loading-02.js new file mode 100644 index 0000000000..710e8b9e29 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-loading-02.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the details view is locked after recording has stopped and before + * the recording has finished loading. + * Also test that the details view isn't locked if the recording that is being + * stopped isn't the active one. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + getSelectedRecordingIndex, + setSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, $, PerformanceController } = panel.panelWin; + const detailsContainer = $("#details-pane-container"); + const recordingNotice = $("#recording-notice"); + const loadingNotice = $("#loading-notice"); + const detailsPane = $("#details-pane"); + + await startRecording(panel); + + is( + detailsContainer.selectedPanel, + recordingNotice, + "The recording-notice is shown while recording." + ); + + let recordingStopping = once( + PerformanceController, + EVENTS.RECORDING_STATE_CHANGE, + { + expectedArgs: ["recording-stopping"], + } + ); + let recordingStopped = once( + PerformanceController, + EVENTS.RECORDING_STATE_CHANGE, + { + expectedArgs: ["recording-stopped"], + } + ); + let everythingStopped = stopRecording(panel); + + await recordingStopping; + is( + detailsContainer.selectedPanel, + loadingNotice, + "The loading-notice is shown while the record is stopping." + ); + + await recordingStopped; + is( + detailsContainer.selectedPanel, + detailsPane, + "The details panel is shown after the record has stopped." + ); + + await everythingStopped; + await startRecording(panel); + + info("While the 2nd record is still going, switch to the first one."); + const recordingSelected = once( + PerformanceController, + EVENTS.RECORDING_SELECTED + ); + setSelectedRecording(panel, 0); + await recordingSelected; + + recordingStopping = once( + PerformanceController, + EVENTS.RECORDING_STATE_CHANGE, + { + expectedArgs: ["recording-stopping"], + } + ); + recordingStopped = once( + PerformanceController, + EVENTS.RECORDING_STATE_CHANGE, + { + expectedArgs: ["recording-stopped"], + } + ); + everythingStopped = stopRecording(panel); + + await recordingStopping; + is( + detailsContainer.selectedPanel, + detailsPane, + "The details panel is still shown while the 2nd record is being stopped." + ); + is( + getSelectedRecordingIndex(panel), + 0, + "The first record is still selected." + ); + + await recordingStopped; + + is( + detailsContainer.selectedPanel, + detailsPane, + "The details panel is still shown after the 2nd record has stopped." + ); + is(getSelectedRecordingIndex(panel), 1, "The second record is now selected."); + + await everythingStopped; + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-marker-details.js b/devtools/client/performance/test/browser_perf-marker-details.js new file mode 100644 index 0000000000..9f55497a10 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-marker-details.js @@ -0,0 +1,143 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/* eslint-disable */ +/** + * Tests if the Marker Details view renders all properties expected + * for each marker. + */ + +async function spawnTest() { + let { target, panel } = await initPerformance(MARKERS_URL); + let { $, $$, EVENTS, PerformanceController, OverviewView, WaterfallView } = panel.panelWin; + + // Hijack the markers massaging part of creating the waterfall view, + // to prevent collapsing markers and allowing this test to verify + // everything individually. A better solution would be to just expand + // all markers first and then skip the meta nodes, but I'm lazy. + WaterfallView._prepareWaterfallTree = markers => { + return { submarkers: markers }; + }; + + const MARKER_TYPES = [ + "Styles", "Reflow", "ConsoleTime", "TimeStamp" + ]; + + await startRecording(panel); + ok(true, "Recording has started."); + + await waitUntil(() => { + // Wait until we get all the different markers. + let markers = PerformanceController.getCurrentRecording().getMarkers(); + return MARKER_TYPES.every(type => markers.some(m => m.name === type)); + }); + + await stopRecording(panel); + ok(true, "Recording has ended."); + + info("No need to select everything in the timeline."); + info("All the markers should be displayed by default."); + + let bars = Array.prototype.filter.call($$(".waterfall-marker-bar"), + (bar) => MARKER_TYPES.includes(bar.getAttribute("type"))); + let markers = PerformanceController.getCurrentRecording().getMarkers() + .filter(m => MARKER_TYPES.includes(m.name)); + + info(`Got ${bars.length} bars and ${markers.length} markers.`); + info("Markers types from datasrc: " + Array.from(markers, e => e.name)); + info("Markers names from sidebar: " + Array.from(bars, e => e.parentNode.parentNode.querySelector(".waterfall-marker-name").getAttribute("value"))); + + ok(bars.length >= MARKER_TYPES.length, `Got at least ${MARKER_TYPES.length} markers (1)`); + ok(markers.length >= MARKER_TYPES.length, `Got at least ${MARKER_TYPES.length} markers (2)`); + + // Sanity check that markers are in chronologically ascending order + markers.reduce((previous, m) => { + if (m.start <= previous) { + ok(false, "Markers are not in order"); + info(markers); + } + return m.start; + }, 0); + + // Override the timestamp marker's stack with our own recursive stack, which + // can happen for unknown reasons (bug 1246555); we should not cause a crash + // when attempting to render a recursive stack trace + let timestampMarker = markers.find(m => m.name === "ConsoleTime"); + ok(typeof timestampMarker.stack === "number", "ConsoleTime marker has a stack before overwriting."); + let frames = PerformanceController.getCurrentRecording().getFrames(); + let frameIndex = timestampMarker.stack = frames.length; + frames.push({ line: 1, column: 1, source: "file.js", functionDisplayName: "test", parent: frameIndex + 1}); + frames.push({ line: 1, column: 1, source: "file.js", functionDisplayName: "test", parent: frameIndex + 2 }); + frames.push({ line: 1, column: 1, source: "file.js", functionDisplayName: "test", parent: frameIndex }); + + const tests = { + ConsoleTime: function (marker) { + info("Got `ConsoleTime` marker with data: " + JSON.stringify(marker)); + ok(marker.stack === frameIndex, "Should have the ConsoleTime marker with recursive stack"); + shouldHaveStack($, "startStack", marker); + shouldHaveStack($, "endStack", marker); + shouldHaveLabel($, "Timer Name:", "!!!", marker); + return true; + }, + TimeStamp: function (marker) { + info("Got `TimeStamp` marker with data: " + JSON.stringify(marker)); + shouldHaveLabel($, "Label:", "go", marker); + shouldHaveStack($, "stack", marker); + return true; + }, + Styles: function (marker) { + info("Got `Styles` marker with data: " + JSON.stringify(marker)); + if (marker.stack) { + shouldHaveStack($, "stack", marker); + return true; + } + }, + Reflow: function (marker) { + info("Got `Reflow` marker with data: " + JSON.stringify(marker)); + if (marker.stack) { + shouldHaveStack($, "stack", marker); + return true; + } + } + }; + + // Keep track of all marker tests that are finished so we only + // run through each marker test once, so we don't spam 500 redundant + // tests. + let testsDone = []; + + for (let i = 0; i < bars.length; i++) { + let bar = bars[i]; + let m = markers[i]; + await EventUtils.sendMouseEvent({ type: "mousedown" }, bar); + + if (tests[m.name]) { + if (!testsDone.includes(m.name)) { + let fullTestComplete = tests[m.name](m); + if (fullTestComplete) { + testsDone.push(m.name); + } + } + } else { + throw new Error(`No tests for ${m.name} -- should be filtered out.`); + } + + if (testsDone.length === Object.keys(tests).length) { + break; + } + } + + await teardown(panel); + finish(); +} + +function shouldHaveStack($, type, marker) { + ok($(`#waterfall-details .marker-details-stack[type=${type}]`), `${marker.name} has a stack: ${type}`); +} + +function shouldHaveLabel($, name, value, marker) { + let $name = $(`#waterfall-details .marker-details-labelcontainer .marker-details-labelname[value="${name}"]`); + let $value = $name.parentNode.querySelector(".marker-details-labelvalue"); + is($value.getAttribute("value"), value, `${marker.name} has correct label for ${name}:${value}`); +} +/* eslint-enable */ diff --git a/devtools/client/performance/test/browser_perf-options-01-toggle-throw.js b/devtools/client/performance/test/browser_perf-options-01-toggle-throw.js new file mode 100644 index 0000000000..a110d1b2c1 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-01-toggle-throw.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that toggling preferences before there are any recordings does not throw. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { DetailsView, JsCallTreeView } = panel.panelWin; + + await DetailsView.selectView("js-calltree"); + + // Manually call the _onPrefChanged function so we can catch an error. + try { + JsCallTreeView._onPrefChanged(null, "invert-call-tree", true); + ok( + true, + "Toggling preferences before there are any recordings should not fail." + ); + } catch (e) { + ok( + false, + "Toggling preferences before there are any recordings should not fail." + ); + } + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-02-toggle-throw-alt.js b/devtools/client/performance/test/browser_perf-options-02-toggle-throw-alt.js new file mode 100644 index 0000000000..a8dc39390d --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-02-toggle-throw-alt.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that toggling preferences during a recording does not throw. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { DetailsView, JsCallTreeView } = panel.panelWin; + + await DetailsView.selectView("js-calltree"); + await startRecording(panel); + + // Manually call the _onPrefChanged function so we can catch an error. + try { + JsCallTreeView._onPrefChanged(null, "invert-call-tree", true); + ok(true, "Toggling preferences during a recording should not fail."); + } catch (e) { + ok(false, "Toggling preferences during a recording should not fail."); + } + + await stopRecording(panel, { + expectedViewClass: "JsCallTreeView", + expectedViewEvent: "UI_JS_CALL_TREE_RENDERED", + }); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-03-toggle-meta.js b/devtools/client/performance/test/browser_perf-options-03-toggle-meta.js new file mode 100644 index 0000000000..9d0428a97c --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-03-toggle-meta.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that toggling meta option prefs change visibility of other options. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_EXPERIMENTAL_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); + +add_task(async function() { + Services.prefs.setBoolPref(UI_EXPERIMENTAL_PREF, false); + + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { $ } = panel.panelWin; + const $body = $(".theme-body"); + const $menu = $("#performance-options-menupopup"); + + ok( + !$body.classList.contains("experimental-enabled"), + "The body node does not have `experimental-enabled` on start." + ); + ok( + !$menu.classList.contains("experimental-enabled"), + "The menu popup does not have `experimental-enabled` on start." + ); + + Services.prefs.setBoolPref(UI_EXPERIMENTAL_PREF, true); + + ok( + $body.classList.contains("experimental-enabled"), + "The body node has `experimental-enabled` after toggle." + ); + ok( + $menu.classList.contains("experimental-enabled"), + "The menu popup has `experimental-enabled` after toggle." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-enable-framerate-01.js b/devtools/client/performance/test/browser_perf-options-enable-framerate-01.js new file mode 100644 index 0000000000..3bc7a686a3 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-enable-framerate-01.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that `enable-framerate` toggles the visibility of the fps graph, + * as well as enabling ticks data on the PerformanceFront. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_FRAMERATE_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + isVisible, +} = require("devtools/client/performance/test/helpers/dom-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { $, PerformanceController } = panel.panelWin; + + // Disable framerate to test. + Services.prefs.setBoolPref(UI_ENABLE_FRAMERATE_PREF, false); + + await startRecording(panel); + await stopRecording(panel); + + is( + PerformanceController.getCurrentRecording().getConfiguration().withTicks, + false, + "PerformanceFront started without ticks recording." + ); + ok( + !isVisible($("#time-framerate")), + "The fps graph is hidden when ticks disabled." + ); + + // Re-enable framerate. + Services.prefs.setBoolPref(UI_ENABLE_FRAMERATE_PREF, true); + + is( + PerformanceController.getCurrentRecording().getConfiguration().withTicks, + false, + "PerformanceFront still marked without ticks recording." + ); + ok( + !isVisible($("#time-framerate")), + "The fps graph is still hidden if recording does not contain ticks." + ); + + await startRecording(panel); + await stopRecording(panel); + + is( + PerformanceController.getCurrentRecording().getConfiguration().withTicks, + true, + "PerformanceFront started with ticks recording." + ); + ok( + isVisible($("#time-framerate")), + "The fps graph is not hidden when ticks enabled before recording." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-enable-framerate-02.js b/devtools/client/performance/test/browser_perf-options-enable-framerate-02.js new file mode 100644 index 0000000000..2c4da5c89f --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-enable-framerate-02.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that toggling `enable-memory` during a recording doesn't change that + * recording's state and does not break. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_FRAMERATE_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { PerformanceController } = panel.panelWin; + + // Test starting without framerate, and stopping with it. + Services.prefs.setBoolPref(UI_ENABLE_FRAMERATE_PREF, false); + await startRecording(panel); + + Services.prefs.setBoolPref(UI_ENABLE_FRAMERATE_PREF, true); + await stopRecording(panel); + + is( + PerformanceController.getCurrentRecording().getConfiguration().withTicks, + false, + "The recording finished without tracking framerate." + ); + + // Test starting with framerate, and stopping without it. + await startRecording(panel); + + Services.prefs.setBoolPref(UI_ENABLE_FRAMERATE_PREF, false); + await stopRecording(panel); + + is( + PerformanceController.getCurrentRecording().getConfiguration().withTicks, + true, + "The recording finished with tracking framerate." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-enable-memory-01.js b/devtools/client/performance/test/browser_perf-options-enable-memory-01.js new file mode 100644 index 0000000000..e32ad8b84c --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-enable-memory-01.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that `enable-memory` toggles the visibility of the memory graph, + * as well as enabling memory data on the PerformanceFront. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_MEMORY_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + isVisible, +} = require("devtools/client/performance/test/helpers/dom-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { $, PerformanceController } = panel.panelWin; + + // Disable memory to test. + Services.prefs.setBoolPref(UI_ENABLE_MEMORY_PREF, false); + + await startRecording(panel); + await stopRecording(panel); + + is( + PerformanceController.getCurrentRecording().getConfiguration().withMemory, + false, + "PerformanceFront started without memory recording." + ); + is( + PerformanceController.getCurrentRecording().getConfiguration() + .withAllocations, + false, + "PerformanceFront started without allocations recording." + ); + ok( + !isVisible($("#memory-overview")), + "The memory graph is hidden when memory disabled." + ); + + // Re-enable memory. + Services.prefs.setBoolPref(UI_ENABLE_MEMORY_PREF, true); + + is( + PerformanceController.getCurrentRecording().getConfiguration().withMemory, + false, + "PerformanceFront still marked without memory recording." + ); + is( + PerformanceController.getCurrentRecording().getConfiguration() + .withAllocations, + false, + "PerformanceFront still marked without allocations recording." + ); + ok( + !isVisible($("#memory-overview")), + "memory graph is still hidden after enabling " + + "if recording did not start recording memory" + ); + + await startRecording(panel); + await stopRecording(panel); + + is( + PerformanceController.getCurrentRecording().getConfiguration().withMemory, + true, + "PerformanceFront started with memory recording." + ); + is( + PerformanceController.getCurrentRecording().getConfiguration() + .withAllocations, + false, + "PerformanceFront did not record with allocations." + ); + ok( + isVisible($("#memory-overview")), + "The memory graph is not hidden when memory enabled before recording." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-enable-memory-02.js b/devtools/client/performance/test/browser_perf-options-enable-memory-02.js new file mode 100644 index 0000000000..b71fef4ed9 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-enable-memory-02.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that toggling `enable-memory` during a recording doesn't change that + * recording's state and does not break. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_MEMORY_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { PerformanceController } = panel.panelWin; + + // Test starting without memory, and stopping with it. + Services.prefs.setBoolPref(UI_ENABLE_MEMORY_PREF, false); + await startRecording(panel); + + Services.prefs.setBoolPref(UI_ENABLE_MEMORY_PREF, true); + await stopRecording(panel); + + is( + PerformanceController.getCurrentRecording().getConfiguration().withMemory, + false, + "The recording finished without tracking memory." + ); + is( + PerformanceController.getCurrentRecording().getConfiguration() + .withAllocations, + false, + "The recording finished without tracking allocations." + ); + + // Test starting with memory, and stopping without it. + await startRecording(panel); + + Services.prefs.setBoolPref(UI_ENABLE_MEMORY_PREF, false); + await stopRecording(panel); + + is( + PerformanceController.getCurrentRecording().getConfiguration().withMemory, + true, + "The recording finished with tracking memory." + ); + is( + PerformanceController.getCurrentRecording().getConfiguration() + .withAllocations, + false, + "The recording still is not recording allocations." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-flatten-tree-recursion-01.js b/devtools/client/performance/test/browser_perf-options-flatten-tree-recursion-01.js new file mode 100644 index 0000000000..07bdbf9e69 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-flatten-tree-recursion-01.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the js flamegraphs get rerendered when toggling `flatten-tree-recursion`. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_FLATTEN_RECURSION_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { + EVENTS, + PerformanceController, + DetailsView, + JsFlameGraphView, + } = panel.panelWin; + const { + FlameGraphUtils, + } = require("devtools/client/shared/widgets/FlameGraph"); + + Services.prefs.setBoolPref(UI_FLATTEN_RECURSION_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + let rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + await DetailsView.selectView("js-flamegraph"); + await rendered; + + const thread1 = PerformanceController.getCurrentRecording().getProfile() + .threads[0]; + const rendering1 = FlameGraphUtils._cache.get(thread1); + + ok(thread1, "The samples were retrieved from the controller."); + ok(rendering1, "The rendering data was cached."); + + rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + Services.prefs.setBoolPref(UI_FLATTEN_RECURSION_PREF, false); + await rendered; + ok(true, "JsFlameGraphView rerendered when toggling flatten-tree-recursion."); + + const thread2 = PerformanceController.getCurrentRecording().getProfile() + .threads[0]; + const rendering2 = FlameGraphUtils._cache.get(thread2); + + is( + thread1, + thread2, + "The same samples data should be retrieved from the controller (1)." + ); + isnot( + rendering1, + rendering2, + "The rendering data should be different because other options were used (1)." + ); + + rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + Services.prefs.setBoolPref(UI_FLATTEN_RECURSION_PREF, true); + await rendered; + ok( + true, + "JsFlameGraphView rerendered when toggling back flatten-tree-recursion." + ); + + const thread3 = PerformanceController.getCurrentRecording().getProfile() + .threads[0]; + const rendering3 = FlameGraphUtils._cache.get(thread3); + + is( + thread2, + thread3, + "The same samples data should be retrieved from the controller (2)." + ); + isnot( + rendering2, + rendering3, + "The rendering data should be different because other options were used (2)." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-flatten-tree-recursion-02.js b/devtools/client/performance/test/browser_perf-options-flatten-tree-recursion-02.js new file mode 100644 index 0000000000..d7e8a0cc06 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-flatten-tree-recursion-02.js @@ -0,0 +1,124 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the memory flamegraphs get rerendered when toggling + * `flatten-tree-recursion`. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_FLATTEN_RECURSION_PREF, + UI_ENABLE_ALLOCATIONS_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { + EVENTS, + PerformanceController, + DetailsView, + MemoryFlameGraphView, + } = panel.panelWin; + + const { + FlameGraphUtils, + } = require("devtools/client/shared/widgets/FlameGraph"); + const RecordingUtils = require("devtools/shared/performance/recording-utils"); + + // Enable memory to test + Services.prefs.setBoolPref(UI_ENABLE_ALLOCATIONS_PREF, true); + Services.prefs.setBoolPref(UI_FLATTEN_RECURSION_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + let rendered = once( + MemoryFlameGraphView, + EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED + ); + await DetailsView.selectView("memory-flamegraph"); + await rendered; + + const allocations1 = PerformanceController.getCurrentRecording().getAllocations(); + const thread1 = RecordingUtils.getProfileThreadFromAllocations(allocations1); + const rendering1 = FlameGraphUtils._cache.get(thread1); + + ok(allocations1, "The allocations were retrieved from the controller."); + ok(thread1, "The allocations profile was synthesized by the utility funcs."); + ok(rendering1, "The rendering data was cached."); + + rendered = once(MemoryFlameGraphView, EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED); + Services.prefs.setBoolPref(UI_FLATTEN_RECURSION_PREF, false); + await rendered; + ok( + true, + "MemoryFlameGraphView rerendered when toggling flatten-tree-recursion." + ); + + const allocations2 = PerformanceController.getCurrentRecording().getAllocations(); + const thread2 = RecordingUtils.getProfileThreadFromAllocations(allocations2); + const rendering2 = FlameGraphUtils._cache.get(thread2); + + is( + allocations1, + allocations2, + "The same allocations data should be retrieved from the controller (1)." + ); + is( + thread1, + thread2, + "The same allocations profile should be retrieved from the utility funcs. (1)." + ); + isnot( + rendering1, + rendering2, + "The rendering data should be different because other options were used (1)." + ); + + rendered = once(MemoryFlameGraphView, EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED); + Services.prefs.setBoolPref(UI_FLATTEN_RECURSION_PREF, true); + await rendered; + ok( + true, + "MemoryFlameGraphView rerendered when toggling back flatten-tree-recursion." + ); + + const allocations3 = PerformanceController.getCurrentRecording().getAllocations(); + const thread3 = RecordingUtils.getProfileThreadFromAllocations(allocations3); + const rendering3 = FlameGraphUtils._cache.get(thread3); + + is( + allocations2, + allocations3, + "The same allocations data should be retrieved from the controller (2)." + ); + is( + thread2, + thread3, + "The same allocations profile should be retrieved from the utility funcs. (2)." + ); + isnot( + rendering2, + rendering3, + "The rendering data should be different because other options were used (2)." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-invert-call-tree-01.js b/devtools/client/performance/test/browser_perf-options-invert-call-tree-01.js new file mode 100644 index 0000000000..65fcd4f866 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-invert-call-tree-01.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the js call tree views get rerendered when toggling `invert-call-tree`. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_INVERT_CALL_TREE_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, DetailsView, JsCallTreeView } = panel.panelWin; + + Services.prefs.setBoolPref(UI_INVERT_CALL_TREE_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + let rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + await DetailsView.selectView("js-calltree"); + await rendered; + + rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + Services.prefs.setBoolPref(UI_INVERT_CALL_TREE_PREF, false); + await rendered; + ok(true, "JsCallTreeView rerendered when toggling invert-call-tree."); + + rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + Services.prefs.setBoolPref(UI_INVERT_CALL_TREE_PREF, true); + await rendered; + ok(true, "JsCallTreeView rerendered when toggling back invert-call-tree."); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-invert-call-tree-02.js b/devtools/client/performance/test/browser_perf-options-invert-call-tree-02.js new file mode 100644 index 0000000000..c594e9624e --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-invert-call-tree-02.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the memory call tree views get rerendered when toggling `invert-call-tree`. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_ALLOCATIONS_PREF, + UI_INVERT_CALL_TREE_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, DetailsView, MemoryCallTreeView } = panel.panelWin; + + // Enable allocations to test. + Services.prefs.setBoolPref(UI_ENABLE_ALLOCATIONS_PREF, true); + Services.prefs.setBoolPref(UI_INVERT_CALL_TREE_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + let rendered = once(MemoryCallTreeView, EVENTS.UI_MEMORY_CALL_TREE_RENDERED); + await DetailsView.selectView("memory-calltree"); + await rendered; + + rendered = once(MemoryCallTreeView, EVENTS.UI_MEMORY_CALL_TREE_RENDERED); + Services.prefs.setBoolPref(UI_INVERT_CALL_TREE_PREF, false); + await rendered; + ok(true, "MemoryCallTreeView rerendered when toggling invert-call-tree."); + + rendered = once(MemoryCallTreeView, EVENTS.UI_MEMORY_CALL_TREE_RENDERED); + Services.prefs.setBoolPref(UI_INVERT_CALL_TREE_PREF, true); + await rendered; + ok( + true, + "MemoryCallTreeView rerendered when toggling back invert-call-tree." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-invert-flame-graph-01.js b/devtools/client/performance/test/browser_perf-options-invert-flame-graph-01.js new file mode 100644 index 0000000000..1416a59df4 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-invert-flame-graph-01.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the js flamegraphs views get rerendered when toggling `invert-flame-graph`. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_INVERT_FLAME_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, DetailsView, JsFlameGraphView } = panel.panelWin; + + Services.prefs.setBoolPref(UI_INVERT_FLAME_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + let rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + await DetailsView.selectView("js-flamegraph"); + await rendered; + + rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + Services.prefs.setBoolPref(UI_INVERT_FLAME_PREF, false); + await rendered; + ok(true, "JsFlameGraphView rerendered when toggling invert-call-tree."); + + rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + Services.prefs.setBoolPref(UI_INVERT_FLAME_PREF, true); + await rendered; + ok(true, "JsFlameGraphView rerendered when toggling back invert-call-tree."); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-invert-flame-graph-02.js b/devtools/client/performance/test/browser_perf-options-invert-flame-graph-02.js new file mode 100644 index 0000000000..a968c17c68 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-invert-flame-graph-02.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the memory flamegraphs views get rerendered when toggling + * `invert-flame-graph`. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_ALLOCATIONS_PREF, + UI_INVERT_FLAME_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, DetailsView, MemoryFlameGraphView } = panel.panelWin; + + // Enable allocations to test. + Services.prefs.setBoolPref(UI_ENABLE_ALLOCATIONS_PREF, true); + Services.prefs.setBoolPref(UI_INVERT_FLAME_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + let rendered = once( + MemoryFlameGraphView, + EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED + ); + await DetailsView.selectView("memory-flamegraph"); + await rendered; + + rendered = once(MemoryFlameGraphView, EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED); + Services.prefs.setBoolPref(UI_INVERT_FLAME_PREF, false); + await rendered; + ok(true, "MemoryFlameGraphView rerendered when toggling invert-call-tree."); + + rendered = once(MemoryFlameGraphView, EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED); + Services.prefs.setBoolPref(UI_INVERT_FLAME_PREF, true); + await rendered; + ok( + true, + "MemoryFlameGraphView rerendered when toggling back invert-call-tree." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-propagate-allocations.js b/devtools/client/performance/test/browser_perf-options-propagate-allocations.js new file mode 100644 index 0000000000..330085f9db --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-propagate-allocations.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that setting the `devtools.performance.memory.` prefs propagate to + * the memory actor. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + MEMORY_SAMPLE_PROB_PREF, + MEMORY_MAX_LOG_LEN_PREF, + UI_ENABLE_ALLOCATIONS_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); + +add_task(async function() { + const { panel, toolbox } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + // Enable allocations to test. + Services.prefs.setBoolPref(UI_ENABLE_ALLOCATIONS_PREF, true); + Services.prefs.setCharPref(MEMORY_SAMPLE_PROB_PREF, "0.213"); + Services.prefs.setIntPref(MEMORY_MAX_LOG_LEN_PREF, 777777); + + await startRecording(panel); + const performanceFront = await toolbox.target.getFront("performance"); + const { + probability, + maxLogLength, + } = await performanceFront.getConfiguration(); + await stopRecording(panel); + + is( + probability, + 0.213, + "The allocations probability option is set on memory actor." + ); + is( + maxLogLength, + 777777, + "The allocations max log length option is set on memory actor." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-propagate-profiler.js b/devtools/client/performance/test/browser_perf-options-propagate-profiler.js new file mode 100644 index 0000000000..2a969d353a --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-propagate-profiler.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that setting the `devtools.performance.profiler.` prefs propagate + * to the profiler actor. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + PROFILER_BUFFER_SIZE_PREF, + PROFILER_SAMPLE_RATE_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); + +add_task(async function() { + const { panel, toolbox } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + Services.prefs.setIntPref(PROFILER_BUFFER_SIZE_PREF, 1000); + Services.prefs.setIntPref(PROFILER_SAMPLE_RATE_PREF, 2000); + + await startRecording(panel); + const performanceFront = await toolbox.target.getFront("performance"); + const { entries, interval } = await performanceFront.getConfiguration(); + await stopRecording(panel); + + is(entries, 1000, "profiler entries option is set on profiler"); + is(interval, 0.5, "profiler interval option is set on profiler"); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-show-idle-blocks-01.js b/devtools/client/performance/test/browser_perf-options-show-idle-blocks-01.js new file mode 100644 index 0000000000..b62e13c794 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-show-idle-blocks-01.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the js flamegraphs get rerendered when toggling `show-idle-blocks`. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_SHOW_IDLE_BLOCKS_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, DetailsView, JsFlameGraphView } = panel.panelWin; + + Services.prefs.setBoolPref(UI_SHOW_IDLE_BLOCKS_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + let rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + await DetailsView.selectView("js-flamegraph"); + await rendered; + + rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + Services.prefs.setBoolPref(UI_SHOW_IDLE_BLOCKS_PREF, false); + await rendered; + ok(true, "JsFlameGraphView rerendered when toggling show-idle-blocks."); + + rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + Services.prefs.setBoolPref(UI_SHOW_IDLE_BLOCKS_PREF, true); + await rendered; + ok(true, "JsFlameGraphView rerendered when toggling back show-idle-blocks."); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-show-idle-blocks-02.js b/devtools/client/performance/test/browser_perf-options-show-idle-blocks-02.js new file mode 100644 index 0000000000..fbc500f9fb --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-show-idle-blocks-02.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the memory flamegraphs get rerendered when toggling `show-idle-blocks`. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_ALLOCATIONS_PREF, + UI_SHOW_IDLE_BLOCKS_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, DetailsView, MemoryFlameGraphView } = panel.panelWin; + + // Enable allocations to test. + Services.prefs.setBoolPref(UI_ENABLE_ALLOCATIONS_PREF, true); + Services.prefs.setBoolPref(UI_SHOW_IDLE_BLOCKS_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + let rendered = once( + MemoryFlameGraphView, + EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED + ); + await DetailsView.selectView("memory-flamegraph"); + await rendered; + + rendered = once(MemoryFlameGraphView, EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED); + Services.prefs.setBoolPref(UI_SHOW_IDLE_BLOCKS_PREF, false); + await rendered; + ok(true, "MemoryFlameGraphView rerendered when toggling show-idle-blocks."); + + rendered = once(MemoryFlameGraphView, EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED); + Services.prefs.setBoolPref(UI_SHOW_IDLE_BLOCKS_PREF, true); + await rendered; + ok( + true, + "MemoryFlameGraphView rerendered when toggling back show-idle-blocks." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-show-jit-optimizations.js b/devtools/client/performance/test/browser_perf-options-show-jit-optimizations.js new file mode 100644 index 0000000000..62cf4b15eb --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-show-jit-optimizations.js @@ -0,0 +1,303 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/* eslint-disable */ +// Bug 1235788, increase time out of this test +requestLongerTimeout(2); + +/** + * Tests that the JIT Optimizations view renders optimization data + * if on, and displays selected frames on focus. + */ +const { + setSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); +Services.prefs.setBoolPref(INVERT_PREF, false); + +async function spawnTest() { + let { panel } = await initPerformance(SIMPLE_URL); + let { EVENTS, $, $$, window, PerformanceController } = panel.panelWin; + let { + OverviewView, + DetailsView, + OptimizationsListView, + JsCallTreeView, + } = panel.panelWin; + + let profilerData = { threads: [gThread] }; + + is( + Services.prefs.getBoolPref(JIT_PREF), + false, + "record JIT Optimizations pref off by default" + ); + Services.prefs.setBoolPref(JIT_PREF, true); + is( + Services.prefs.getBoolPref(JIT_PREF), + true, + "toggle on record JIT Optimizations" + ); + + // Make two recordings, so we have one to switch to later, as the + // second one will have fake sample data + await startRecording(panel); + await stopRecording(panel); + + await startRecording(panel); + await stopRecording(panel); + + await DetailsView.selectView("js-calltree"); + + await injectAndRenderProfilerData(); + + is( + $("#jit-optimizations-view").classList.contains("hidden"), + true, + "JIT Optimizations should be hidden when pref is on, but no frame selected" + ); + + // A is never a leaf, so it's optimizations should not be shown. + await checkFrame(1); + + // gRawSite2 and gRawSite3 are both optimizations on B, so they'll have + // indices in descending order of # of samples. + await checkFrame(2, true); + + // Leaf node (C) with no optimizations should not display any opts. + await checkFrame(3); + + // Select the node with optimizations and change to a new recording + // to ensure the opts view is cleared + let rendered = once(JsCallTreeView, "focus"); + await mousedown(window, $$(".call-tree-item")[2]); + await rendered; + let isHidden = $("#jit-optimizations-view").classList.contains("hidden"); + ok(!isHidden, "opts view should be visible when selecting a frame with opts"); + + let select = once(PerformanceController, EVENTS.RECORDING_SELECTED); + rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + setSelectedRecording(panel, 0); + await Promise.all([select, rendered]); + + isHidden = $("#jit-optimizations-view").classList.contains("hidden"); + ok(isHidden, "opts view is hidden when switching recordings"); + + rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + setSelectedRecording(panel, 1); + await rendered; + + rendered = once(JsCallTreeView, "focus"); + await mousedown(window, $$(".call-tree-item")[2]); + await rendered; + isHidden = $("#jit-optimizations-view").classList.contains("hidden"); + ok(!isHidden, "opts view should be visible when selecting a frame with opts"); + + rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + Services.prefs.setBoolPref(JIT_PREF, false); + await rendered; + ok(true, "call tree rerendered when JIT pref changes"); + isHidden = $("#jit-optimizations-view").classList.contains("hidden"); + ok(isHidden, "opts view hidden when toggling off jit pref"); + + rendered = once(JsCallTreeView, "focus"); + await mousedown(window, $$(".call-tree-item")[2]); + await rendered; + isHidden = $("#jit-optimizations-view").classList.contains("hidden"); + ok( + isHidden, + "opts view hidden when jit pref off and selecting a frame with opts" + ); + + await teardown(panel); + finish(); + + async function injectAndRenderProfilerData() { + // Get current recording and inject our mock data + info("Injecting mock profile data"); + let recording = PerformanceController.getCurrentRecording(); + recording._profile = profilerData; + + // Force a rerender + let rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + JsCallTreeView.render(OverviewView.getTimeInterval()); + await rendered; + } + + async function checkFrame(frameIndex, hasOpts) { + info(`Checking frame ${frameIndex}`); + // Click the frame + let rendered = once(JsCallTreeView, "focus"); + await mousedown(window, $$(".call-tree-item")[frameIndex]); + await rendered; + + let isHidden = $("#jit-optimizations-view").classList.contains("hidden"); + if (hasOpts) { + ok( + !isHidden, + "JIT Optimizations view is not hidden if current frame has opts." + ); + } else { + ok( + isHidden, + "JIT Optimizations view is hidden if current frame does not have opts" + ); + } + } +} + +var gUniqueStacks = new RecordingUtils.UniqueStacks(); + +function uniqStr(s) { + return gUniqueStacks.getOrAddStringIndex(s); +} + +// Since deflateThread doesn't handle deflating optimization info, use +// placeholder names A_O1, B_O2, and B_O3, which will be used to manually +// splice deduped opts into the profile. +var gThread = RecordingUtils.deflateThread( + { + samples: [ + { + time: 0, + frames: [{ location: "(root)" }], + }, + { + time: 5, + frames: [ + { location: "(root)" }, + { location: "A_O1" }, + { location: "B_O2" }, + { location: "C (http://foo/bar/baz:56)" }, + ], + }, + { + time: 5 + 1, + frames: [ + { location: "(root)" }, + { location: "A (http://foo/bar/baz:12)" }, + { location: "B_O2" }, + ], + }, + { + time: 5 + 1 + 2, + frames: [ + { location: "(root)" }, + { location: "A_O1" }, + { location: "B_O3" }, + ], + }, + { + time: 5 + 1 + 2 + 7, + frames: [ + { location: "(root)" }, + { location: "A_O1" }, + { location: "E (http://foo/bar/baz:90)" }, + { location: "F (http://foo/bar/baz:99)" }, + ], + }, + ], + markers: [], + }, + gUniqueStacks +); + +// 3 RawOptimizationSites +var gRawSite1 = { + _testFrameInfo: { name: "A", line: "12", file: "@baz" }, + line: 12, + column: 2, + types: [ + { + mirType: uniqStr("Object"), + site: uniqStr("A (http://foo/bar/bar:12)"), + typeset: [ + { + keyedBy: uniqStr("constructor"), + name: uniqStr("Foo"), + location: uniqStr("A (http://foo/bar/baz:12)"), + }, + { + keyedBy: uniqStr("primitive"), + location: uniqStr("self-hosted"), + }, + ], + }, + ], + attempts: { + schema: { + outcome: 0, + strategy: 1, + }, + data: [ + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure2"), uniqStr("SomeGetter2")], + [uniqStr("Failure3"), uniqStr("SomeGetter3")], + ], + }, +}; + +var gRawSite2 = { + _testFrameInfo: { name: "B", line: "10", file: "@boo" }, + line: 40, + types: [ + { + mirType: uniqStr("Int32"), + site: uniqStr("Receiver"), + }, + ], + attempts: { + schema: { + outcome: 0, + strategy: 1, + }, + data: [ + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure2"), uniqStr("SomeGetter2")], + [uniqStr("Inlined"), uniqStr("SomeGetter3")], + ], + }, +}; + +var gRawSite3 = { + _testFrameInfo: { name: "B", line: "10", file: "@boo" }, + line: 34, + types: [ + { + mirType: uniqStr("Int32"), + site: uniqStr("Receiver"), + }, + ], + attempts: { + schema: { + outcome: 0, + strategy: 1, + }, + data: [ + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure2"), uniqStr("SomeGetter2")], + [uniqStr("Failure3"), uniqStr("SomeGetter3")], + ], + }, +}; + +gThread.frameTable.data.forEach(frame => { + const LOCATION_SLOT = gThread.frameTable.schema.location; + const OPTIMIZATIONS_SLOT = gThread.frameTable.schema.optimizations; + + let l = gThread.stringTable[frame[LOCATION_SLOT]]; + switch (l) { + case "A_O1": + frame[LOCATION_SLOT] = uniqStr("A (http://foo/bar/baz:12)"); + frame[OPTIMIZATIONS_SLOT] = gRawSite1; + break; + case "B_O2": + frame[LOCATION_SLOT] = uniqStr("B (http://foo/bar/boo:10)"); + frame[OPTIMIZATIONS_SLOT] = gRawSite2; + break; + case "B_O3": + frame[LOCATION_SLOT] = uniqStr("B (http://foo/bar/boo:10)"); + frame[OPTIMIZATIONS_SLOT] = gRawSite3; + break; + } +}); +/* eslint-enable */ diff --git a/devtools/client/performance/test/browser_perf-options-show-platform-data-01.js b/devtools/client/performance/test/browser_perf-options-show-platform-data-01.js new file mode 100644 index 0000000000..0f0d9f4ced --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-show-platform-data-01.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the js call tree views get rerendered when toggling `show-platform-data`. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_SHOW_PLATFORM_DATA_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, DetailsView, JsCallTreeView } = panel.panelWin; + + Services.prefs.setBoolPref(UI_SHOW_PLATFORM_DATA_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + let rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + await DetailsView.selectView("js-calltree"); + await rendered; + + rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + Services.prefs.setBoolPref(UI_SHOW_PLATFORM_DATA_PREF, false); + await rendered; + ok(true, "JsCallTreeView rerendered when toggling show-idle-blocks."); + + rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + Services.prefs.setBoolPref(UI_SHOW_PLATFORM_DATA_PREF, true); + await rendered; + ok(true, "JsCallTreeView rerendered when toggling back show-idle-blocks."); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-options-show-platform-data-02.js b/devtools/client/performance/test/browser_perf-options-show-platform-data-02.js new file mode 100644 index 0000000000..63317baf70 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-options-show-platform-data-02.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the js flamegraphs views get rerendered when toggling `show-platform-data`. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_SHOW_PLATFORM_DATA_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, DetailsView, JsFlameGraphView } = panel.panelWin; + + Services.prefs.setBoolPref(UI_SHOW_PLATFORM_DATA_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + let rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + await DetailsView.selectView("js-flamegraph"); + await rendered; + + rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + Services.prefs.setBoolPref(UI_SHOW_PLATFORM_DATA_PREF, false); + await rendered; + ok(true, "JsFlameGraphView rerendered when toggling show-idle-blocks."); + + rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + Services.prefs.setBoolPref(UI_SHOW_PLATFORM_DATA_PREF, true); + await rendered; + ok(true, "JsFlameGraphView rerendered when toggling back show-idle-blocks."); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-overview-render-01.js b/devtools/client/performance/test/browser_perf-overview-render-01.js new file mode 100644 index 0000000000..49eaafe1b3 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-overview-render-01.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the overview continuously renders content when recording. + */ + +const { Constants } = require("devtools/client/performance/modules/constants"); +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + times, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, OverviewView } = panel.panelWin; + + await startRecording(panel); + + // Ensure overview keeps rendering. + await times(OverviewView, EVENTS.UI_OVERVIEW_RENDERED, 3, { + expectedArgs: [Constants.FRAMERATE_GRAPH_LOW_RES_INTERVAL], + }); + + ok(true, "Overview was rendered while recording."); + + await stopRecording(panel); + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-overview-render-02.js b/devtools/client/performance/test/browser_perf-overview-render-02.js new file mode 100644 index 0000000000..bc517ff170 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-overview-render-02.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the overview graphs cannot be selected during recording + * and that they're cleared upon rerecording. + */ + +const { Constants } = require("devtools/client/performance/modules/constants"); +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_MEMORY_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + times, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, OverviewView } = panel.panelWin; + + // Enable memory to test. + Services.prefs.setBoolPref(UI_ENABLE_MEMORY_PREF, true); + + await startRecording(panel); + + const framerate = OverviewView.graphs.get("framerate"); + const markers = OverviewView.graphs.get("timeline"); + const memory = OverviewView.graphs.get("memory"); + + ok( + "selectionEnabled" in framerate, + "The selection should not be enabled for the framerate overview (1)." + ); + is( + framerate.selectionEnabled, + false, + "The selection should not be enabled for the framerate overview (2)." + ); + is( + framerate.hasSelection(), + false, + "The framerate overview shouldn't have a selection before recording." + ); + + ok( + "selectionEnabled" in markers, + "The selection should not be enabled for the markers overview (1)." + ); + is( + markers.selectionEnabled, + false, + "The selection should not be enabled for the markers overview (2)." + ); + is( + markers.hasSelection(), + false, + "The markers overview shouldn't have a selection before recording." + ); + + ok( + "selectionEnabled" in memory, + "The selection should not be enabled for the memory overview (1)." + ); + is( + memory.selectionEnabled, + false, + "The selection should not be enabled for the memory overview (2)." + ); + is( + memory.hasSelection(), + false, + "The memory overview shouldn't have a selection before recording." + ); + + // Ensure overview keeps rendering. + await times(OverviewView, EVENTS.UI_OVERVIEW_RENDERED, 3, { + expectedArgs: [Constants.FRAMERATE_GRAPH_LOW_RES_INTERVAL], + }); + + ok( + "selectionEnabled" in framerate, + "The selection should still not be enabled for the framerate overview (1)." + ); + is( + framerate.selectionEnabled, + false, + "The selection should still not be enabled for the framerate overview (2)." + ); + is( + framerate.hasSelection(), + false, + "The framerate overview still shouldn't have a selection before recording." + ); + + ok( + "selectionEnabled" in markers, + "The selection should still not be enabled for the markers overview (1)." + ); + is( + markers.selectionEnabled, + false, + "The selection should still not be enabled for the markers overview (2)." + ); + is( + markers.hasSelection(), + false, + "The markers overview still shouldn't have a selection before recording." + ); + + ok( + "selectionEnabled" in memory, + "The selection should still not be enabled for the memory overview (1)." + ); + is( + memory.selectionEnabled, + false, + "The selection should still not be enabled for the memory overview (2)." + ); + is( + memory.hasSelection(), + false, + "The memory overview still shouldn't have a selection before recording." + ); + + await stopRecording(panel); + + is( + framerate.selectionEnabled, + true, + "The selection should now be enabled for the framerate overview." + ); + is( + markers.selectionEnabled, + true, + "The selection should now be enabled for the markers overview." + ); + is( + memory.selectionEnabled, + true, + "The selection should now be enabled for the memory overview." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-overview-render-03.js b/devtools/client/performance/test/browser_perf-overview-render-03.js new file mode 100644 index 0000000000..05968827ea --- /dev/null +++ b/devtools/client/performance/test/browser_perf-overview-render-03.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the overview graphs share the exact same width and scaling. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_MEMORY_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + waitUntil, +} = require("devtools/client/performance/test/helpers/wait-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { PerformanceController, OverviewView } = panel.panelWin; + + // Enable memory to test. + Services.prefs.setBoolPref(UI_ENABLE_MEMORY_PREF, true); + + const doChecks = () => { + const markers = OverviewView.graphs.get("timeline"); + const framerate = OverviewView.graphs.get("framerate"); + const memory = OverviewView.graphs.get("memory"); + + ok(markers.width > 0, "The overview's markers graph has a width."); + ok( + markers.dataScaleX > 0, + "The overview's markers graph has a data scale factor." + ); + + ok(memory.width > 0, "The overview's memory graph has a width."); + ok( + memory.dataDuration > 0, + "The overview's memory graph has a data duration." + ); + ok( + memory.dataScaleX > 0, + "The overview's memory graph has a data scale factor." + ); + + ok(framerate.width > 0, "The overview's framerate graph has a width."); + ok( + framerate.dataDuration > 0, + "The overview's framerate graph has a data duration." + ); + ok( + framerate.dataScaleX > 0, + "The overview's framerate graph has a data scale factor." + ); + + is( + markers.width, + memory.width, + "The markers and memory graphs widths are the same." + ); + is( + markers.width, + framerate.width, + "The markers and framerate graphs widths are the same." + ); + + is( + memory.dataDuration, + framerate.dataDuration, + "The memory and framerate graphs data duration are the same." + ); + + is( + markers.dataScaleX, + memory.dataScaleX, + "The markers and memory graphs data scale are the same." + ); + is( + markers.dataScaleX, + framerate.dataScaleX, + "The markers and framerate graphs data scale are the same." + ); + }; + + await startRecording(panel); + doChecks(); + + await waitUntil( + () => PerformanceController.getCurrentRecording().getMarkers().length + ); + await waitUntil( + () => PerformanceController.getCurrentRecording().getMemory().length + ); + await waitUntil( + () => PerformanceController.getCurrentRecording().getTicks().length + ); + doChecks(); + + await stopRecording(panel); + doChecks(); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-overview-render-04.js b/devtools/client/performance/test/browser_perf-overview-render-04.js new file mode 100644 index 0000000000..e4b156ed83 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-overview-render-04.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the overview graphs do not render when realtime rendering is off + * due to lack of e10s. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_MEMORY_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + waitUntil, +} = require("devtools/client/performance/test/helpers/wait-utils"); +const { + isVisible, +} = require("devtools/client/performance/test/helpers/dom-utils"); +const { + setSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { $, EVENTS, PerformanceController, OverviewView } = panel.panelWin; + + // Enable memory to test. + Services.prefs.setBoolPref(UI_ENABLE_MEMORY_PREF, true); + + // Set realtime rendering off. + OverviewView.isRealtimeRenderingEnabled = () => false; + + let updated = 0; + OverviewView.on(EVENTS.UI_OVERVIEW_RENDERED, () => updated++); + + await startRecording(panel, { skipWaitingForOverview: true }); + + is(isVisible($("#overview-pane")), false, "Overview graphs hidden."); + is(updated, 0, "Overview graphs have not been updated"); + + await waitUntil( + () => PerformanceController.getCurrentRecording().getMarkers().length + ); + await waitUntil( + () => PerformanceController.getCurrentRecording().getMemory().length + ); + await waitUntil( + () => PerformanceController.getCurrentRecording().getTicks().length + ); + is(isVisible($("#overview-pane")), false, "Overview graphs still hidden."); + is(updated, 0, "Overview graphs have still not been updated"); + + await stopRecording(panel); + + is(isVisible($("#overview-pane")), true, "Overview graphs no longer hidden."); + is(updated, 1, "Overview graphs rendered upon completion."); + + await startRecording(panel, { skipWaitingForOverview: true }); + + is( + isVisible($("#overview-pane")), + false, + "Overview graphs hidden again when starting new recording." + ); + is(updated, 1, "Overview graphs have not been updated again."); + + setSelectedRecording(panel, 0); + is( + isVisible($("#overview-pane")), + true, + "Overview graphs no longer hidden when switching back to complete recording." + ); + is(updated, 1, "Overview graphs have not been updated again."); + + setSelectedRecording(panel, 1); + is( + isVisible($("#overview-pane")), + false, + "Overview graphs hidden again when going back to inprogress recording." + ); + is(updated, 1, "Overview graphs have not been updated again."); + + await stopRecording(panel); + + is( + isVisible($("#overview-pane")), + true, + "overview graphs no longer hidden when recording finishes" + ); + is(updated, 2, "Overview graphs rendered again upon completion."); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-overview-selection-01.js b/devtools/client/performance/test/browser_perf-overview-selection-01.js new file mode 100644 index 0000000000..17d9516bfc --- /dev/null +++ b/devtools/client/performance/test/browser_perf-overview-selection-01.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that events are fired from selection manipulation. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + dragStartCanvasGraph, + dragStopCanvasGraph, + clickCanvasGraph, +} = require("devtools/client/performance/test/helpers/input-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, PerformanceController, OverviewView } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + const duration = PerformanceController.getCurrentRecording().getDuration(); + const graph = OverviewView.graphs.get("timeline"); + + // Select the first half of the graph. + + let rangeSelected = once(OverviewView, EVENTS.UI_OVERVIEW_RANGE_SELECTED, { + spreadArgs: true, + }); + dragStartCanvasGraph(graph, { x: 0 }); + let [{ startTime, endTime }] = await rangeSelected; + is(endTime, duration, "The selected range is the entire graph, for now."); + + rangeSelected = once(OverviewView, EVENTS.UI_OVERVIEW_RANGE_SELECTED, { + spreadArgs: true, + }); + dragStopCanvasGraph(graph, { x: graph.width / 2 }); + [{ startTime, endTime }] = await rangeSelected; + is(endTime, duration / 2, "The selected range is half of the graph."); + + is(graph.hasSelection(), true, "A selection exists on the graph."); + is( + startTime, + 0, + "The UI_OVERVIEW_RANGE_SELECTED event fired with 0 as a `startTime`." + ); + is( + endTime, + duration / 2, + `The UI_OVERVIEW_RANGE_SELECTED event fired with ${duration / + 2} as \`endTime\`.` + ); + + const mapStart = () => 0; + const mapEnd = () => duration; + const actual = graph.getMappedSelection({ mapStart, mapEnd }); + is(actual.min, 0, "Graph selection starts at 0."); + is(actual.max, duration / 2, `Graph selection ends at ${duration / 2}.`); + + // Listen to deselection. + + rangeSelected = once(OverviewView, EVENTS.UI_OVERVIEW_RANGE_SELECTED, { + spreadArgs: true, + }); + clickCanvasGraph(graph, { x: (3 * graph.width) / 4 }); + [{ startTime, endTime }] = await rangeSelected; + + is(graph.hasSelection(), false, "A selection no longer on the graph."); + is( + startTime, + 0, + "The UI_OVERVIEW_RANGE_SELECTED event fired with 0 as a `startTime`." + ); + is( + endTime, + duration, + "The UI_OVERVIEW_RANGE_SELECTED event fired with duration as `endTime`." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-overview-selection-02.js b/devtools/client/performance/test/browser_perf-overview-selection-02.js new file mode 100644 index 0000000000..549f1e9287 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-overview-selection-02.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the graphs' selection is correctly disabled or enabled. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_MEMORY_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { OverviewView } = panel.panelWin; + + // Enable memory to test. + Services.prefs.setBoolPref(UI_ENABLE_MEMORY_PREF, true); + + await startRecording(panel); + + const markersOverview = OverviewView.graphs.get("timeline"); + const memoryGraph = OverviewView.graphs.get("memory"); + const framerateGraph = OverviewView.graphs.get("framerate"); + + ok(markersOverview, "The markers graph should have been created now."); + ok(memoryGraph, "The memory graph should have been created now."); + ok(framerateGraph, "The framerate graph should have been created now."); + + ok( + !markersOverview.selectionEnabled, + "Selection shouldn't be enabled when the first recording started (2)." + ); + ok( + !memoryGraph.selectionEnabled, + "Selection shouldn't be enabled when the first recording started (3)." + ); + ok( + !framerateGraph.selectionEnabled, + "Selection shouldn't be enabled when the first recording started (1)." + ); + + await stopRecording(panel); + + ok( + markersOverview.selectionEnabled, + "Selection should be enabled when the first recording finishes (2)." + ); + ok( + memoryGraph.selectionEnabled, + "Selection should be enabled when the first recording finishes (3)." + ); + ok( + framerateGraph.selectionEnabled, + "Selection should be enabled when the first recording finishes (1)." + ); + + await startRecording(panel); + + ok( + !markersOverview.selectionEnabled, + "Selection shouldn't be enabled when the second recording started (2)." + ); + ok( + !memoryGraph.selectionEnabled, + "Selection shouldn't be enabled when the second recording started (3)." + ); + ok( + !framerateGraph.selectionEnabled, + "Selection shouldn't be enabled when the second recording started (1)." + ); + + await stopRecording(panel); + + ok( + markersOverview.selectionEnabled, + "Selection should be enabled when the first second finishes (2)." + ); + ok( + memoryGraph.selectionEnabled, + "Selection should be enabled when the first second finishes (3)." + ); + ok( + framerateGraph.selectionEnabled, + "Selection should be enabled when the first second finishes (1)." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-overview-selection-03.js b/devtools/client/performance/test/browser_perf-overview-selection-03.js new file mode 100644 index 0000000000..7dcdbe95de --- /dev/null +++ b/devtools/client/performance/test/browser_perf-overview-selection-03.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the graphs' selections are linked. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_MEMORY_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + times, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + dragStartCanvasGraph, + dragStopCanvasGraph, +} = require("devtools/client/performance/test/helpers/input-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, OverviewView } = panel.panelWin; + + // Enable memory to test. + Services.prefs.setBoolPref(UI_ENABLE_MEMORY_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + const markersOverview = OverviewView.graphs.get("timeline"); + const memoryGraph = OverviewView.graphs.get("memory"); + const framerateGraph = OverviewView.graphs.get("framerate"); + const width = framerateGraph.width; + + // Perform a selection inside the framerate graph. + + let rangeSelected = times(OverviewView, EVENTS.UI_OVERVIEW_RANGE_SELECTED, 2); + dragStartCanvasGraph(framerateGraph, { x: 0 }); + dragStopCanvasGraph(framerateGraph, { x: width / 2 }); + await rangeSelected; + + is( + JSON.stringify(markersOverview.getSelection()), + JSON.stringify(framerateGraph.getSelection()), + "The markers overview has a correct selection." + ); + is( + JSON.stringify(memoryGraph.getSelection()), + JSON.stringify(framerateGraph.getSelection()), + "The memory overview has a correct selection." + ); + is( + JSON.stringify(framerateGraph.getSelection()), + JSON.stringify({ start: 0, end: width / 2 }), + "The framerate graph has a correct selection." + ); + + // Perform a selection inside the markers overview. + + markersOverview.dropSelection(); + + rangeSelected = times(OverviewView, EVENTS.UI_OVERVIEW_RANGE_SELECTED, 2); + dragStartCanvasGraph(markersOverview, { x: 0 }); + dragStopCanvasGraph(markersOverview, { x: width / 4 }); + await rangeSelected; + + is( + JSON.stringify(markersOverview.getSelection()), + JSON.stringify(framerateGraph.getSelection()), + "The markers overview has a correct selection." + ); + is( + JSON.stringify(memoryGraph.getSelection()), + JSON.stringify(framerateGraph.getSelection()), + "The memory overview has a correct selection." + ); + is( + JSON.stringify(framerateGraph.getSelection()), + JSON.stringify({ start: 0, end: width / 4 }), + "The framerate graph has a correct selection." + ); + + // Perform a selection inside the memory overview. + + markersOverview.dropSelection(); + + rangeSelected = times(OverviewView, EVENTS.UI_OVERVIEW_RANGE_SELECTED, 2); + dragStartCanvasGraph(memoryGraph, { x: 0 }); + dragStopCanvasGraph(memoryGraph, { x: width / 10 }); + await rangeSelected; + + is( + JSON.stringify(markersOverview.getSelection()), + JSON.stringify(framerateGraph.getSelection()), + "The markers overview has a correct selection." + ); + is( + JSON.stringify(memoryGraph.getSelection()), + JSON.stringify(framerateGraph.getSelection()), + "The memory overview has a correct selection." + ); + is( + JSON.stringify(framerateGraph.getSelection()), + JSON.stringify({ start: 0, end: width / 10 }), + "The framerate graph has a correct selection." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-overview-time-interval.js b/devtools/client/performance/test/browser_perf-overview-time-interval.js new file mode 100644 index 0000000000..b268d54529 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-overview-time-interval.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the `setTimeInterval` and `getTimeInterval` functions + * work properly. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, OverviewView } = panel.panelWin; + + try { + OverviewView.setTimeInterval({ starTime: 0, endTime: 1 }); + ok(false, "Setting a time interval shouldn't have worked."); + } catch (e) { + ok(true, "Setting a time interval didn't work, as expected."); + } + + try { + OverviewView.getTimeInterval(); + ok(false, "Getting the time interval shouldn't have worked."); + } catch (e) { + ok(true, "Getting the time interval didn't work, as expected."); + } + + await startRecording(panel); + await stopRecording(panel); + + // Get/set the time interval and wait for the event propagation. + + const rangeSelected = once(OverviewView, EVENTS.UI_OVERVIEW_RANGE_SELECTED); + OverviewView.setTimeInterval({ startTime: 10, endTime: 20 }); + await rangeSelected; + + const firstInterval = OverviewView.getTimeInterval(); + info("First interval start time: " + firstInterval.startTime); + info("First interval end time: " + firstInterval.endTime); + is( + Math.round(firstInterval.startTime), + 10, + "The interval's start time was properly set." + ); + is( + Math.round(firstInterval.endTime), + 20, + "The interval's end time was properly set." + ); + + // Get/set another time interval and make sure there's no event propagation. + + function fail() { + ok(false, "The selection event should not have propagated."); + } + + OverviewView.on(EVENTS.UI_OVERVIEW_RANGE_SELECTED, fail); + OverviewView.setTimeInterval( + { startTime: 30, endTime: 40 }, + { stopPropagation: true } + ); + OverviewView.off(EVENTS.UI_OVERVIEW_RANGE_SELECTED, fail); + + const secondInterval = OverviewView.getTimeInterval(); + info("Second interval start time: " + secondInterval.startTime); + info("Second interval end time: " + secondInterval.endTime); + is( + Math.round(secondInterval.startTime), + 30, + "The interval's start time was properly set again." + ); + is( + Math.round(secondInterval.endTime), + 40, + "The interval's end time was properly set again." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-private-browsing.js b/devtools/client/performance/test/browser_perf-private-browsing.js new file mode 100644 index 0000000000..8a2c7b93c4 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-private-browsing.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the frontend is disabled when in private browsing mode. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +let gPanelWinTuples = []; + +add_task(async function() { + await testNormalWindow(); + await testPrivateWindow(); + await testRecordingFailingInWindow(0); + await testRecordingFailingInWindow(1); + await teardownPerfInWindow(1, { shouldCloseWindow: true }); + await testRecordingSucceedingInWindow(0); + await teardownPerfInWindow(0, { shouldCloseWindow: false }); + + gPanelWinTuples = null; +}); + +async function createPanelInNewWindow(options) { + const win = await BrowserTestUtils.openNewBrowserWindow(options); + return createPanelInWindow(options, win); +} + +async function createPanelInWindow(options, win = window) { + const { panel } = await initPerformanceInNewTab( + { + url: SIMPLE_URL, + win: win, + }, + options + ); + + gPanelWinTuples.push({ panel, win }); + return { panel, win }; +} + +async function testNormalWindow() { + const { panel } = await createPanelInWindow({ + private: false, + }); + + const { PerformanceView } = panel.panelWin; + + is( + PerformanceView.getState(), + "empty", + "The initial state of the performance panel view is correct (1)." + ); +} + +async function testPrivateWindow() { + const { panel } = await createPanelInNewWindow({ + private: true, + // The add-on SDK can't seem to be able to listen to "ready" or "close" + // events for private tabs. Don't really absolutely need to though. + dontWaitForTabReady: true, + }); + + const { PerformanceView } = panel.panelWin; + + is( + PerformanceView.getState(), + "unavailable", + "The initial state of the performance panel view is correct (2)." + ); +} + +async function testRecordingFailingInWindow(index) { + const { panel } = gPanelWinTuples[index]; + const { EVENTS, PerformanceController } = panel.panelWin; + + const onRecordingStarted = () => { + ok(false, "Recording should not start while a private window is present."); + }; + + PerformanceController.on(EVENTS.RECORDING_STATE_CHANGE, onRecordingStarted); + + const whenFailed = once( + PerformanceController, + EVENTS.BACKEND_FAILED_AFTER_RECORDING_START + ); + PerformanceController.startRecording(); + await whenFailed; + ok(true, "Recording has failed."); + + PerformanceController.off(EVENTS.RECORDING_STATE_CHANGE, onRecordingStarted); +} + +async function testRecordingSucceedingInWindow(index) { + const { panel } = gPanelWinTuples[index]; + const { EVENTS, PerformanceController } = panel.panelWin; + + const onRecordingFailed = () => { + ok(false, "Recording should start while now private windows are present."); + }; + + PerformanceController.on( + EVENTS.BACKEND_FAILED_AFTER_RECORDING_START, + onRecordingFailed + ); + + await startRecording(panel); + await stopRecording(panel); + ok(true, "Recording has succeeded."); + + PerformanceController.off( + EVENTS.BACKEND_FAILED_AFTER_RECORDING_START, + onRecordingFailed + ); +} + +async function teardownPerfInWindow(index, options) { + const { panel, win } = gPanelWinTuples[index]; + await teardownToolboxAndRemoveTab(panel); + + if (options.shouldCloseWindow) { + win.close(); + } +} diff --git a/devtools/client/performance/test/browser_perf-range-changed-render.js b/devtools/client/performance/test/browser_perf-range-changed-render.js new file mode 100644 index 0000000000..98e6ca7f92 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-range-changed-render.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the detail views are rerendered after the range changes. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { + EVENTS, + OverviewView, + DetailsView, + WaterfallView, + JsCallTreeView, + JsFlameGraphView, + } = panel.panelWin; + + let updatedWaterfall = 0; + let updatedCallTree = 0; + let updatedFlameGraph = 0; + const updateWaterfall = () => updatedWaterfall++; + const updateCallTree = () => updatedCallTree++; + const updateFlameGraph = () => updatedFlameGraph++; + WaterfallView.on(EVENTS.UI_WATERFALL_RENDERED, updateWaterfall); + JsCallTreeView.on(EVENTS.UI_JS_CALL_TREE_RENDERED, updateCallTree); + JsFlameGraphView.on(EVENTS.UI_JS_FLAMEGRAPH_RENDERED, updateFlameGraph); + + await startRecording(panel); + await stopRecording(panel); + + let rendered = once(WaterfallView, EVENTS.UI_WATERFALL_RENDERED); + OverviewView.emit(EVENTS.UI_OVERVIEW_RANGE_SELECTED, { + startTime: 0, + endTime: 10, + }); + await rendered; + ok( + true, + "Waterfall rerenders when a range in the overview graph is selected." + ); + + rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + await DetailsView.selectView("js-calltree"); + await rendered; + ok(true, "Call tree rerenders after its corresponding pane is shown."); + + rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + await DetailsView.selectView("js-flamegraph"); + await rendered; + ok(true, "Flamegraph rerenders after its corresponding pane is shown."); + + rendered = once(JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + OverviewView.emit(EVENTS.UI_OVERVIEW_RANGE_SELECTED); + await rendered; + ok( + true, + "Flamegraph rerenders when a range in the overview graph is removed." + ); + + rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + await DetailsView.selectView("js-calltree"); + await rendered; + ok(true, "Call tree rerenders after its corresponding pane is shown."); + + rendered = once(WaterfallView, EVENTS.UI_WATERFALL_RENDERED); + await DetailsView.selectView("waterfall"); + await rendered; + ok(true, "Waterfall rerenders after its corresponding pane is shown."); + + // The WaterfallView is rerendered on window resize. Loading the other graphs can + // trigger a window resize and increase the total number of rerenders. + // See Bug 1532993#c12. + ok( + updatedWaterfall === 3 || updatedWaterfall === 4, + "WaterfallView rerendered 3 or 4 times." + ); + is(updatedCallTree, 2, "JsCallTreeView rerendered 2 times."); + is(updatedFlameGraph, 2, "JsFlameGraphView rerendered 2 times."); + + WaterfallView.off(EVENTS.UI_WATERFALL_RENDERED, updateWaterfall); + JsCallTreeView.off(EVENTS.UI_JS_CALL_TREE_RENDERED, updateCallTree); + JsFlameGraphView.off(EVENTS.UI_JS_FLAMEGRAPH_RENDERED, updateFlameGraph); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-recording-notices-01.js b/devtools/client/performance/test/browser_perf-recording-notices-01.js new file mode 100644 index 0000000000..e608b8b19b --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recording-notices-01.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the recording notice panes are toggled in correct scenarios + * for initialization and a single recording. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { $, PerformanceView } = panel.panelWin; + + const MAIN_CONTAINER = $("#performance-view"); + const EMPTY = $("#empty-notice"); + const CONTENT = $("#performance-view-content"); + const DETAILS_CONTAINER = $("#details-pane-container"); + const RECORDING = $("#recording-notice"); + const DETAILS = $("#details-pane"); + + is(PerformanceView.getState(), "empty", "Correct default state."); + is(MAIN_CONTAINER.selectedPanel, EMPTY, "Showing empty panel on load."); + + await startRecording(panel); + + is( + PerformanceView.getState(), + "recording", + "Correct state during recording." + ); + is(MAIN_CONTAINER.selectedPanel, CONTENT, "Showing main view with timeline."); + is(DETAILS_CONTAINER.selectedPanel, RECORDING, "Showing recording panel."); + + await stopRecording(panel); + + is(PerformanceView.getState(), "recorded", "Correct state after recording."); + is(MAIN_CONTAINER.selectedPanel, CONTENT, "Showing main view with timeline."); + is(DETAILS_CONTAINER.selectedPanel, DETAILS, "Showing rendered graphs."); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-recording-notices-02.js b/devtools/client/performance/test/browser_perf-recording-notices-02.js new file mode 100644 index 0000000000..a482aafcdc --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recording-notices-02.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the recording notice panes are toggled when going between + * a completed recording and an in-progress recording. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + setSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, $, PerformanceController, PerformanceView } = panel.panelWin; + + const MAIN_CONTAINER = $("#performance-view"); + const CONTENT = $("#performance-view-content"); + const DETAILS_CONTAINER = $("#details-pane-container"); + const RECORDING = $("#recording-notice"); + const DETAILS = $("#details-pane"); + + await startRecording(panel); + await stopRecording(panel); + + await startRecording(panel); + + is( + PerformanceView.getState(), + "recording", + "Correct state during recording." + ); + is(MAIN_CONTAINER.selectedPanel, CONTENT, "Showing main view with timeline."); + is(DETAILS_CONTAINER.selectedPanel, RECORDING, "Showing recording panel."); + + let selected = once(PerformanceController, EVENTS.RECORDING_SELECTED); + setSelectedRecording(panel, 0); + await selected; + + is( + PerformanceView.getState(), + "recorded", + "Correct state during recording but selecting a completed recording." + ); + is(MAIN_CONTAINER.selectedPanel, CONTENT, "Showing main view with timeline."); + is(DETAILS_CONTAINER.selectedPanel, DETAILS, "Showing recorded panel."); + + selected = once(PerformanceController, EVENTS.RECORDING_SELECTED); + setSelectedRecording(panel, 1); + await selected; + + is( + PerformanceView.getState(), + "recording", + "Correct state when switching back to recording in progress." + ); + is(MAIN_CONTAINER.selectedPanel, CONTENT, "Showing main view with timeline."); + is(DETAILS_CONTAINER.selectedPanel, RECORDING, "Showing recording panel."); + + await stopRecording(panel); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-recording-notices-03.js b/devtools/client/performance/test/browser_perf-recording-notices-03.js new file mode 100644 index 0000000000..de9cfdb144 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recording-notices-03.js @@ -0,0 +1,208 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that recording notices display buffer status when available, + * and can switch between different recordings with the correct buffer + * information displayed. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + PROFILER_BUFFER_SIZE_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + pmmInitWithBrowser, + pmmStopProfiler, +} = require("devtools/client/performance/test/helpers/profiler-mm-utils"); +const { + initPerformanceInTab, + initConsoleInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + waitUntil, +} = require("devtools/client/performance/test/helpers/wait-utils"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + setSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + // Make sure the profiler module is stopped so we can set a new buffer limit. + pmmInitWithBrowser(gBrowser); + await pmmStopProfiler(); + + // Keep the profiler's buffer large, but still get to 1% relatively quick. + Services.prefs.setIntPref(PROFILER_BUFFER_SIZE_PREF, 1000000); + + const { target, console } = await initConsoleInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { panel } = await initPerformanceInTab({ tab: target.localTab }); + const { EVENTS, $, PerformanceController, PerformanceView } = panel.panelWin; + + // Set a fast profiler-status update interval. + const performanceFront = await panel.target.getFront("performance"); + await performanceFront.setProfilerStatusInterval(10); + + const DETAILS_CONTAINER = $("#details-pane-container"); + const NORMAL_BUFFER_STATUS_MESSAGE = $( + "#recording-notice .buffer-status-message" + ); + const CONSOLE_BUFFER_STATUS_MESSAGE = $( + "#console-recording-notice .buffer-status-message" + ); + let gPercent; + + // Start a manual recording. + await startRecording(panel); + + await waitUntil(async function() { + [gPercent] = await once( + PerformanceView, + EVENTS.UI_RECORDING_PROFILER_STATUS_RENDERED, + { spreadArgs: true } + ); + + return gPercent > 0; + }); + + ok(true, "Buffer percentage increased in display (1)."); + + let bufferUsage = PerformanceController.getBufferUsageForRecording( + PerformanceController.getCurrentRecording() + ); + + let bufferStatus = DETAILS_CONTAINER.getAttribute("buffer-status"); + either( + bufferStatus, + "in-progress", + "full", + "Container has [buffer-status=in-progress] or [buffer-status=full]." + ); + ok( + NORMAL_BUFFER_STATUS_MESSAGE.value.includes(gPercent + "%"), + "Buffer status text has correct percentage." + ); + + // Start a console profile. + await console.profile("rust"); + + await waitUntil(async function() { + [gPercent] = await once( + PerformanceView, + EVENTS.UI_RECORDING_PROFILER_STATUS_RENDERED, + { spreadArgs: true } + ); + + // In some slow environments (eg ccov) it can happen that the buffer is full + // during the test run. In that case, the next condition can never be true + // because bufferUsage is 1, so we introduced a special condition to account + // for this special case. + if (bufferStatus === "full") { + return gPercent === 100; + } + + return gPercent > Math.floor(bufferUsage * 100); + }); + + ok(true, "Buffer percentage increased in display (2)."); + + bufferUsage = PerformanceController.getBufferUsageForRecording( + PerformanceController.getCurrentRecording() + ); + bufferStatus = DETAILS_CONTAINER.getAttribute("buffer-status"); + either( + bufferStatus, + "in-progress", + "full", + "Container has [buffer-status=in-progress] or [buffer-status=full]." + ); + ok( + NORMAL_BUFFER_STATUS_MESSAGE.value.includes(gPercent + "%"), + "Buffer status text has correct percentage." + ); + + // Select the console recording. + let selected = once(PerformanceController, EVENTS.RECORDING_SELECTED); + setSelectedRecording(panel, 1); + await selected; + + await waitUntil(async function() { + [gPercent] = await once( + PerformanceView, + EVENTS.UI_RECORDING_PROFILER_STATUS_RENDERED, + { spreadArgs: true } + ); + return gPercent > 0; + }); + + ok(true, "Percentage updated for newly selected recording."); + + bufferStatus = DETAILS_CONTAINER.getAttribute("buffer-status"); + either( + bufferStatus, + "in-progress", + "full", + "Container has [buffer-status=in-progress] or [buffer-status=full]." + ); + ok( + CONSOLE_BUFFER_STATUS_MESSAGE.value.includes(gPercent + "%"), + "Buffer status text has correct percentage for console recording." + ); + + // Stop the console profile, then select the original manual recording. + await console.profileEnd("rust"); + + selected = once(PerformanceController, EVENTS.RECORDING_SELECTED); + setSelectedRecording(panel, 0); + await selected; + + bufferStatus = DETAILS_CONTAINER.getAttribute("buffer-status"); + await waitUntil(async function() { + [gPercent] = await once( + PerformanceView, + EVENTS.UI_RECORDING_PROFILER_STATUS_RENDERED, + { spreadArgs: true } + ); + + // In some slow environments (eg ccov) it can happen that the buffer is full + // during the test run. In that case, the next condition can never be true + // because bufferUsage is 1, so we introduced a special condition to account + // for this special case. + if (bufferStatus === "full") { + return gPercent === 100; + } + + return gPercent > Math.floor(bufferUsage * 100); + }); + + ok(true, "Buffer percentage increased in display (3)."); + + bufferStatus = DETAILS_CONTAINER.getAttribute("buffer-status"); + either( + bufferStatus, + "in-progress", + "full", + "Container has [buffer-status=in-progress] or [buffer-status=full]." + ); + ok( + NORMAL_BUFFER_STATUS_MESSAGE.value.includes(gPercent + "%"), + "Buffer status text has correct percentage." + ); + + // Stop the manual recording. + await stopRecording(panel); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-recording-notices-04.js b/devtools/client/performance/test/browser_perf-recording-notices-04.js new file mode 100644 index 0000000000..d3be6fbab9 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recording-notices-04.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that when a recording overlaps the circular buffer, that + * a class is assigned to the recording notices. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + PROFILER_BUFFER_SIZE_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + pmmInitWithBrowser, + pmmStopProfiler, +} = require("devtools/client/performance/test/helpers/profiler-mm-utils"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + waitUntil, +} = require("devtools/client/performance/test/helpers/wait-utils"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + // Make sure the profiler module is stopped so we can set a new buffer limit. + pmmInitWithBrowser(gBrowser); + await pmmStopProfiler(); + + // Keep the profiler's buffer small, to get to 100% really quickly. + Services.prefs.setIntPref(PROFILER_BUFFER_SIZE_PREF, 10000); + + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, $, PerformanceController, PerformanceView } = panel.panelWin; + + // Set a fast profiler-status update interval + const performanceFront = await panel.target.getFront("performance"); + await performanceFront.setProfilerStatusInterval(10); + + const DETAILS_CONTAINER = $("#details-pane-container"); + const NORMAL_BUFFER_STATUS_MESSAGE = $( + "#recording-notice .buffer-status-message" + ); + let gPercent; + + // Start a manual recording. + await startRecording(panel); + + await waitUntil(async function() { + [gPercent] = await once( + PerformanceView, + EVENTS.UI_RECORDING_PROFILER_STATUS_RENDERED, + { spreadArgs: true } + ); + return gPercent == 100; + }); + + ok(true, "Buffer percentage increased in display."); + + const bufferUsage = PerformanceController.getBufferUsageForRecording( + PerformanceController.getCurrentRecording() + ); + is(bufferUsage, 1, "Buffer is full for this recording."); + is( + DETAILS_CONTAINER.getAttribute("buffer-status"), + "full", + "Container has [buffer-status=full]." + ); + ok( + NORMAL_BUFFER_STATUS_MESSAGE.value.includes(gPercent + "%"), + "Buffer status text has correct percentage." + ); + + // Stop the manual recording. + await stopRecording(panel); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-recording-notices-05.js b/devtools/client/performance/test/browser_perf-recording-notices-05.js new file mode 100644 index 0000000000..0541797cb3 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recording-notices-05.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the circular buffer notices work when e10s is on/off. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { $, PerformanceController } = panel.panelWin; + + // Set a fast profiler-status update interval + const performanceFront = await panel.target.getFront("performance"); + await performanceFront.setProfilerStatusInterval(10); + + let enabled = false; + + PerformanceController.getMultiprocessStatus = () => { + return { enabled }; + }; + + PerformanceController._setMultiprocessAttributes(); + is( + $("#performance-view").getAttribute("e10s"), + "disabled", + "When e10s is disabled, container has [e10s=disabled]." + ); + + enabled = true; + + PerformanceController._setMultiprocessAttributes(); + + // XXX: Switched to from ok() to todo_is() in Bug 1467712. Follow up in 1500913 + // This cannot work with the current implementation, _setMultiprocessAttributes is not + // removing existing attributes. + todo_is( + $("#performance-view").getAttribute("e10s"), + "", + "When e10s is enabled, there should be no e10s attribute." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-recording-selected-01.js b/devtools/client/performance/test/browser_perf-recording-selected-01.js new file mode 100644 index 0000000000..9da220083b --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recording-selected-01.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler correctly handles multiple recordings and can + * successfully switch between them. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + setSelectedRecording, + getRecordingsCount, + getSelectedRecordingIndex, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, PerformanceController } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + await startRecording(panel); + await stopRecording(panel); + + is(getRecordingsCount(panel), 2, "There should be two recordings visible."); + is( + getSelectedRecordingIndex(panel), + 1, + "The second recording item should be selected." + ); + + const selected = once(PerformanceController, EVENTS.RECORDING_SELECTED); + setSelectedRecording(panel, 0); + await selected; + + is( + getRecordingsCount(panel), + 2, + "There should still be two recordings visible." + ); + is( + getSelectedRecordingIndex(panel), + 0, + "The first recording item should be selected." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-recording-selected-02.js b/devtools/client/performance/test/browser_perf-recording-selected-02.js new file mode 100644 index 0000000000..d2e54fd975 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recording-selected-02.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler correctly handles multiple recordings and can + * successfully switch between them, even when one of them is in progress. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + getSelectedRecordingIndex, + setSelectedRecording, + getRecordingsCount, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + // This test seems to take a very long time to finish on Linux VMs. + requestLongerTimeout(4); + + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, PerformanceController } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + await startRecording(panel); + + is(getRecordingsCount(panel), 2, "There should be two recordings visible."); + is( + getSelectedRecordingIndex(panel), + 1, + "The new recording item should be selected." + ); + + let selected = once(PerformanceController, EVENTS.RECORDING_SELECTED); + setSelectedRecording(panel, 0); + await selected; + + is( + getRecordingsCount(panel), + 2, + "There should still be two recordings visible." + ); + is( + getSelectedRecordingIndex(panel), + 0, + "The first recording item should be selected now." + ); + + selected = once(PerformanceController, EVENTS.RECORDING_SELECTED); + setSelectedRecording(panel, 1); + await selected; + + is( + getRecordingsCount(panel), + 2, + "There should still be two recordings visible." + ); + is( + getSelectedRecordingIndex(panel), + 1, + "The second recording item should be selected again." + ); + + await stopRecording(panel); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-recording-selected-03.js b/devtools/client/performance/test/browser_perf-recording-selected-03.js new file mode 100644 index 0000000000..316731a363 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recording-selected-03.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler UI does not forget that recording is active when + * selected recording changes. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + setSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { $, EVENTS, PerformanceController } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + await startRecording(panel); + + info("Selecting recording #0 and waiting for it to be displayed."); + + const selected = once(PerformanceController, EVENTS.RECORDING_SELECTED); + setSelectedRecording(panel, 0); + await selected; + + ok( + $("#main-record-button").classList.contains("checked"), + "Button is still checked after selecting another item." + ); + ok( + !$("#main-record-button").hasAttribute("disabled"), + "Button is not locked after selecting another item." + ); + + await stopRecording(panel); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-recording-selected-04.js b/devtools/client/performance/test/browser_perf-recording-selected-04.js new file mode 100644 index 0000000000..89c0f02023 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recording-selected-04.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that all components can get rerendered for a profile when switching. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_MEMORY_PREF, + UI_ENABLE_ALLOCATIONS_PREF, + PROFILER_SAMPLE_RATE_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, + waitForAllWidgetsRendered, +} = require("devtools/client/performance/test/helpers/actions"); +const { + setSelectedRecording, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { DetailsView, DetailsSubview } = panel.panelWin; + + // Enable memory to test the memory overview. + Services.prefs.setBoolPref(UI_ENABLE_MEMORY_PREF, true); + + // Enable allocations to test the memory-calltree and memory-flamegraph. + Services.prefs.setBoolPref(UI_ENABLE_ALLOCATIONS_PREF, true); + + // Because enabling the memory panel has a significant overhead, especially in + // slow builds like ccov builds, let's reduce the overhead from the sampling. + Services.prefs.setIntPref(PROFILER_SAMPLE_RATE_PREF, 100); + + ok(true, "Starting recording..."); + await startRecording(panel); + ok(true, "Recording started!"); + ok(true, "Stopping recording..."); + await stopRecording(panel); + ok(true, "Recording stopped!"); + + // Allow widgets to be updated while hidden, to make testing easier. + DetailsSubview.canUpdateWhileHidden = true; + + // Cycle through all the views to initialize them. The waterfall is shown + // by default, but all the other views are created lazily, so won't emit + // any events. + await DetailsView.selectView("js-calltree"); + await DetailsView.selectView("js-flamegraph"); + await DetailsView.selectView("memory-calltree"); + await DetailsView.selectView("memory-flamegraph"); + + await startRecording(panel); + await stopRecording(panel); + + let rerender = waitForAllWidgetsRendered(panel); + setSelectedRecording(panel, 0); + await rerender; + + ok(true, "All widgets were rendered when selecting the first recording."); + + rerender = waitForAllWidgetsRendered(panel); + setSelectedRecording(panel, 1); + await rerender; + + ok(true, "All widgets were rendered when selecting the second recording."); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-recordings-clear-01.js b/devtools/client/performance/test/browser_perf-recordings-clear-01.js new file mode 100644 index 0000000000..3cfc27328f --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recordings-clear-01.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that clearing recordings empties out the recordings list and toggles + * the empty notice state. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPanelInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + getRecordingsCount, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { panel } = await initPanelInNewTab({ + tool: "performance", + url: SIMPLE_URL, + win: window, + }); + + const { PerformanceController, PerformanceView } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + is( + getRecordingsCount(panel), + 1, + "The recordings list should have one recording." + ); + isnot( + PerformanceView.getState(), + "empty", + "PerformanceView should not be in an empty state." + ); + isnot( + PerformanceController.getCurrentRecording(), + null, + "There should be a current recording." + ); + + await startRecording(panel); + await stopRecording(panel); + + is( + getRecordingsCount(panel), + 2, + "The recordings list should have two recordings." + ); + isnot( + PerformanceView.getState(), + "empty", + "PerformanceView should not be in an empty state." + ); + isnot( + PerformanceController.getCurrentRecording(), + null, + "There should be a current recording." + ); + + await PerformanceController.clearRecordings(); + + is(getRecordingsCount(panel), 0, "The recordings list should be empty."); + is( + PerformanceView.getState(), + "empty", + "PerformanceView should be in an empty state." + ); + is( + PerformanceController.getCurrentRecording(), + null, + "There should be no current recording." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-recordings-clear-02.js b/devtools/client/performance/test/browser_perf-recordings-clear-02.js new file mode 100644 index 0000000000..2f09304436 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recordings-clear-02.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that clearing recordings empties out the recordings list and stops + * a current recording if recording and can continue recording after. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPanelInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + times, + once, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + getRecordingsCount, +} = require("devtools/client/performance/test/helpers/recording-utils"); + +add_task(async function() { + const { panel } = await initPanelInNewTab({ + tool: "performance", + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, PerformanceController, PerformanceView } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + is( + getRecordingsCount(panel), + 1, + "The recordings list should have one recording." + ); + isnot( + PerformanceView.getState(), + "empty", + "PerformanceView should not be in an empty state." + ); + isnot( + PerformanceController.getCurrentRecording(), + null, + "There should be a current recording." + ); + + await startRecording(panel); + + is( + getRecordingsCount(panel), + 2, + "The recordings list should have two recordings." + ); + isnot( + PerformanceView.getState(), + "empty", + "PerformanceView should not be in an empty state." + ); + isnot( + PerformanceController.getCurrentRecording(), + null, + "There should be a current recording." + ); + + const recordingDeleted = times( + PerformanceController, + EVENTS.RECORDING_DELETED, + 2 + ); + const recordingStopped = once( + PerformanceController, + EVENTS.RECORDING_STATE_CHANGE, + { + expectedArgs: ["recording-stopped"], + } + ); + + PerformanceController.clearRecordings(); + + await recordingDeleted; + await recordingStopped; + + is(getRecordingsCount(panel), 0, "The recordings list should be empty."); + is( + PerformanceView.getState(), + "empty", + "PerformanceView should be in an empty state." + ); + is( + PerformanceController.getCurrentRecording(), + null, + "There should be no current recording." + ); + + // Bug 1169146: Try another recording after clearing mid-recording. + await startRecording(panel); + await stopRecording(panel); + + is( + getRecordingsCount(panel), + 1, + "The recordings list should have one recording." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-recordings-io-01.js b/devtools/client/performance/test/browser_perf-recordings-io-01.js new file mode 100644 index 0000000000..63040566c1 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recordings-io-01.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/* eslint-disable */ +/** + * Tests if the performance tool is able to save and load recordings. + */ + +var test = async function () { + var { target, panel, toolbox } = await initPerformance(SIMPLE_URL); + var { $, EVENTS, PerformanceController, PerformanceView, DetailsView, DetailsSubview } = panel.panelWin; + + // Enable allocations to test the memory-calltree and memory-flamegraph. + Services.prefs.setBoolPref(ALLOCATIONS_PREF, true); + Services.prefs.setBoolPref(MEMORY_PREF, true); + Services.prefs.setBoolPref(FRAMERATE_PREF, true); + + // Need to allow widgets to be updated while hidden, otherwise we can't use + // `waitForWidgetsRendered`. + DetailsSubview.canUpdateWhileHidden = true; + + await startRecording(panel); + await stopRecording(panel); + + // Cycle through all the views to initialize them, otherwise we can't use + // `waitForWidgetsRendered`. The waterfall is shown by default, but all the + // other views are created lazily, so won't emit any events. + await DetailsView.selectView("js-calltree"); + await DetailsView.selectView("js-flamegraph"); + await DetailsView.selectView("memory-calltree"); + await DetailsView.selectView("memory-flamegraph"); + + // Verify original recording. + + let originalData = PerformanceController.getCurrentRecording().getAllData(); + ok(originalData, "The original recording is not empty."); + + // Save recording. + + let file = FileUtils.getFile("TmpD", ["tmpprofile.json"]); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("666", 8)); + + let exported = once(PerformanceController, EVENTS.RECORDING_EXPORTED); + await PerformanceController.exportRecording("", PerformanceController.getCurrentRecording(), file); + + await exported; + ok(true, "The recording data appears to have been successfully saved."); + + // Check if the imported file name has tmpprofile in it as the file + // names also has different suffix to avoid conflict + + let displayedName = $(".recording-item-title").getAttribute("value"); + ok(/^tmpprofile/.test(displayedName), "File has expected display name after import"); + ok(!/\.json$/.test(displayedName), "Display name does not have .json in it"); + + // Import recording. + + let rerendered = waitForWidgetsRendered(panel); + let imported = once(PerformanceController, EVENTS.RECORDING_IMPORTED); + PerformanceView.emit(EVENTS.UI_IMPORT_RECORDING, file); + + await imported; + ok(true, "The recording data appears to have been successfully imported."); + + await rerendered; + ok(true, "The imported data was re-rendered."); + + // Verify imported recording. + + let importedData = PerformanceController.getCurrentRecording().getAllData(); + + ok(/^tmpprofile/.test(importedData.label), + "The imported data label is identical to the filename without its extension."); + is(importedData.duration, originalData.duration, + "The imported data is identical to the original data (1)."); + is(importedData.markers.toSource(), originalData.markers.toSource(), + "The imported data is identical to the original data (2)."); + is(importedData.memory.toSource(), originalData.memory.toSource(), + "The imported data is identical to the original data (3)."); + is(importedData.ticks.toSource(), originalData.ticks.toSource(), + "The imported data is identical to the original data (4)."); + is(importedData.allocations.toSource(), originalData.allocations.toSource(), + "The imported data is identical to the original data (5)."); + is(importedData.profile.toSource(), originalData.profile.toSource(), + "The imported data is identical to the original data (6)."); + is(importedData.configuration.withTicks, originalData.configuration.withTicks, + "The imported data is identical to the original data (7)."); + is(importedData.configuration.withMemory, originalData.configuration.withMemory, + "The imported data is identical to the original data (8)."); + + await teardown(panel); + finish(); +}; +/* eslint-enable */ diff --git a/devtools/client/performance/test/browser_perf-recordings-io-02.js b/devtools/client/performance/test/browser_perf-recordings-io-02.js new file mode 100644 index 0000000000..3104a40e96 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recordings-io-02.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/* eslint-disable */ +/** + * Tests if the performance tool gracefully handles loading bogus files. + */ + +var test = async function () { + let { target, panel, toolbox } = await initPerformance(SIMPLE_URL); + let { EVENTS, PerformanceController } = panel.panelWin; + + let file = FileUtils.getFile("TmpD", ["tmpprofile.json"]); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("666", 8)); + + try { + await PerformanceController.importRecording("", file); + ok(false, "The recording succeeded unexpectedly."); + } catch (e) { + is(e.message, "Could not read recording data file.", "Message is correct."); + ok(true, "The recording was cancelled."); + } + + await teardown(panel); + finish(); +}; diff --git a/devtools/client/performance/test/browser_perf-recordings-io-03.js b/devtools/client/performance/test/browser_perf-recordings-io-03.js new file mode 100644 index 0000000000..6b9dc47f64 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recordings-io-03.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/* eslint-disable */ +/** + * Tests if the performance tool gracefully handles loading files that are JSON, + * but don't contain the appropriate recording data. + */ + +var { FileUtils } = ChromeUtils.import("resource://gre/modules/FileUtils.jsm"); +var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); + +var test = async function () { + let { target, panel, toolbox } = await initPerformance(SIMPLE_URL); + let { EVENTS, PerformanceController } = panel.panelWin; + + let file = FileUtils.getFile("TmpD", ["tmpprofile.json"]); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("666", 8)); + await asyncCopy({ bogus: "data" }, file); + + try { + await PerformanceController.importRecording("", file); + ok(false, "The recording succeeded unexpectedly."); + } catch (e) { + is(e.message, "Unrecognized recording data file.", "Message is correct."); + ok(true, "The recording was cancelled."); + } + + await teardown(panel); + finish(); +}; + +function getUnicodeConverter() { + let className = "@mozilla.org/intl/scriptableunicodeconverter"; + let converter = Cc[className].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + return converter; +} + +function asyncCopy(data, file) { + let string = JSON.stringify(data); + let inputStream = getUnicodeConverter().convertToInputStream(string); + let outputStream = FileUtils.openSafeFileOutputStream(file); + + return new Promise((resolve, reject) => { + NetUtil.asyncCopy(inputStream, outputStream, status => { + if (!Components.isSuccessCode(status)) { + reject(new Error("Could not save data to file.")); + } + resolve(); + }); + }); +} +/* eslint-enable */ diff --git a/devtools/client/performance/test/browser_perf-recordings-io-04.js b/devtools/client/performance/test/browser_perf-recordings-io-04.js new file mode 100644 index 0000000000..30f723c3d8 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recordings-io-04.js @@ -0,0 +1,176 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/* eslint-disable */ +/** + * Tests if the performance tool can import profiler data from the + * original profiler tool (Performance Recording v1, and Profiler data v2) and the correct views and graphs are loaded. + */ +var TICKS_DATA = (function () { + let ticks = []; + for (let i = 0; i < 100; i++) { + ticks.push(i * 10); + } + return ticks; +})(); + +var PROFILER_DATA = (function () { + let data = {}; + let threads = data.threads = []; + let thread = {}; + threads.push(thread); + thread.name = "Content"; + thread.samples = [{ + time: 5, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" } + ] + }, { + time: 5 + 6, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "D" } + ] + }, { + time: 5 + 6 + 7, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "E" }, + { location: "F" } + ] + }, { + time: 20, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + { location: "D" }, + { location: "E" }, + { location: "F" }, + { location: "G" } + ] + }]; + + // Handled in other deflating tests + thread.markers = []; + + let meta = data.meta = {}; + meta.version = 2; + meta.interval = 1; + meta.stackwalk = 0; + meta.product = "Firefox"; + return data; +})(); + +var test = async function () { + let { target, panel, toolbox } = await initPerformance(SIMPLE_URL); + let { $, EVENTS, PerformanceController, DetailsView, OverviewView, JsCallTreeView } = panel.panelWin; + + // Enable memory to test the memory-calltree and memory-flamegraph. + Services.prefs.setBoolPref(ALLOCATIONS_PREF, true); + + // Create a structure from the data that mimics the old profiler's data. + // Different name for `ticks`, different way of storing time, + // and no memory, markers data. + let oldProfilerData = { + profilerData: { profile: PROFILER_DATA }, + ticksData: TICKS_DATA, + recordingDuration: 10000, + fileType: "Recorded Performance Data", + version: 1 + }; + + // Save recording as an old profiler data. + let file = FileUtils.getFile("TmpD", ["tmpprofile.json"]); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("666", 8)); + await asyncCopy(oldProfilerData, file); + + // Import recording. + + let calltreeRendered = once(OverviewView, EVENTS.UI_FRAMERATE_GRAPH_RENDERED); + let fpsRendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + let imported = once(PerformanceController, EVENTS.RECORDING_IMPORTED); + await PerformanceController.importRecording("", file); + + await imported; + ok(true, "The original profiler data appears to have been successfully imported."); + + await calltreeRendered; + await fpsRendered; + ok(true, "The imported data was re-rendered."); + + // Ensure that only framerate and js calltree/flamegraph view are available + is(isVisible($("#overview-pane")), true, "overview graph container still shown"); + is(isVisible($("#memory-overview")), false, "memory graph hidden"); + is(isVisible($("#markers-overview")), false, "markers overview graph hidden"); + is(isVisible($("#time-framerate")), true, "fps graph shown"); + is($("#select-waterfall-view").hidden, true, "waterfall button hidden"); + is($("#select-js-calltree-view").hidden, false, "jscalltree button shown"); + is($("#select-js-flamegraph-view").hidden, false, "jsflamegraph button shown"); + is($("#select-memory-calltree-view").hidden, true, "memorycalltree button hidden"); + is($("#select-memory-flamegraph-view").hidden, true, "memoryflamegraph button hidden"); + ok(DetailsView.isViewSelected(JsCallTreeView), "jscalltree view selected as its the only option"); + + // Verify imported recording. + + let importedData = PerformanceController.getCurrentRecording().getAllData(); + let expected = Object.create({ + duration: 10000, + markers: [].toSource(), + frames: [].toSource(), + memory: [].toSource(), + ticks: TICKS_DATA.toSource(), + profile: RecordingUtils.deflateProfile(JSON.parse(JSON.stringify(PROFILER_DATA))).toSource(), + allocations: ({sites:[], timestamps:[], frames:[], sizes: []}).toSource(), + withTicks: true, + withMemory: false, + sampleFrequency: void 0 + }); + + for (let field in expected) { + if (!!~["withTicks", "withMemory", "sampleFrequency"].indexOf(field)) { + is(importedData.configuration[field], expected[field], `${field} successfully converted in legacy import.`); + } else if (field === "profile") { + is(importedData.profile.toSource(), expected.profile, + "profiler data's samples successfully converted in legacy import."); + is(importedData.profile.meta.version, 3, "Updated meta version to 3."); + } else { + let data = importedData[field]; + is(typeof data === "object" ? data.toSource() : data, expected[field], + `${field} successfully converted in legacy import.`); + } + } + + await teardown(panel); + finish(); +}; + +function getUnicodeConverter() { + let className = "@mozilla.org/intl/scriptableunicodeconverter"; + let converter = Cc[className].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + return converter; +} + +function asyncCopy(data, file) { + let string = JSON.stringify(data); + let inputStream = getUnicodeConverter().convertToInputStream(string); + let outputStream = FileUtils.openSafeFileOutputStream(file); + + return new Promise((resolve, reject) => { + NetUtil.asyncCopy(inputStream, outputStream, status => { + if (!Components.isSuccessCode(status)) { + reject(new Error("Could not save data to file.")); + } + resolve(); + }); + }); +} +/* eslint-enable */ diff --git a/devtools/client/performance/test/browser_perf-recordings-io-05.js b/devtools/client/performance/test/browser_perf-recordings-io-05.js new file mode 100644 index 0000000000..4d671543d2 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recordings-io-05.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/* eslint-disable */ +/** + * Test that when importing and no graphs rendered yet, we do not get a + * `getMappedSelection` error. + */ + +var test = async function () { + var { target, panel, toolbox } = await initPerformance(SIMPLE_URL); + var { EVENTS, PerformanceController, WaterfallView } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + // Save recording. + + let file = FileUtils.getFile("TmpD", ["tmpprofile.json"]); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("666", 8)); + + let exported = once(PerformanceController, EVENTS.RECORDING_EXPORTED); + await PerformanceController.exportRecording("", PerformanceController.getCurrentRecording(), file); + + await exported; + ok(true, "The recording data appears to have been successfully saved."); + + // Clear and re-import. + + await PerformanceController.clearRecordings(); + + let rendered = once(WaterfallView, EVENTS.UI_WATERFALL_RENDERED); + let imported = once(PerformanceController, EVENTS.RECORDING_IMPORTED); + await PerformanceController.importRecording("", file); + await imported; + await rendered; + + ok(true, "No error was thrown."); + + await teardown(panel); + finish(); +}; +/* eslint-enable */ diff --git a/devtools/client/performance/test/browser_perf-recordings-io-06.js b/devtools/client/performance/test/browser_perf-recordings-io-06.js new file mode 100644 index 0000000000..06c55efe99 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-recordings-io-06.js @@ -0,0 +1,140 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/* eslint-disable */ +/** + * Tests if the performance tool can import profiler data when Profiler is v2 + * and requires deflating, and has an extra thread that's a string. Not sure + * what causes this. + */ +var STRINGED_THREAD = (function () { + let thread = {}; + + thread.libs = [{ + start: 123, + end: 456, + offset: 0, + name: "", + breakpadId: "" + }]; + thread.meta = { version: 2, interval: 1, stackwalk: 0, processType: 1, startTime: 0 }; + thread.threads = [{ + name: "Plugin", + tid: 4197, + samples: [], + markers: [], + }]; + + return JSON.stringify(thread); +})(); + +var PROFILER_DATA = (function () { + let data = {}; + let threads = data.threads = []; + let thread = {}; + threads.push(thread); + threads.push(STRINGED_THREAD); + thread.name = "Content"; + thread.samples = [{ + time: 5, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" } + ] + }, { + time: 5 + 6, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "D" } + ] + }, { + time: 5 + 6 + 7, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "E" }, + { location: "F" } + ] + }, { + time: 20, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + { location: "D" }, + { location: "E" }, + { location: "F" }, + { location: "G" } + ] + }]; + + // Handled in other deflating tests + thread.markers = []; + + let meta = data.meta = {}; + meta.version = 2; + meta.interval = 1; + meta.stackwalk = 0; + meta.product = "Firefox"; + return data; +})(); + +var test = async function () { + let { target, panel, toolbox } = await initPerformance(SIMPLE_URL); + let { $, EVENTS, PerformanceController, DetailsView, JsCallTreeView } = panel.panelWin; + + let profilerData = { + profile: PROFILER_DATA, + duration: 10000, + configuration: {}, + fileType: "Recorded Performance Data", + version: 2 + }; + + let file = FileUtils.getFile("TmpD", ["tmpprofile.json"]); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("666", 8)); + await asyncCopy(profilerData, file); + + // Import recording. + + let calltreeRendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + let imported = once(PerformanceController, EVENTS.RECORDING_IMPORTED); + await PerformanceController.importRecording("", file); + + await imported; + ok(true, "The profiler data appears to have been successfully imported."); + + await calltreeRendered; + ok(true, "The imported data was re-rendered."); + + await teardown(panel); + finish(); +}; + +function getUnicodeConverter() { + let className = "@mozilla.org/intl/scriptableunicodeconverter"; + let converter = Cc[className].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + return converter; +} + +function asyncCopy(data, file) { + let string = JSON.stringify(data); + let inputStream = getUnicodeConverter().convertToInputStream(string); + let outputStream = FileUtils.openSafeFileOutputStream(file); + + return new Promise((resolve, reject) => { + NetUtil.asyncCopy(inputStream, outputStream, status => { + if (!Components.isSuccessCode(status)) { + reject(new Error("Could not save data to file.")); + } + resolve(); + }); + }); +} +/* eslint-enable */ diff --git a/devtools/client/performance/test/browser_perf-refresh.js b/devtools/client/performance/test/browser_perf-refresh.js new file mode 100644 index 0000000000..bea41c5fc6 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-refresh.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Rough test that the recording still continues after a refresh. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, + reload, +} = require("devtools/client/performance/test/helpers/actions"); +const { + waitUntil, +} = require("devtools/client/performance/test/helpers/wait-utils"); + +add_task(async function() { + const { panel, target } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { PerformanceController } = panel.panelWin; + + await startRecording(panel); + await reload(target); + + const recording = PerformanceController.getCurrentRecording(); + const markersLength = recording.getAllData().markers.length; + + ok( + recording.isRecording(), + "RecordingModel should still be recording after reload" + ); + + await waitUntil(() => recording.getMarkers().length > markersLength); + ok(true, "Markers continue after reload."); + + await stopRecording(panel); + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-states.js b/devtools/client/performance/test/browser_perf-states.js new file mode 100644 index 0000000000..d55c24872c --- /dev/null +++ b/devtools/client/performance/test/browser_perf-states.js @@ -0,0 +1,176 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that view states and lazy component intialization works. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_MEMORY_PREF, + UI_ENABLE_ALLOCATIONS_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { PerformanceView, OverviewView, DetailsView } = panel.panelWin; + + is( + PerformanceView.getState(), + "empty", + "The intial state of the performance panel view is correct." + ); + + ok( + !OverviewView.graphs.get("timeline"), + "The markers graph should not have been created yet." + ); + ok( + !OverviewView.graphs.get("memory"), + "The memory graph should not have been created yet." + ); + ok( + !OverviewView.graphs.get("framerate"), + "The framerate graph should not have been created yet." + ); + + ok( + !DetailsView.components.waterfall.initialized, + "The waterfall detail view should not have been created yet." + ); + ok( + !DetailsView.components["js-calltree"].initialized, + "The js-calltree detail view should not have been created yet." + ); + ok( + !DetailsView.components["js-flamegraph"].initialized, + "The js-flamegraph detail view should not have been created yet." + ); + ok( + !DetailsView.components["memory-calltree"].initialized, + "The memory-calltree detail view should not have been created yet." + ); + ok( + !DetailsView.components["memory-flamegraph"].initialized, + "The memory-flamegraph detail view should not have been created yet." + ); + + Services.prefs.setBoolPref(UI_ENABLE_MEMORY_PREF, true); + Services.prefs.setBoolPref(UI_ENABLE_ALLOCATIONS_PREF, true); + + ok( + !OverviewView.graphs.get("timeline"), + "The markers graph should still not have been created yet." + ); + ok( + !OverviewView.graphs.get("memory"), + "The memory graph should still not have been created yet." + ); + ok( + !OverviewView.graphs.get("framerate"), + "The framerate graph should still not have been created yet." + ); + + await startRecording(panel); + + is( + PerformanceView.getState(), + "recording", + "The current state of the performance panel view is 'recording'." + ); + ok( + OverviewView.graphs.get("memory"), + "The memory graph should have been created now." + ); + ok( + OverviewView.graphs.get("framerate"), + "The framerate graph should have been created now." + ); + + await stopRecording(panel); + + is( + PerformanceView.getState(), + "recorded", + "The current state of the performance panel view is 'recorded'." + ); + ok( + !DetailsView.components["js-calltree"].initialized, + "The js-calltree detail view should still not have been created yet." + ); + ok( + !DetailsView.components["js-flamegraph"].initialized, + "The js-flamegraph detail view should still not have been created yet." + ); + ok( + !DetailsView.components["memory-calltree"].initialized, + "The memory-calltree detail view should still not have been created yet." + ); + ok( + !DetailsView.components["memory-flamegraph"].initialized, + "The memory-flamegraph detail view should still not have been created yet." + ); + + await DetailsView.selectView("js-calltree"); + + is( + PerformanceView.getState(), + "recorded", + "The current state of the performance panel view is still 'recorded'." + ); + ok( + DetailsView.components["js-calltree"].initialized, + "The js-calltree detail view should still have been created now." + ); + ok( + !DetailsView.components["js-flamegraph"].initialized, + "The js-flamegraph detail view should still not have been created yet." + ); + ok( + !DetailsView.components["memory-calltree"].initialized, + "The memory-calltree detail view should still not have been created yet." + ); + ok( + !DetailsView.components["memory-flamegraph"].initialized, + "The memory-flamegraph detail view should still not have been created yet." + ); + + await DetailsView.selectView("memory-calltree"); + + is( + PerformanceView.getState(), + "recorded", + "The current state of the performance panel view is still 'recorded'." + ); + ok( + DetailsView.components["js-calltree"].initialized, + "The js-calltree detail view should still register as being created." + ); + ok( + !DetailsView.components["js-flamegraph"].initialized, + "The js-flamegraph detail view should still not have been created yet." + ); + ok( + DetailsView.components["memory-calltree"].initialized, + "The memory-calltree detail view should still have been created now." + ); + ok( + !DetailsView.components["memory-flamegraph"].initialized, + "The memory-flamegraph detail view should still not have been created yet." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_perf-telemetry-01.js b/devtools/client/performance/test/browser_perf-telemetry-01.js new file mode 100644 index 0000000000..6c236c8639 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-telemetry-01.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the performance telemetry module records events at appropriate times. + * Specificaly the state about a recording process. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + UI_ENABLE_MEMORY_PREF, +} = require("devtools/client/performance/test/helpers/prefs"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); + +add_task(async function() { + startTelemetry(); + + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + Services.prefs.setBoolPref(UI_ENABLE_MEMORY_PREF, false); + + await startRecording(panel); + await stopRecording(panel); + + Services.prefs.setBoolPref(UI_ENABLE_MEMORY_PREF, true); + + await startRecording(panel); + await stopRecording(panel); + + checkResults(); + + await teardownToolboxAndRemoveTab(panel); +}); + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_PERFTOOLS_") + // here. + checkTelemetry( + "DEVTOOLS_PERFTOOLS_RECORDING_COUNT", + "", + { 0: 2, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_PERFTOOLS_RECORDING_DURATION_MS", + "", + null, + "hasentries" + ); + checkTelemetry( + "DEVTOOLS_PERFTOOLS_RECORDING_FEATURES_USED", + "withMarkers", + { 0: 0, 1: 2, 2: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_PERFTOOLS_RECORDING_FEATURES_USED", + "withMemory", + { 0: 1, 1: 1, 2: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_PERFTOOLS_RECORDING_FEATURES_USED", + "withAllocations", + { 0: 2, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_PERFTOOLS_RECORDING_FEATURES_USED", + "withTicks", + { 0: 0, 1: 2, 2: 0 }, + "array" + ); +} diff --git a/devtools/client/performance/test/browser_perf-telemetry-02.js b/devtools/client/performance/test/browser_perf-telemetry-02.js new file mode 100644 index 0000000000..c76a349b6d --- /dev/null +++ b/devtools/client/performance/test/browser_perf-telemetry-02.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the performance telemetry module records events at appropriate times. + * Specifically export/import. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + startTelemetry(); + + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { EVENTS, PerformanceController } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + const file = FileUtils.getFile("TmpD", ["tmpprofile.json"]); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("666", 8)); + + const exported = once(PerformanceController, EVENTS.RECORDING_EXPORTED); + await PerformanceController.exportRecording( + PerformanceController.getCurrentRecording(), + file + ); + await exported; + + const imported = once(PerformanceController, EVENTS.RECORDING_IMPORTED); + await PerformanceController.importRecording(file); + await imported; + + checkResults(); + await teardownToolboxAndRemoveTab(panel); +}); + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_PERFTOOLS_") + // here. + checkTelemetry( + "DEVTOOLS_PERFTOOLS_RECORDING_IMPORT_FLAG", + "", + { 0: 0, 1: 1, 2: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_PERFTOOLS_RECORDING_EXPORT_FLAG", + "", + { 0: 0, 1: 1, 2: 0 }, + "array" + ); +} diff --git a/devtools/client/performance/test/browser_perf-telemetry-03.js b/devtools/client/performance/test/browser_perf-telemetry-03.js new file mode 100644 index 0000000000..2983b1a125 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-telemetry-03.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the performance telemetry module records events at appropriate times. + * Specifically the destruction of certain views. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + startTelemetry(); + + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { + EVENTS, + DetailsView, + JsCallTreeView, + JsFlameGraphView, + } = panel.panelWin; + + await startRecording(panel); + await stopRecording(panel); + + const calltreeRendered = once( + JsCallTreeView, + EVENTS.UI_JS_CALL_TREE_RENDERED + ); + const flamegraphRendered = once( + JsFlameGraphView, + EVENTS.UI_JS_FLAMEGRAPH_RENDERED + ); + + // Go through some views to check later. + await DetailsView.selectView("js-calltree"); + await calltreeRendered; + + await DetailsView.selectView("js-flamegraph"); + await flamegraphRendered; + + await teardownToolboxAndRemoveTab(panel); + + checkResults(); +}); + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_PERFTOOLS_") + // here. + checkTelemetry( + "DEVTOOLS_PERFTOOLS_SELECTED_VIEW_MS", + "js-calltree", + null, + "hasentries" + ); + checkTelemetry( + "DEVTOOLS_PERFTOOLS_SELECTED_VIEW_MS", + "js-flamegraph", + null, + "hasentries" + ); + checkTelemetry( + "DEVTOOLS_PERFTOOLS_SELECTED_VIEW_MS", + "waterfall", + null, + "hasentries" + ); +} diff --git a/devtools/client/performance/test/browser_perf-telemetry-04.js b/devtools/client/performance/test/browser_perf-telemetry-04.js new file mode 100644 index 0000000000..68df776cf5 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-telemetry-04.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the performance telemetry module records events at appropriate times. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInTab, + initConsoleInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + waitForRecordingStartedEvents, + waitForRecordingStoppedEvents, +} = require("devtools/client/performance/test/helpers/actions"); + +add_task(async function() { + startTelemetry(); + + const { target, console } = await initConsoleInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { panel } = await initPerformanceInTab({ tab: target.localTab }); + + const started = waitForRecordingStartedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + }); + await console.profile("rust"); + await started; + + const stopped = waitForRecordingStoppedEvents(panel, { + // only emitted for manual recordings + skipWaitingForBackendReady: true, + }); + await console.profileEnd("rust"); + await stopped; + + checkResults(); + await teardownToolboxAndRemoveTab(panel); +}); + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_PERFTOOLS_") + // here. + checkTelemetry( + "DEVTOOLS_PERFTOOLS_CONSOLE_RECORDING_COUNT", + "", + { 0: 1, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_PERFTOOLS_RECORDING_DURATION_MS", + "", + null, + "hasentries" + ); + checkTelemetry( + "DEVTOOLS_PERFTOOLS_RECORDING_FEATURES_USED", + "withMarkers", + { 0: 0, 1: 1, 2: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_PERFTOOLS_RECORDING_FEATURES_USED", + "withMemory", + { 0: 1, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_PERFTOOLS_RECORDING_FEATURES_USED", + "withAllocations", + { 0: 1, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_PERFTOOLS_RECORDING_FEATURES_USED", + "withTicks", + { 0: 0, 1: 1, 2: 0 }, + "array" + ); +} diff --git a/devtools/client/performance/test/browser_perf-theme-toggle.js b/devtools/client/performance/test/browser_perf-theme-toggle.js new file mode 100644 index 0000000000..b866e59b6a --- /dev/null +++ b/devtools/client/performance/test/browser_perf-theme-toggle.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/* eslint-disable */ +/** + * Tests if the markers and memory overviews render with the correct + * theme on load, and rerenders when changed. + */ + +const { setTheme } = require("devtools/client/shared/theme"); + +const LIGHT_BG = "white"; +const DARK_BG = "#14171a"; + +setTheme("dark"); +Services.prefs.setBoolPref(MEMORY_PREF, false); + +requestLongerTimeout(2); + +async function spawnTest() { + let { panel } = await initPerformance(SIMPLE_URL); + let { EVENTS, $, OverviewView, document: doc } = panel.panelWin; + + await startRecording(panel); + let markers = OverviewView.graphs.get("timeline"); + is(markers.backgroundColor, DARK_BG, + "correct theme on load for markers."); + await stopRecording(panel); + + let refreshed = once(markers, "refresh"); + setTheme("light"); + await refreshed; + + ok(true, "markers were rerendered after theme change."); + is(markers.backgroundColor, LIGHT_BG, + "correct theme on after toggle for markers."); + + // reset back to dark + refreshed = once(markers, "refresh"); + setTheme("dark"); + await refreshed; + + info("Testing with memory overview"); + + Services.prefs.setBoolPref(MEMORY_PREF, true); + + await startRecording(panel); + let memory = OverviewView.graphs.get("memory"); + is(memory.backgroundColor, DARK_BG, + "correct theme on load for memory."); + await stopRecording(panel); + + refreshed = Promise.all([ + once(markers, "refresh"), + once(memory, "refresh"), + ]); + setTheme("light"); + await refreshed; + + ok(true, "Both memory and markers were rerendered after theme change."); + is(markers.backgroundColor, LIGHT_BG, + "correct theme on after toggle for markers."); + is(memory.backgroundColor, LIGHT_BG, + "correct theme on after toggle for memory."); + + refreshed = Promise.all([ + once(markers, "refresh"), + once(memory, "refresh"), + ]); + + // Set theme back to light + setTheme("light"); + await refreshed; + + await teardown(panel); + finish(); +} +/* eslint-enable */ diff --git a/devtools/client/performance/test/browser_perf-tree-abstract-01.js b/devtools/client/performance/test/browser_perf-tree-abstract-01.js new file mode 100644 index 0000000000..75c8c3803b --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-abstract-01.js @@ -0,0 +1,205 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the abstract tree base class for the profiler's tree view + * works as advertised. + */ + +const { + appendAndWaitForPaint, +} = require("devtools/client/performance/test/helpers/dom-utils"); +const { + synthesizeCustomTreeClass, +} = require("devtools/client/performance/test/helpers/synth-utils"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { MyCustomTreeItem, myDataSrc } = synthesizeCustomTreeClass(); + + const container = document.createXULElement("vbox"); + await appendAndWaitForPaint(gBrowser.selectedBrowser.parentNode, container); + + // Populate the tree and test the root item... + + const treeRoot = new MyCustomTreeItem(myDataSrc, { parent: null }); + treeRoot.attachTo(container); + + ok(!treeRoot.expanded, "The root node should not be expanded yet."); + ok(!treeRoot.populated, "The root node should not be populated yet."); + + is( + container.childNodes.length, + 1, + "The container node should have one child available." + ); + is( + container.childNodes[0], + treeRoot.target, + "The root node's target is a child of the container node." + ); + + is(treeRoot.root, treeRoot, "The root node has the correct root."); + is(treeRoot.parent, null, "The root node has the correct parent."); + is(treeRoot.level, 0, "The root node has the correct level."); + is( + treeRoot.target.style.marginInlineStart, + "0px", + "The root node's indentation is correct." + ); + is( + treeRoot.target.textContent, + "root", + "The root node's text contents are correct." + ); + is(treeRoot.container, container, "The root node's container is correct."); + + // Expand the root and test the child items... + + let receivedExpandEvent = once(treeRoot, "expand", { spreadArgs: true }); + let receivedFocusEvent = once(treeRoot, "focus"); + await mousedown(treeRoot.target.querySelector(".arrow")); + + let [eventItem] = await receivedExpandEvent; + is(eventItem, treeRoot, "The 'expand' event target is correct (1)."); + + await receivedFocusEvent; + is( + document.commandDispatcher.focusedElement, + treeRoot.target, + "The root node is now focused." + ); + + const fooItem = treeRoot.getChild(0); + const barItem = treeRoot.getChild(1); + + is( + container.childNodes.length, + 3, + "The container node should now have three children available." + ); + is( + container.childNodes[0], + treeRoot.target, + "The root node's target is a child of the container node." + ); + is( + container.childNodes[1], + fooItem.target, + "The 'foo' node's target is a child of the container node." + ); + is( + container.childNodes[2], + barItem.target, + "The 'bar' node's target is a child of the container node." + ); + + is(fooItem.root, treeRoot, "The 'foo' node has the correct root."); + is(fooItem.parent, treeRoot, "The 'foo' node has the correct parent."); + is(fooItem.level, 1, "The 'foo' node has the correct level."); + is( + fooItem.target.style.marginInlineStart, + "10px", + "The 'foo' node's indentation is correct." + ); + is( + fooItem.target.textContent, + "foo", + "The 'foo' node's text contents are correct." + ); + is(fooItem.container, container, "The 'foo' node's container is correct."); + + is(barItem.root, treeRoot, "The 'bar' node has the correct root."); + is(barItem.parent, treeRoot, "The 'bar' node has the correct parent."); + is(barItem.level, 1, "The 'bar' node has the correct level."); + is( + barItem.target.style.marginInlineStart, + "10px", + "The 'bar' node's indentation is correct." + ); + is( + barItem.target.textContent, + "bar", + "The 'bar' node's text contents are correct." + ); + is(barItem.container, container, "The 'bar' node's container is correct."); + + // Test clicking on the `foo` node... + + receivedFocusEvent = once(treeRoot, "focus", { spreadArgs: true }); + await mousedown(fooItem.target); + + [eventItem] = await receivedFocusEvent; + is(eventItem, fooItem, "The 'focus' event target is correct (2)."); + is( + document.commandDispatcher.focusedElement, + fooItem.target, + "The 'foo' node is now focused." + ); + + // Test double clicking on the `bar` node... + + receivedExpandEvent = once(treeRoot, "expand", { spreadArgs: true }); + receivedFocusEvent = once(treeRoot, "focus"); + await dblclick(barItem.target); + + [eventItem] = await receivedExpandEvent; + is(eventItem, barItem, "The 'expand' event target is correct (3)."); + + await receivedFocusEvent; + is( + document.commandDispatcher.focusedElement, + barItem.target, + "The 'foo' node is now focused." + ); + + // A child item got expanded, test the descendants... + + const bazItem = barItem.getChild(0); + + is( + container.childNodes.length, + 4, + "The container node should now have four children available." + ); + is( + container.childNodes[0], + treeRoot.target, + "The root node's target is a child of the container node." + ); + is( + container.childNodes[1], + fooItem.target, + "The 'foo' node's target is a child of the container node." + ); + is( + container.childNodes[2], + barItem.target, + "The 'bar' node's target is a child of the container node." + ); + is( + container.childNodes[3], + bazItem.target, + "The 'baz' node's target is a child of the container node." + ); + + is(bazItem.root, treeRoot, "The 'baz' node has the correct root."); + is(bazItem.parent, barItem, "The 'baz' node has the correct parent."); + is(bazItem.level, 2, "The 'baz' node has the correct level."); + is( + bazItem.target.style.marginInlineStart, + "20px", + "The 'baz' node's indentation is correct." + ); + is( + bazItem.target.textContent, + "baz", + "The 'baz' node's text contents are correct." + ); + is(bazItem.container, container, "The 'baz' node's container is correct."); + + container.remove(); +}); diff --git a/devtools/client/performance/test/browser_perf-tree-abstract-02.js b/devtools/client/performance/test/browser_perf-tree-abstract-02.js new file mode 100644 index 0000000000..0eeb539c32 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-abstract-02.js @@ -0,0 +1,213 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the abstract tree base class for the profiler's tree view + * has a functional public API. + */ + +const { + appendAndWaitForPaint, +} = require("devtools/client/performance/test/helpers/dom-utils"); +const { + synthesizeCustomTreeClass, +} = require("devtools/client/performance/test/helpers/synth-utils"); + +add_task(async function() { + const { MyCustomTreeItem, myDataSrc } = synthesizeCustomTreeClass(); + + const container = document.createXULElement("vbox"); + await appendAndWaitForPaint(gBrowser.selectedBrowser.parentNode, container); + + // Populate the tree and test the root item... + + const treeRoot = new MyCustomTreeItem(myDataSrc, { parent: null }); + treeRoot.autoExpandDepth = 1; + treeRoot.attachTo(container); + + ok(treeRoot.expanded, "The root node should now be expanded."); + ok(treeRoot.populated, "The root node should now be populated."); + + const fooItem = treeRoot.getChild(0); + const barItem = treeRoot.getChild(1); + ok( + !fooItem.expanded && !barItem.expanded, + "The 'foo' and 'bar' nodes should not be expanded yet." + ); + ok( + !fooItem.populated && !barItem.populated, + "The 'foo' and 'bar' nodes should not be populated yet." + ); + + fooItem.expand(); + barItem.expand(); + ok( + fooItem.expanded && barItem.expanded, + "The 'foo' and 'bar' nodes should now be expanded." + ); + ok( + !fooItem.populated, + "The 'foo' node should not be populated because it's empty." + ); + ok(barItem.populated, "The 'bar' node should now be populated."); + + const bazItem = barItem.getChild(0); + ok(!bazItem.expanded, "The 'bar' node should not be expanded yet."); + ok(!bazItem.populated, "The 'bar' node should not be populated yet."); + + bazItem.expand(); + ok(bazItem.expanded, "The 'baz' node should now be expanded."); + ok( + !bazItem.populated, + "The 'baz' node should not be populated because it's empty." + ); + + ok( + !treeRoot.getChild(-1) && !treeRoot.getChild(2), + "Calling `getChild` with out of bounds indices will return null (1)." + ); + ok( + !fooItem.getChild(-1) && !fooItem.getChild(0), + "Calling `getChild` with out of bounds indices will return null (2)." + ); + ok( + !barItem.getChild(-1) && !barItem.getChild(1), + "Calling `getChild` with out of bounds indices will return null (3)." + ); + ok( + !bazItem.getChild(-1) && !bazItem.getChild(0), + "Calling `getChild` with out of bounds indices will return null (4)." + ); + + // Finished expanding all nodes in the tree... + // Continue checking. + + is( + container.childNodes.length, + 4, + "The container node should now have four children available." + ); + is( + container.childNodes[0], + treeRoot.target, + "The root node's target is a child of the container node." + ); + is( + container.childNodes[1], + fooItem.target, + "The 'foo' node's target is a child of the container node." + ); + is( + container.childNodes[2], + barItem.target, + "The 'bar' node's target is a child of the container node." + ); + is( + container.childNodes[3], + bazItem.target, + "The 'baz' node's target is a child of the container node." + ); + + treeRoot.collapse(); + is( + container.childNodes.length, + 1, + "The container node should now have one children available." + ); + + ok(!treeRoot.expanded, "The root node should not be expanded anymore."); + ok( + fooItem.expanded && barItem.expanded && bazItem.expanded, + "The 'foo', 'bar' and 'baz' nodes should still be expanded." + ); + ok( + treeRoot.populated && barItem.populated, + "The root and 'bar' nodes should still be populated." + ); + ok( + !fooItem.populated && !bazItem.populated, + "The 'foo' and 'baz' nodes should still not be populated because they're empty." + ); + + treeRoot.expand(); + is( + container.childNodes.length, + 4, + "The container node should now have four children available again." + ); + + ok( + treeRoot.expanded && + fooItem.expanded && + barItem.expanded && + bazItem.expanded, + "The root, 'foo', 'bar' and 'baz' nodes should now be reexpanded." + ); + ok( + treeRoot.populated && barItem.populated, + "The root and 'bar' nodes should still be populated." + ); + ok( + !fooItem.populated && !bazItem.populated, + "The 'foo' and 'baz' nodes should still not be populated because they're empty." + ); + + // Test `focus` on the root node... + + treeRoot.focus(); + is( + document.commandDispatcher.focusedElement, + treeRoot.target, + "The root node is now focused." + ); + + // Test `focus` on a leaf node... + + bazItem.focus(); + is( + document.commandDispatcher.focusedElement, + bazItem.target, + "The 'baz' node is now focused." + ); + + // Test `remove`... + + barItem.remove(); + is( + container.childNodes.length, + 2, + "The container node should now have two children available." + ); + is( + container.childNodes[0], + treeRoot.target, + "The root node should be the first in the container node." + ); + is( + container.childNodes[1], + fooItem.target, + "The 'foo' node should be the second in the container node." + ); + + fooItem.remove(); + is( + container.childNodes.length, + 1, + "The container node should now have one children available." + ); + is( + container.childNodes[0], + treeRoot.target, + "The root node should be the only in the container node." + ); + + treeRoot.remove(); + is( + container.childNodes.length, + 0, + "The container node should now have no children available." + ); + + container.remove(); +}); diff --git a/devtools/client/performance/test/browser_perf-tree-abstract-03.js b/devtools/client/performance/test/browser_perf-tree-abstract-03.js new file mode 100644 index 0000000000..9ec289944b --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-abstract-03.js @@ -0,0 +1,207 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the abstract tree base class for the profiler's tree view + * is keyboard accessible. + */ + +const { + appendAndWaitForPaint, +} = require("devtools/client/performance/test/helpers/dom-utils"); +const { + synthesizeCustomTreeClass, +} = require("devtools/client/performance/test/helpers/synth-utils"); + +add_task(async function() { + const { MyCustomTreeItem, myDataSrc } = synthesizeCustomTreeClass(); + + const container = document.createXULElement("vbox"); + await appendAndWaitForPaint(gBrowser.selectedBrowser.parentNode, container); + + // Populate the tree by pressing RIGHT... + + const treeRoot = new MyCustomTreeItem(myDataSrc, { parent: null }); + treeRoot.attachTo(container); + treeRoot.focus(); + + key("VK_RIGHT"); + ok(treeRoot.expanded, "The root node is now expanded."); + is( + document.commandDispatcher.focusedElement, + treeRoot.target, + "The root node is still focused." + ); + + const fooItem = treeRoot.getChild(0); + const barItem = treeRoot.getChild(1); + + key("VK_RIGHT"); + ok(!fooItem.expanded, "The 'foo' node is not expanded yet."); + is( + document.commandDispatcher.focusedElement, + fooItem.target, + "The 'foo' node is now focused." + ); + + key("VK_RIGHT"); + ok(fooItem.expanded, "The 'foo' node is now expanded."); + is( + document.commandDispatcher.focusedElement, + fooItem.target, + "The 'foo' node is still focused." + ); + + key("VK_RIGHT"); + ok(!barItem.expanded, "The 'bar' node is not expanded yet."); + is( + document.commandDispatcher.focusedElement, + barItem.target, + "The 'bar' node is now focused." + ); + + key("VK_RIGHT"); + ok(barItem.expanded, "The 'bar' node is now expanded."); + is( + document.commandDispatcher.focusedElement, + barItem.target, + "The 'bar' node is still focused." + ); + + const bazItem = barItem.getChild(0); + + key("VK_RIGHT"); + ok(!bazItem.expanded, "The 'baz' node is not expanded yet."); + is( + document.commandDispatcher.focusedElement, + bazItem.target, + "The 'baz' node is now focused." + ); + + key("VK_RIGHT"); + ok(bazItem.expanded, "The 'baz' node is now expanded."); + is( + document.commandDispatcher.focusedElement, + bazItem.target, + "The 'baz' node is still focused." + ); + + // Test RIGHT on a leaf node. + + key("VK_RIGHT"); + is( + document.commandDispatcher.focusedElement, + bazItem.target, + "The 'baz' node is still focused." + ); + + // Test DOWN on a leaf node. + + key("VK_DOWN"); + is( + document.commandDispatcher.focusedElement, + bazItem.target, + "The 'baz' node is now refocused." + ); + + // Test UP. + + key("VK_UP"); + is( + document.commandDispatcher.focusedElement, + barItem.target, + "The 'bar' node is now refocused." + ); + + key("VK_UP"); + is( + document.commandDispatcher.focusedElement, + fooItem.target, + "The 'foo' node is now refocused." + ); + + key("VK_UP"); + is( + document.commandDispatcher.focusedElement, + treeRoot.target, + "The root node is now refocused." + ); + + // Test DOWN. + + key("VK_DOWN"); + is( + document.commandDispatcher.focusedElement, + fooItem.target, + "The 'foo' node is now refocused." + ); + + key("VK_DOWN"); + is( + document.commandDispatcher.focusedElement, + barItem.target, + "The 'bar' node is now refocused." + ); + + key("VK_DOWN"); + is( + document.commandDispatcher.focusedElement, + bazItem.target, + "The 'baz' node is now refocused." + ); + + // Test LEFT. + + key("VK_LEFT"); + ok(barItem.expanded, "The 'bar' node is still expanded."); + is( + document.commandDispatcher.focusedElement, + barItem.target, + "The 'bar' node is now refocused." + ); + + key("VK_LEFT"); + ok(!barItem.expanded, "The 'bar' node is not expanded anymore."); + is( + document.commandDispatcher.focusedElement, + barItem.target, + "The 'bar' node is still focused." + ); + + key("VK_LEFT"); + ok(treeRoot.expanded, "The root node is still expanded."); + is( + document.commandDispatcher.focusedElement, + treeRoot.target, + "The root node is now refocused." + ); + + key("VK_LEFT"); + ok(!treeRoot.expanded, "The root node is not expanded anymore."); + is( + document.commandDispatcher.focusedElement, + treeRoot.target, + "The root node is still focused." + ); + + // Test LEFT on the root node. + + key("VK_LEFT"); + is( + document.commandDispatcher.focusedElement, + treeRoot.target, + "The root node is still focused." + ); + + // Test UP on the root node. + + key("VK_UP"); + is( + document.commandDispatcher.focusedElement, + treeRoot.target, + "The root node is still focused." + ); + + container.remove(); +}); diff --git a/devtools/client/performance/test/browser_perf-tree-abstract-04.js b/devtools/client/performance/test/browser_perf-tree-abstract-04.js new file mode 100644 index 0000000000..fe5acb7be8 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-abstract-04.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the treeview expander arrow doesn't react to dblclick events. + */ + +const { + appendAndWaitForPaint, +} = require("devtools/client/performance/test/helpers/dom-utils"); +const { + synthesizeCustomTreeClass, +} = require("devtools/client/performance/test/helpers/synth-utils"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { MyCustomTreeItem, myDataSrc } = synthesizeCustomTreeClass(); + + const container = document.createXULElement("vbox"); + await appendAndWaitForPaint(gBrowser.selectedBrowser.parentNode, container); + + // Populate the tree and test the root item... + + const treeRoot = new MyCustomTreeItem(myDataSrc, { parent: null }); + treeRoot.attachTo(container); + + const originalTreeRootExpandedState = treeRoot.expanded; + info("Double clicking on the root item arrow and waiting for focus event."); + + const receivedFocusEvent = once(treeRoot, "focus"); + await dblclick(treeRoot.target.querySelector(".arrow")); + await receivedFocusEvent; + + is( + treeRoot.expanded, + originalTreeRootExpandedState, + "A double click on the arrow was ignored." + ); + + container.remove(); +}); diff --git a/devtools/client/performance/test/browser_perf-tree-abstract-05.js b/devtools/client/performance/test/browser_perf-tree-abstract-05.js new file mode 100644 index 0000000000..55cdb39224 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-abstract-05.js @@ -0,0 +1,139 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the abstract tree base class for the profiler's tree view + * supports PageUp/PageDown/Home/End keys. + */ + +const { + appendAndWaitForPaint, +} = require("devtools/client/performance/test/helpers/dom-utils"); +const { + synthesizeCustomTreeClass, +} = require("devtools/client/performance/test/helpers/synth-utils"); + +add_task(async function() { + const { MyCustomTreeItem } = synthesizeCustomTreeClass(); + + const container = document.createXULElement("vbox"); + container.style.height = "100%"; + container.style.overflow = "scroll"; + const browserStack = gBrowser.selectedBrowser.parentNode; + // Allow the browser stack to shrink since it will have really long content + browserStack.style.minHeight = "0"; + registerCleanupFunction(() => { + browserStack.style.removeProperty("min-height"); + }); + await appendAndWaitForPaint(browserStack, container); + + const myDataSrc = { + label: "root", + children: [], + }; + + for (let i = 0; i < 1000; i++) { + myDataSrc.children.push({ + label: "child-" + i, + children: [], + }); + } + + const treeRoot = new MyCustomTreeItem(myDataSrc, { parent: null }); + treeRoot.attachTo(container); + treeRoot.focus(); + treeRoot.expand(); + + is( + document.commandDispatcher.focusedElement, + treeRoot.target, + "The root node is focused." + ); + + // Test HOME and END + + key("VK_END"); + is( + document.commandDispatcher.focusedElement, + treeRoot.getChild(myDataSrc.children.length - 1).target, + "The last node is focused." + ); + + key("VK_HOME"); + is( + document.commandDispatcher.focusedElement, + treeRoot.target, + "The first (root) node is focused." + ); + + // Test PageUp and PageDown + + const nodesPerPageSize = treeRoot._getNodesPerPageSize(); + + key("VK_PAGE_DOWN"); + is( + document.commandDispatcher.focusedElement, + treeRoot.getChild(nodesPerPageSize - 1).target, + "The first node in the second page is focused." + ); + + key("VK_PAGE_DOWN"); + is( + document.commandDispatcher.focusedElement, + treeRoot.getChild(nodesPerPageSize * 2 - 1).target, + "The first node in the third page is focused." + ); + + key("VK_PAGE_UP"); + is( + document.commandDispatcher.focusedElement, + treeRoot.getChild(nodesPerPageSize - 1).target, + "The first node in the second page is focused." + ); + + key("VK_PAGE_UP"); + is( + document.commandDispatcher.focusedElement, + treeRoot.target, + "The first (root) node is focused." + ); + + // Test PageUp in the middle of the first page + + let middleIndex = Math.floor(nodesPerPageSize / 2); + + treeRoot.getChild(middleIndex).target.focus(); + is( + document.commandDispatcher.focusedElement, + treeRoot.getChild(middleIndex).target, + "The middle node in the first page is focused." + ); + + key("VK_PAGE_UP"); + is( + document.commandDispatcher.focusedElement, + treeRoot.target, + "The first (root) node is focused." + ); + + // Test PageDown in the middle of the last page + + middleIndex = Math.ceil(myDataSrc.children.length - middleIndex); + + treeRoot.getChild(middleIndex).target.focus(); + is( + document.commandDispatcher.focusedElement, + treeRoot.getChild(middleIndex).target, + "The middle node in the last page is focused." + ); + + key("VK_PAGE_DOWN"); + is( + document.commandDispatcher.focusedElement, + treeRoot.getChild(myDataSrc.children.length - 1).target, + "The last node is focused." + ); + + container.remove(); +}); diff --git a/devtools/client/performance/test/browser_perf-tree-view-01.js b/devtools/client/performance/test/browser_perf-tree-view-01.js new file mode 100644 index 0000000000..35b2164e61 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-view-01.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + http://foo/bar/creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler's tree view implementation works properly and + * creates the correct column structure. + */ + +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); +const { + CallView, +} = require("devtools/client/performance/modules/widgets/tree-view"); +const { + synthesizeProfile, +} = require("devtools/client/performance/test/helpers/synth-utils"); + +add_task(function() { + const profile = synthesizeProfile(); + const threadNode = new ThreadNode(profile.threads[0], { + startTime: 0, + endTime: 20, + }); + + // Don't display the synthesized (root) and the real (root) node twice. + threadNode.calls = threadNode.calls[0].calls; + + const treeRoot = new CallView({ frame: threadNode }); + const container = document.createXULElement("vbox"); + treeRoot.autoExpandDepth = 0; + treeRoot.attachTo(container); + + is( + container.childNodes.length, + 1, + "The container node should have one child available." + ); + is( + container.childNodes[0].className, + "call-tree-item", + "The root node in the tree has the correct class name." + ); + + is( + container.childNodes[0].childNodes.length, + 6, + "The root node in the tree has the correct number of children." + ); + is( + container.childNodes[0].querySelectorAll(".call-tree-cell").length, + 6, + "The root node in the tree has only 6 'call-tree-cell' children." + ); + + is( + container.childNodes[0].childNodes[0].getAttribute("type"), + "duration", + "The root node in the tree has a duration cell." + ); + is( + container.childNodes[0].childNodes[0].textContent.trim(), + "20 ms", + "The root node in the tree has the correct duration cell value." + ); + + is( + container.childNodes[0].childNodes[1].getAttribute("type"), + "percentage", + "The root node in the tree has a percentage cell." + ); + is( + container.childNodes[0].childNodes[1].textContent.trim(), + "100%", + "The root node in the tree has the correct percentage cell value." + ); + + is( + container.childNodes[0].childNodes[2].getAttribute("type"), + "self-duration", + "The root node in the tree has a self-duration cell." + ); + is( + container.childNodes[0].childNodes[2].textContent.trim(), + "0 ms", + "The root node in the tree has the correct self-duration cell value." + ); + + is( + container.childNodes[0].childNodes[3].getAttribute("type"), + "self-percentage", + "The root node in the tree has a self-percentage cell." + ); + is( + container.childNodes[0].childNodes[3].textContent.trim(), + "0%", + "The root node in the tree has the correct self-percentage cell value." + ); + + is( + container.childNodes[0].childNodes[4].getAttribute("type"), + "samples", + "The root node in the tree has an samples cell." + ); + is( + container.childNodes[0].childNodes[4].textContent.trim(), + "0", + "The root node in the tree has the correct samples cell value." + ); + + is( + container.childNodes[0].childNodes[5].getAttribute("type"), + "function", + "The root node in the tree has a function cell." + ); + is( + container.childNodes[0].childNodes[5].style.marginInlineStart, + "0px", + "The root node in the tree has the correct indentation." + ); +}); diff --git a/devtools/client/performance/test/browser_perf-tree-view-02.js b/devtools/client/performance/test/browser_perf-tree-view-02.js new file mode 100644 index 0000000000..222626e2eb --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-view-02.js @@ -0,0 +1,287 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler's tree view implementation works properly and + * creates the correct column structure after expanding some of the nodes. + * Also tests that demangling works. + */ + +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); +const { + CallView, +} = require("devtools/client/performance/modules/widgets/tree-view"); +const { + synthesizeProfile, +} = require("devtools/client/performance/test/helpers/synth-utils"); + +const MANGLED_FN = "__Z3FooIiEvv"; +const UNMANGLED_FN = "void Foo<int>()"; + +add_task(function() { + // Create a profile and mangle a function inside the string table. + const profile = synthesizeProfile(); + + profile.threads[0].stringTable[1] = profile.threads[0].stringTable[1].replace( + "A (", + `${MANGLED_FN} (` + ); + + const threadNode = new ThreadNode(profile.threads[0], { + startTime: 0, + endTime: 20, + }); + + // Don't display the synthesized (root) and the real (root) node twice. + threadNode.calls = threadNode.calls[0].calls; + + const treeRoot = new CallView({ frame: threadNode }); + const container = document.createXULElement("vbox"); + treeRoot.autoExpandDepth = 0; + treeRoot.attachTo(container); + + const $$ = node => container.querySelectorAll(node); + const $fun = (node, ancestor) => + (ancestor || container).querySelector( + ".call-tree-cell[type=function] > " + node + ); + const $$fun = (node, ancestor) => + (ancestor || container).querySelectorAll( + ".call-tree-cell[type=function] > " + node + ); + const $$dur = i => + container.querySelectorAll(".call-tree-cell[type=duration]")[i]; + const $$per = i => + container.querySelectorAll(".call-tree-cell[type=percentage]")[i]; + const $$sam = i => + container.querySelectorAll(".call-tree-cell[type=samples]")[i]; + + is( + container.childNodes.length, + 1, + "The container node should have one child available." + ); + is( + container.childNodes[0].className, + "call-tree-item", + "The root node in the tree has the correct class name." + ); + + is( + $$dur(0).textContent.trim(), + "20 ms", + "The root's duration cell displays the correct value." + ); + is( + $$per(0).textContent.trim(), + "100%", + "The root's percentage cell displays the correct value." + ); + is( + $$sam(0).textContent.trim(), + "0", + "The root's samples cell displays the correct value." + ); + is( + $$fun(".call-tree-name")[0].textContent.trim(), + "(root)", + "The root's function cell displays the correct name." + ); + is( + $$fun(".call-tree-url")[0], + undefined, + "The root's function cell displays no url." + ); + is( + $$fun(".call-tree-line")[0], + undefined, + "The root's function cell displays no line." + ); + is( + $$fun(".call-tree-host")[0], + undefined, + "The root's function cell displays no host." + ); + is( + $$fun(".call-tree-category")[0], + undefined, + "The root's function cell displays no category." + ); + + treeRoot.expand(); + + is( + container.childNodes.length, + 2, + "The container node should have two children available." + ); + is( + container.childNodes[0].className, + "call-tree-item", + "The root node in the tree has the correct class name." + ); + is( + container.childNodes[1].className, + "call-tree-item", + "The .A node in the tree has the correct class name." + ); + + // Test demangling in the profiler tree. + is( + $$dur(1).textContent.trim(), + "20 ms", + "The .A node's duration cell displays the correct value." + ); + is( + $$per(1).textContent.trim(), + "100%", + "The .A node's percentage cell displays the correct value." + ); + is( + $$sam(1).textContent.trim(), + "0", + "The .A node's samples cell displays the correct value." + ); + + is( + $fun(".call-tree-name", $$(".call-tree-item")[1]).textContent.trim(), + UNMANGLED_FN, + "The .A node's function cell displays the correct name." + ); + is( + $fun(".call-tree-url", $$(".call-tree-item")[1]).textContent.trim(), + "baz", + "The .A node's function cell displays the correct url." + ); + is( + $fun(".call-tree-line", $$(".call-tree-item")[1]).textContent.trim(), + ":12", + "The .A node's function cell displays the correct line." + ); + is( + $fun(".call-tree-host", $$(".call-tree-item")[1]).textContent.trim(), + "foo", + "The .A node's function cell displays the correct host." + ); + is( + $fun(".call-tree-category", $$(".call-tree-item")[1]).textContent.trim(), + "JIT", + "The .A node's function cell displays the correct category." + ); + + ok( + $$(".call-tree-item")[1] + .getAttribute("tooltiptext") + .includes(MANGLED_FN), + "The .A node's row's tooltip contains the original mangled name." + ); + + const A = treeRoot.getChild(); + A.expand(); + + is( + container.childNodes.length, + 4, + "The container node should have four children available." + ); + is( + container.childNodes[0].className, + "call-tree-item", + "The root node in the tree has the correct class name." + ); + is( + container.childNodes[1].className, + "call-tree-item", + "The .A node in the tree has the correct class name." + ); + is( + container.childNodes[2].className, + "call-tree-item", + "The .B node in the tree has the correct class name." + ); + is( + container.childNodes[3].className, + "call-tree-item", + "The .E node in the tree has the correct class name." + ); + + ok( + $fun(".call-tree-url", $$(".call-tree-item")[1]) + .getAttribute("tooltiptext") + .includes("http://foo/bar/baz"), + "The .A node's function cell displays the correct url tooltiptext." + ); + is( + $fun(".call-tree-line", $$(".call-tree-item")[1]).textContent.trim(), + ":12", + "The .A node's function cell displays the correct line." + ); + is( + $fun(".call-tree-column", $$(".call-tree-item")[1]).textContent.trim(), + ":9", + "The .A node's function cell displays the correct column." + ); + is( + $fun(".call-tree-host", $$(".call-tree-item")[1]).textContent.trim(), + "foo", + "The .A node's function cell displays the correct host." + ); + is( + $$dur(2).textContent.trim(), + "15 ms", + "The .A.B node's duration cell displays the correct value." + ); + is( + $$per(2).textContent.trim(), + "75%", + "The .A.B node's percentage cell displays the correct value." + ); + is( + $$sam(2).textContent.trim(), + "0", + "The .A.B node's samples cell displays the correct value." + ); + is( + $fun(".call-tree-name", $$(".call-tree-item")[2]).textContent.trim(), + "B InterruptibleLayout", + "The .A.B node's function cell displays the correct name." + ); + is( + $fun(".call-tree-url", $$(".call-tree-item")[2]), + null, + "The .A.B node's function cell has no url." + ); + is( + $fun(".call-tree-category", $$(".call-tree-item")[2]).textContent.trim(), + "Layout", + "The .A.B node's function cell displays the correct category." + ); + is( + $$dur(3).textContent.trim(), + "5 ms", + "The .A.E node's duration cell displays the correct value." + ); + is( + $$per(3).textContent.trim(), + "25%", + "The .A.E node's percentage cell displays the correct value." + ); + is( + $$sam(3).textContent.trim(), + "0", + "The .A.E node's samples cell displays the correct value." + ); + is( + $fun(".call-tree-name", $$(".call-tree-item")[3]).textContent.trim(), + "E", + "The .A.E node's function cell displays the correct name." + ); + is( + $fun(".call-tree-category", $$(".call-tree-item")[3]).textContent.trim(), + "GC", + "The .A.E node's function cell displays the correct category." + ); +}); diff --git a/devtools/client/performance/test/browser_perf-tree-view-03.js b/devtools/client/performance/test/browser_perf-tree-view-03.js new file mode 100644 index 0000000000..b69a71d51b --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-view-03.js @@ -0,0 +1,163 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler's tree view implementation works properly and + * creates the correct column structure and can auto-expand all nodes. + */ + +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); +const { + CallView, +} = require("devtools/client/performance/modules/widgets/tree-view"); +const { + synthesizeProfile, +} = require("devtools/client/performance/test/helpers/synth-utils"); + +add_task(function() { + const profile = synthesizeProfile(); + const threadNode = new ThreadNode(profile.threads[0], { + startTime: 0, + endTime: 20, + }); + + // Don't display the synthesized (root) and the real (root) node twice. + threadNode.calls = threadNode.calls[0].calls; + + const treeRoot = new CallView({ frame: threadNode }); + const container = document.createXULElement("vbox"); + treeRoot.attachTo(container); + + const $$fun = i => + container.querySelectorAll(".call-tree-cell[type=function]")[i]; + const $$nam = i => + container.querySelectorAll( + ".call-tree-cell[type=function] > .call-tree-name" + )[i]; + const $$dur = i => + container.querySelectorAll(".call-tree-cell[type=duration]")[i]; + + is( + container.childNodes.length, + 7, + "The container node should have all children available." + ); + is( + Array.from(container.childNodes).filter( + e => e.className != "call-tree-item" + ).length, + 0, + "All item nodes in the tree have the correct class name." + ); + + is( + $$fun(0).style.marginInlineStart, + "0px", + "The root node's function cell has the correct indentation." + ); + is( + $$fun(1).style.marginInlineStart, + "16px", + "The .A node's function cell has the correct indentation." + ); + is( + $$fun(2).style.marginInlineStart, + "32px", + "The .A.B node's function cell has the correct indentation." + ); + is( + $$fun(3).style.marginInlineStart, + "48px", + "The .A.B.D node's function cell has the correct indentation." + ); + is( + $$fun(4).style.marginInlineStart, + "48px", + "The .A.B.C node's function cell has the correct indentation." + ); + is( + $$fun(5).style.marginInlineStart, + "32px", + "The .A.E node's function cell has the correct indentation." + ); + is( + $$fun(6).style.marginInlineStart, + "48px", + "The .A.E.F node's function cell has the correct indentation." + ); + + is( + $$nam(0).textContent.trim(), + "(root)", + "The root node's function cell displays the correct name." + ); + is( + $$nam(1).textContent.trim(), + "A", + "The .A node's function cell displays the correct name." + ); + is( + $$nam(2).textContent.trim(), + "B InterruptibleLayout", + "The .A.B node's function cell displays the correct name." + ); + is( + $$nam(3).textContent.trim(), + "D INTER_SLICE_GC", + "The .A.B.D node's function cell displays the correct name." + ); + is( + $$nam(4).textContent.trim(), + "C", + "The .A.B.C node's function cell displays the correct name." + ); + is( + $$nam(5).textContent.trim(), + "E", + "The .A.E node's function cell displays the correct name." + ); + is( + $$nam(6).textContent.trim(), + "F", + "The .A.E.F node's function cell displays the correct name." + ); + + is( + $$dur(0).textContent.trim(), + "20 ms", + "The root node's function cell displays the correct duration." + ); + is( + $$dur(1).textContent.trim(), + "20 ms", + "The .A node's function cell displays the correct duration." + ); + is( + $$dur(2).textContent.trim(), + "15 ms", + "The .A.B node's function cell displays the correct duration." + ); + is( + $$dur(3).textContent.trim(), + "10 ms", + "The .A.B.D node's function cell displays the correct duration." + ); + is( + $$dur(4).textContent.trim(), + "5 ms", + "The .A.B.C node's function cell displays the correct duration." + ); + is( + $$dur(5).textContent.trim(), + "5 ms", + "The .A.E node's function cell displays the correct duration." + ); + is( + $$dur(6).textContent.trim(), + "5 ms", + "The .A.E.F node's function cell displays the correct duration." + ); +}); diff --git a/devtools/client/performance/test/browser_perf-tree-view-04.js b/devtools/client/performance/test/browser_perf-tree-view-04.js new file mode 100644 index 0000000000..f3281634de --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-view-04.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler's tree view implementation works properly and + * creates the correct DOM nodes in the correct order. + */ + +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); +const { + CallView, +} = require("devtools/client/performance/modules/widgets/tree-view"); +const { + synthesizeProfile, +} = require("devtools/client/performance/test/helpers/synth-utils"); + +add_task(function() { + const profile = synthesizeProfile(); + const threadNode = new ThreadNode(profile.threads[0], { + startTime: 0, + endTime: 20, + }); + + // Don't display the synthesized (root) and the real (root) node twice. + threadNode.calls = threadNode.calls[0].calls; + + const treeRoot = new CallView({ frame: threadNode }); + const container = document.createXULElement("vbox"); + treeRoot.attachTo(container); + + is( + treeRoot.target.getAttribute("origin"), + "chrome", + "The root node's 'origin' attribute is correct." + ); + is( + treeRoot.target.getAttribute("category"), + "", + "The root node's 'category' attribute is correct." + ); + is( + treeRoot.target.getAttribute("tooltiptext"), + "", + "The root node's 'tooltiptext' attribute is correct." + ); + is( + treeRoot.target.querySelector(".call-tree-category"), + null, + "The root node's category label cell should be hidden." + ); + + const A = treeRoot.getChild(); + const B = A.getChild(); + const D = B.getChild(); + + is( + A.target.getAttribute("tooltiptext"), + "A (http://foo/bar/baz:12:9)", + "The .A node's 'tooltiptext' attribute is correct" + ); + is( + D.target.getAttribute("origin"), + "chrome", + "The .A.B.D node's 'origin' attribute is correct." + ); + is( + D.target.getAttribute("category"), + "gc", + "The .A.B.D node's 'category' attribute is correct." + ); + is( + D.target.getAttribute("tooltiptext"), + "D INTER_SLICE_GC", + "The .A.B.D node's 'tooltiptext' attribute is correct." + ); + + is( + D.target.childNodes.length, + 6, + "The number of columns displayed for tree items is correct." + ); + is( + D.target.childNodes[0].getAttribute("type"), + "duration", + "The first column displayed for tree items is correct." + ); + is( + D.target.childNodes[1].getAttribute("type"), + "percentage", + "The third column displayed for tree items is correct." + ); + is( + D.target.childNodes[2].getAttribute("type"), + "self-duration", + "The second column displayed for tree items is correct." + ); + is( + D.target.childNodes[3].getAttribute("type"), + "self-percentage", + "The fourth column displayed for tree items is correct." + ); + is( + D.target.childNodes[4].getAttribute("type"), + "samples", + "The fifth column displayed for tree items is correct." + ); + is( + D.target.childNodes[5].getAttribute("type"), + "function", + "The sixth column displayed for tree items is correct." + ); + + const functionCell = A.target.childNodes[5]; + + is( + functionCell.childNodes.length, + 7, + "The number of columns displayed for function cells is correct." + ); + is( + functionCell.childNodes[0].className, + "arrow theme-twisty", + "The first node displayed for function cells is correct." + ); + is( + functionCell.childNodes[1].className, + "plain call-tree-name", + "The second node displayed for function cells is correct." + ); + is( + functionCell.childNodes[2].className, + "plain call-tree-url", + "The third node displayed for function cells is correct." + ); + is( + functionCell.childNodes[3].className, + "plain call-tree-line", + "The fourth node displayed for function cells is correct." + ); + is( + functionCell.childNodes[4].className, + "plain call-tree-column", + "The fifth node displayed for function cells is correct." + ); + is( + functionCell.childNodes[5].className, + "plain call-tree-host", + "The sixth node displayed for function cells is correct." + ); + is( + functionCell.childNodes[6].className, + "plain call-tree-category", + "The seventh node displayed for function cells is correct." + ); +}); diff --git a/devtools/client/performance/test/browser_perf-tree-view-05.js b/devtools/client/performance/test/browser_perf-tree-view-05.js new file mode 100644 index 0000000000..a70129717b --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-view-05.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler's tree view implementation works properly and + * can toggle categories hidden or visible. + */ + +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); +const { + CallView, +} = require("devtools/client/performance/modules/widgets/tree-view"); +const { + synthesizeProfile, +} = require("devtools/client/performance/test/helpers/synth-utils"); + +add_task(function() { + const profile = synthesizeProfile(); + const threadNode = new ThreadNode(profile.threads[0], { + startTime: 0, + endTime: 20, + }); + + // Don't display the synthesized (root) and the real (root) node twice. + threadNode.calls = threadNode.calls[0].calls; + + const treeRoot = new CallView({ frame: threadNode }); + const container = document.createXULElement("vbox"); + treeRoot.attachTo(container); + + const categories = container.querySelectorAll(".call-tree-category"); + is( + categories.length, + 6, + "The call tree displays a correct number of categories." + ); + ok( + !container.hasAttribute("categories-hidden"), + "All categories should be visible in the tree." + ); + + treeRoot.toggleCategories(false); + is( + categories.length, + 6, + "The call tree displays the same number of categories." + ); + ok( + container.hasAttribute("categories-hidden"), + "All categories should now be hidden in the tree." + ); +}); diff --git a/devtools/client/performance/test/browser_perf-tree-view-06.js b/devtools/client/performance/test/browser_perf-tree-view-06.js new file mode 100644 index 0000000000..1a51be9ea1 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-view-06.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler's tree view implementation works properly and + * correctly emits events when certain DOM nodes are clicked. + */ + +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); +const { + CallView, +} = require("devtools/client/performance/modules/widgets/tree-view"); +const { + synthesizeProfile, +} = require("devtools/client/performance/test/helpers/synth-utils"); +const { + idleWait, + waitUntil, +} = require("devtools/client/performance/test/helpers/wait-utils"); + +add_task(async function() { + const profile = synthesizeProfile(); + const threadNode = new ThreadNode(profile.threads[0], { + startTime: 0, + endTime: 20, + }); + + // Don't display the synthesized (root) and the real (root) node twice. + threadNode.calls = threadNode.calls[0].calls; + + const treeRoot = new CallView({ frame: threadNode }); + const container = document.createXULElement("vbox"); + treeRoot.attachTo(container); + + const A = treeRoot.getChild(); + + let linkEvent = null; + const handler = e => { + linkEvent = e; + }; + + treeRoot.on("link", handler); + + // Fire right click. + await rightMousedown(A.target.querySelector(".call-tree-url")); + + // Ensure link was not called for right click. + await idleWait(100); + ok(!linkEvent, "The `link` event not fired for right click."); + + // Fire left click. + await mousedown(A.target.querySelector(".call-tree-url")); + + // Ensure link was called for left click. + await waitUntil(() => linkEvent); + is(linkEvent, A, "The `link` event target is correct."); + + treeRoot.off("link", handler); +}); diff --git a/devtools/client/performance/test/browser_perf-tree-view-07.js b/devtools/client/performance/test/browser_perf-tree-view-07.js new file mode 100644 index 0000000000..a15ff1cbc0 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-view-07.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler's tree view implementation works properly and + * has the correct 'root', 'parent', 'level' etc. accessors on child nodes. + */ + +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); +const { + CallView, +} = require("devtools/client/performance/modules/widgets/tree-view"); +const { + synthesizeProfile, +} = require("devtools/client/performance/test/helpers/synth-utils"); + +add_task(function() { + const profile = synthesizeProfile(); + const threadNode = new ThreadNode(profile.threads[0], { + startTime: 0, + endTime: 20, + }); + + // Don't display the synthesized (root) and the real (root) node twice. + threadNode.calls = threadNode.calls[0].calls; + + const treeRoot = new CallView({ frame: threadNode }); + const container = document.createXULElement("vbox"); + container.id = "call-tree-container"; + treeRoot.attachTo(container); + + const A = treeRoot.getChild(); + const B = A.getChild(); + const D = B.getChild(); + + is(D.root, treeRoot, "The .A.B.D node has the correct root."); + is(D.parent, B, "The .A.B.D node has the correct parent."); + is(D.level, 3, "The .A.B.D node has the correct level."); + is( + D.target.className, + "call-tree-item", + "The .A.B.D node has the correct target node." + ); + is( + D.container.id, + "call-tree-container", + "The .A.B.D node has the correct container node." + ); +}); diff --git a/devtools/client/performance/test/browser_perf-tree-view-08.js b/devtools/client/performance/test/browser_perf-tree-view-08.js new file mode 100644 index 0000000000..f04029ab18 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-view-08.js @@ -0,0 +1,155 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the profiler's tree view renders generalized platform data + * when `contentOnly` is on correctly. + */ + +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); +const { + CallView, +} = require("devtools/client/performance/modules/widgets/tree-view"); +const { + CATEGORY_INDEX, +} = require("devtools/client/performance/modules/categories"); +const RecordingUtils = require("devtools/shared/performance/recording-utils"); + +add_task(function() { + const threadNode = new ThreadNode(gProfile.threads[0], { + startTime: 0, + endTime: 20, + contentOnly: true, + }); + + // Don't display the synthesized (root) and the real (root) node twice. + threadNode.calls = threadNode.calls[0].calls; + + const treeRoot = new CallView({ frame: threadNode, autoExpandDepth: 10 }); + const container = document.createXULElement("vbox"); + treeRoot.attachTo(container); + + /* + * (root) + * - A + * - B + * - C + * - D + * - (GC) + * - E + * - F + * - (JS) + * - (JS) + */ + + const A = treeRoot.getChild(0); + const JS = treeRoot.getChild(1); + const GC = A.getChild(1); + const JS2 = A.getChild(2) + .getChild() + .getChild(); + + is( + JS.target.getAttribute("category"), + "js", + "Generalized JS node has correct category" + ); + is( + JS.target.getAttribute("tooltiptext"), + "JIT", + "Generalized JS node has correct category" + ); + is( + JS.target.querySelector(".call-tree-name").textContent.trim(), + "JIT", + "Generalized JS node has correct display value as just the category name." + ); + + is( + JS2.target.getAttribute("category"), + "js", + "Generalized second JS node has correct category" + ); + is( + JS2.target.getAttribute("tooltiptext"), + "JIT", + "Generalized second JS node has correct category" + ); + is( + JS2.target.querySelector(".call-tree-name").textContent.trim(), + "JIT", + "Generalized second JS node has correct display value as just the category name." + ); + + is( + GC.target.getAttribute("category"), + "gc", + "Generalized GC node has correct category" + ); + is( + GC.target.getAttribute("tooltiptext"), + "GC", + "Generalized GC node has correct category" + ); + is( + GC.target.querySelector(".call-tree-name").textContent.trim(), + "GC", + "Generalized GC node has correct display value as just the category name." + ); +}); + +const gProfile = RecordingUtils.deflateProfile({ + meta: { version: 2 }, + threads: [ + { + samples: [ + { + time: 1, + frames: [ + { location: "(root)" }, + { location: "http://content/A" }, + { location: "http://content/B" }, + { location: "http://content/C" }, + ], + }, + { + time: 1 + 1, + frames: [ + { location: "(root)" }, + { location: "http://content/A" }, + { location: "http://content/B" }, + { location: "http://content/D" }, + ], + }, + { + time: 1 + 1 + 2, + frames: [ + { location: "(root)" }, + { location: "http://content/A" }, + { location: "http://content/E" }, + { location: "http://content/F" }, + { location: "platform_JS", category: CATEGORY_INDEX("js") }, + ], + }, + { + time: 1 + 1 + 2 + 3, + frames: [ + { location: "(root)" }, + { location: "platform_JS2", category: CATEGORY_INDEX("js") }, + ], + }, + { + time: 1 + 1 + 2 + 3 + 5, + frames: [ + { location: "(root)" }, + { location: "http://content/A" }, + { location: "platform_GC", category: CATEGORY_INDEX("gc") }, + ], + }, + ], + }, + ], +}); diff --git a/devtools/client/performance/test/browser_perf-tree-view-09.js b/devtools/client/performance/test/browser_perf-tree-view-09.js new file mode 100644 index 0000000000..651a09685f --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-view-09.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the profiler's tree view sorts inverted call trees by + * "self cost" and not "total cost". + */ + +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); +const { + CallView, +} = require("devtools/client/performance/modules/widgets/tree-view"); +const RecordingUtils = require("devtools/shared/performance/recording-utils"); + +add_task(function() { + const threadNode = new ThreadNode(gProfile.threads[0], { + startTime: 0, + endTime: 20, + invertTree: true, + }); + const treeRoot = new CallView({ frame: threadNode, inverted: true }); + const container = document.createXULElement("vbox"); + treeRoot.attachTo(container); + + is( + treeRoot.getChild(0).frame.location, + "B", + "The tree root's first child is the `B` function." + ); + is( + treeRoot.getChild(1).frame.location, + "A", + "The tree root's second child is the `A` function." + ); +}); + +const gProfile = RecordingUtils.deflateProfile({ + meta: { version: 2 }, + threads: [ + { + samples: [ + { + time: 1, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + ], + }, + { + time: 2, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + ], + }, + { + time: 3, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + ], + }, + { + time: 4, + frames: [{ location: "(root)" }, { location: "A" }], + }, + ], + }, + ], +}); diff --git a/devtools/client/performance/test/browser_perf-tree-view-10.js b/devtools/client/performance/test/browser_perf-tree-view-10.js new file mode 100644 index 0000000000..8b50ea9092 --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-view-10.js @@ -0,0 +1,195 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler's tree view, when inverted, displays the self and + * total costs correctly. + */ + +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); +const { + CallView, +} = require("devtools/client/performance/modules/widgets/tree-view"); +const RecordingUtils = require("devtools/shared/performance/recording-utils"); + +add_task(function() { + const threadNode = new ThreadNode(gProfile.threads[0], { + startTime: 0, + endTime: 50, + invertTree: true, + }); + const treeRoot = new CallView({ frame: threadNode, inverted: true }); + const container = document.createXULElement("vbox"); + treeRoot.attachTo(container); + + // Add 1 to each index to skip the hidden root node + const $$nam = i => + container.querySelectorAll( + ".call-tree-cell[type=function] > .call-tree-name" + )[i + 1]; + const $$per = i => + container.querySelectorAll(".call-tree-cell[type=percentage]")[i + 1]; + const $$selfper = i => + container.querySelectorAll(".call-tree-cell[type='self-percentage']")[ + i + 1 + ]; + + /** + * Samples: + * + * A->C + * A->B + * A->B->C x4 + * A->B->D x4 + * + * Expected: + * + * +--total--+--self--+--tree----+ + * | 50% | 50% | C | + * | 40% | 0 | -> B | + * | 30% | 0 | -> A | + * | 10% | 0 | -> A | + * | 40% | 40% | D | + * | 40% | 0 | -> B | + * | 40% | 0 | -> A | + * | 10% | 10% | B | + * | 10% | 0 | -> A | + * +---------+--------+----------+ + */ + + is( + container.childNodes.length, + 10, + "The container node should have all children available." + ); + + // total, self, indent + name + [ + [50, 50, "C"], + [40, 0, " B"], + [30, 0, " A"], + [10, 0, " A"], + [40, 40, "D"], + [40, 0, " B"], + [40, 0, " A"], + [10, 10, "B"], + [10, 0, " A"], + ].forEach(function(def, i) { + info(`Checking ${i}th tree item.`); + + let [total, self, name] = def; + name = name.trim(); + + is($$nam(i).textContent.trim(), name, `${name} has correct name.`); + is( + $$per(i).textContent.trim(), + `${total}%`, + `${name} has correct total percent.` + ); + is( + $$selfper(i).textContent.trim(), + `${self}%`, + `${name} has correct self percent.` + ); + }); +}); + +const gProfile = RecordingUtils.deflateProfile({ + meta: { version: 2 }, + threads: [ + { + samples: [ + { + time: 5, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + ], + }, + { + time: 10, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "D" }, + ], + }, + { + time: 15, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "C" }, + ], + }, + { + time: 20, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + ], + }, + { + time: 25, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + ], + }, + { + time: 30, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + ], + }, + { + time: 35, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "D" }, + ], + }, + { + time: 40, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "D" }, + ], + }, + { + time: 45, + frames: [ + { location: "(root)" }, + { location: "B" }, + { location: "C" }, + ], + }, + { + time: 50, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "D" }, + ], + }, + ], + }, + ], +}); diff --git a/devtools/client/performance/test/browser_perf-tree-view-11.js b/devtools/client/performance/test/browser_perf-tree-view-11.js new file mode 100644 index 0000000000..3a25f6c1dc --- /dev/null +++ b/devtools/client/performance/test/browser_perf-tree-view-11.js @@ -0,0 +1,154 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/* eslint-disable */ +/** + * Tests that if `show-jit-optimizations` is true, then an + * icon is next to the frame with optimizations + */ + +var { CATEGORY_MASK } = require("devtools/client/performance/modules/categories"); + +async function spawnTest() { + let { panel } = await initPerformance(SIMPLE_URL); + let { EVENTS, $, $$, window, PerformanceController } = panel.panelWin; + let { OverviewView, DetailsView, JsCallTreeView } = panel.panelWin; + + let profilerData = { threads: [gThread] }; + + Services.prefs.setBoolPref(JIT_PREF, true); + Services.prefs.setBoolPref(PLATFORM_DATA_PREF, false); + Services.prefs.setBoolPref(INVERT_PREF, false); + + // Make two recordings, so we have one to switch to later, as the + // second one will have fake sample data + await startRecording(panel); + await stopRecording(panel); + + await DetailsView.selectView("js-calltree"); + + await injectAndRenderProfilerData(); + + let rows = $$("#js-calltree-view .call-tree-item"); + is(rows.length, 4, "4 call tree rows exist"); + for (let row of rows) { + let name = $(".call-tree-name", row).textContent.trim(); + switch (name) { + case "A": + ok($(".opt-icon", row), "found an opt icon on a leaf node with opt data"); + break; + case "C": + ok(!$(".opt-icon", row), "frames without opt data do not have an icon"); + break; + case "Gecko": + ok(!$(".opt-icon", row), "meta category frames with opt data do not have an icon"); + break; + case "(root)": + ok(!$(".opt-icon", row), "root frame certainly does not have opt data"); + break; + default: + ok(false, `Unidentified frame: ${name}`); + break; + } + } + + await teardown(panel); + finish(); + + async function injectAndRenderProfilerData() { + // Get current recording and inject our mock data + info("Injecting mock profile data"); + let recording = PerformanceController.getCurrentRecording(); + recording._profile = profilerData; + + // Force a rerender + let rendered = once(JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED); + JsCallTreeView.render(OverviewView.getTimeInterval()); + await rendered; + } +} + +var gUniqueStacks = new RecordingUtils.UniqueStacks(); + +function uniqStr(s) { + return gUniqueStacks.getOrAddStringIndex(s); +} + +// Since deflateThread doesn't handle deflating optimization info, use +// placeholder names A_O1, B_O2, and B_O3, which will be used to manually +// splice deduped opts into the profile. +var gThread = RecordingUtils.deflateThread({ + samples: [{ + time: 0, + frames: [ + { location: "(root)" } + ] + }, { + time: 5, + frames: [ + { location: "(root)" }, + { location: "A (http://foo:1)" }, + ] + }, { + time: 5 + 1, + frames: [ + { location: "(root)" }, + { location: "C (http://foo/bar/baz:56)" } + ] + }, { + time: 5 + 1 + 2, + frames: [ + { location: "(root)" }, + { category: CATEGORY_MASK("other"), location: "PlatformCode" } + ] + }], + markers: [] +}, gUniqueStacks); + +// 3 RawOptimizationSites +var gRawSite1 = { + _testFrameInfo: { name: "A", line: "12", file: "@baz" }, + line: 12, + column: 2, + types: [{ + mirType: uniqStr("Object"), + site: uniqStr("A (http://foo/bar/bar:12)"), + typeset: [{ + keyedBy: uniqStr("constructor"), + name: uniqStr("Foo"), + location: uniqStr("A (http://foo/bar/baz:12)") + }, { + keyedBy: uniqStr("primitive"), + location: uniqStr("self-hosted") + }] + }], + attempts: { + schema: { + outcome: 0, + strategy: 1 + }, + data: [ + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure2"), uniqStr("SomeGetter2")], + [uniqStr("Failure3"), uniqStr("SomeGetter3")] + ] + } +}; + +gThread.frameTable.data.forEach((frame) => { + const LOCATION_SLOT = gThread.frameTable.schema.location; + const OPTIMIZATIONS_SLOT = gThread.frameTable.schema.optimizations; + + let l = gThread.stringTable[frame[LOCATION_SLOT]]; + switch (l) { + case "A (http://foo:1)": + frame[LOCATION_SLOT] = uniqStr("A (http://foo:1)"); + frame[OPTIMIZATIONS_SLOT] = gRawSite1; + break; + case "PlatformCode": + frame[LOCATION_SLOT] = uniqStr("PlatformCode"); + frame[OPTIMIZATIONS_SLOT] = gRawSite1; + break; + } +}); +/* eslint-enable */ diff --git a/devtools/client/performance/test/browser_perf-ui-recording.js b/devtools/client/performance/test/browser_perf-ui-recording.js new file mode 100644 index 0000000000..fbf1f4611d --- /dev/null +++ b/devtools/client/performance/test/browser_perf-ui-recording.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the controller handles recording via the `stopwatch` button + * in the UI. + */ + +const { + pmmInitWithBrowser, + pmmIsProfilerActive, +} = require("devtools/client/performance/test/helpers/profiler-mm-utils"); +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, +} = require("devtools/client/performance/test/helpers/actions"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + pmmInitWithBrowser(gBrowser); + + ok( + !(await pmmIsProfilerActive()), + "The built-in profiler module should not have been automatically started." + ); + + await startRecording(panel); + + ok( + await pmmIsProfilerActive(), + "The built-in profiler module should now be active." + ); + + await stopRecording(panel); + + ok( + await pmmIsProfilerActive(), + "The built-in profiler module should still be active." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_timeline-filters-01.js b/devtools/client/performance/test/browser_timeline-filters-01.js new file mode 100644 index 0000000000..ff9698cbf8 --- /dev/null +++ b/devtools/client/performance/test/browser_timeline-filters-01.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable */ + +/** + * Tests markers filtering mechanism. + */ + +const EPSILON = 0.00000001; + +async function spawnTest() { + let { panel } = await initPerformance(SIMPLE_URL); + let { $, $$, EVENTS, PerformanceController, OverviewView, WaterfallView } = panel.panelWin; + let { TimelineGraph } = require("devtools/client/performance/modules/widgets/graphs"); + let { rowHeight: MARKERS_GRAPH_ROW_HEIGHT } = TimelineGraph.prototype; + + await startRecording(panel); + ok(true, "Recording has started."); + + await waitUntil(() => { + // Wait until we get 3 different markers. + let markers = PerformanceController.getCurrentRecording().getMarkers(); + return markers.some(m => m.name == "Styles") && + markers.some(m => m.name == "Reflow") && + markers.some(m => m.name == "Paint"); + }); + + await stopRecording(panel); + ok(true, "Recording has ended."); + + // Push some fake markers of a type we do not have a blueprint for + let markers = PerformanceController.getCurrentRecording().getMarkers(); + let endTime = markers[markers.length - 1].end; + markers.push({ name: "CustomMarker", start: endTime + EPSILON, end: endTime + (EPSILON * 2) }); + markers.push({ name: "CustomMarker", start: endTime + (EPSILON * 3), end: endTime + (EPSILON * 4) }); + + // Invalidate marker cache + WaterfallView._cache.delete(markers); + + // Select everything + let waterfallRendered = WaterfallView.once(EVENTS.UI_WATERFALL_RENDERED); + OverviewView.setTimeInterval({ startTime: 0, endTime: Number.MAX_VALUE }); + + $("#filter-button").click(); + let menuItem1 = $("menuitem[marker-type=Styles]"); + let menuItem2 = $("menuitem[marker-type=Reflow]"); + let menuItem3 = $("menuitem[marker-type=Paint]"); + let menuItem4 = $("menuitem[marker-type=UNKNOWN]"); + + let overview = OverviewView.graphs.get("timeline"); + let originalHeight = overview.fixedHeight; + + await waterfallRendered; + + 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)"); + ok($(".waterfall-marker-bar[type=CustomMarker]"), "Found at least one 'Unknown' marker (1)"); + + let heightBefore = overview.fixedHeight; + EventUtils.synthesizeMouseAtCenter(menuItem1, {type: "mouseup"}, panel.panelWin); + await waitForOverviewAndCommand(overview, menuItem1); + + 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)"); + ok($(".waterfall-marker-bar[type=CustomMarker]"), "Found at least one 'Unknown' marker (2)"); + + heightBefore = overview.fixedHeight; + EventUtils.synthesizeMouseAtCenter(menuItem2, {type: "mouseup"}, panel.panelWin); + await waitForOverviewAndCommand(overview, menuItem2); + + 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)"); + ok($(".waterfall-marker-bar[type=CustomMarker]"), "Found at least one 'Unknown' marker (3)"); + + heightBefore = overview.fixedHeight; + EventUtils.synthesizeMouseAtCenter(menuItem3, {type: "mouseup"}, panel.panelWin); + await waitForOverviewAndCommand(overview, menuItem3); + + is(overview.fixedHeight, heightBefore - MARKERS_GRAPH_ROW_HEIGHT, "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)"); + ok($(".waterfall-marker-bar[type=CustomMarker]"), "Found at least one 'Unknown' marker (4)"); + + EventUtils.synthesizeMouseAtCenter(menuItem4, {type: "mouseup"}, panel.panelWin); + await waitForOverviewAndCommand(overview, menuItem4); + + ok(!$(".waterfall-marker-bar[type=Styles]"), "No 'Styles' marker (5)"); + ok(!$(".waterfall-marker-bar[type=Reflow]"), "No 'Reflow' marker (5)"); + ok(!$(".waterfall-marker-bar[type=Paint]"), "No 'Paint' marker (5)"); + ok(!$(".waterfall-marker-bar[type=CustomMarker]"), "No 'Unknown' marker (5)"); + + for (let item of [menuItem1, menuItem2, menuItem3]) { + EventUtils.synthesizeMouseAtCenter(item, {type: "mouseup"}, panel.panelWin); + await waitForOverviewAndCommand(overview, item); + } + + ok($(".waterfall-marker-bar[type=Styles]"), "Found at least one 'Styles' marker (6)"); + ok($(".waterfall-marker-bar[type=Reflow]"), "Found at least one 'Reflow' marker (6)"); + ok($(".waterfall-marker-bar[type=Paint]"), "Found at least one 'Paint' marker (6)"); + ok(!$(".waterfall-marker-bar[type=CustomMarker]"), "No 'Unknown' marker (6)"); + + is(overview.fixedHeight, originalHeight, "Overview restored"); + + await teardown(panel); + finish(); +} + +function waitForOverviewAndCommand(overview, item) { + let overviewRendered = overview.once("refresh"); + let menuitemCommandDispatched = once(item, "command"); + return Promise.all([overviewRendered, menuitemCommandDispatched]); +} +/* eslint-enable */ diff --git a/devtools/client/performance/test/browser_timeline-filters-02.js b/devtools/client/performance/test/browser_timeline-filters-02.js new file mode 100644 index 0000000000..6d94853dc6 --- /dev/null +++ b/devtools/client/performance/test/browser_timeline-filters-02.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/* eslint-disable */ +/** + * Tests markers filtering mechanism. + */ + +const URL = EXAMPLE_URL + "doc_innerHTML.html"; + +async function spawnTest() { + let { panel } = await initPerformance(URL); + let { $, $$, EVENTS, PerformanceController, OverviewView, WaterfallView } = panel.panelWin; + + await startRecording(panel); + ok(true, "Recording has started."); + + await waitUntil(() => { + let markers = PerformanceController.getCurrentRecording().getMarkers(); + return markers.some(m => m.name == "Parse HTML") && + markers.some(m => m.name == "Javascript"); + }); + + let waterfallRendered = WaterfallView.once(EVENTS.UI_WATERFALL_RENDERED); + await stopRecording(panel); + + $("#filter-button").click(); + let filterJS = $("menuitem[marker-type=Javascript]"); + + await waterfallRendered; + + ok($(".waterfall-marker-bar[type=Javascript]"), "Found at least one 'Javascript' marker"); + ok(!$(".waterfall-marker-bar[type='Parse HTML']"), "Found no Parse HTML markers as they are nested still"); + + EventUtils.synthesizeMouseAtCenter(filterJS, {type: "mouseup"}, panel.panelWin); + await Promise.all([ + WaterfallView.once(EVENTS.UI_WATERFALL_RENDERED), + once(filterJS, "command") + ]); + + ok(!$(".waterfall-marker-bar[type=Javascript]"), "Javascript markers are all hidden."); + ok($(".waterfall-marker-bar[type='Parse HTML']"), + "Found at least one 'Parse HTML' marker still visible after hiding JS markers"); + + await teardown(panel); + finish(); +} +/* eslint-enable */ diff --git a/devtools/client/performance/test/browser_timeline-waterfall-background.js b/devtools/client/performance/test/browser_timeline-waterfall-background.js new file mode 100644 index 0000000000..2b165e7991 --- /dev/null +++ b/devtools/client/performance/test/browser_timeline-waterfall-background.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the waterfall background is a 1px high canvas stretching across + * the container bounds. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, + waitForOverviewRenderedWithMarkers, +} = require("devtools/client/performance/test/helpers/actions"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { WaterfallView } = panel.panelWin; + + await startRecording(panel); + ok(true, "Recording has started."); + + // Ensure overview is rendering and some markers were received. + await waitForOverviewRenderedWithMarkers(panel); + + await stopRecording(panel); + ok(true, "Recording has ended."); + + // Test the waterfall background. + + ok( + WaterfallView.canvas, + "A canvas should be created after the recording ended." + ); + + is( + WaterfallView.canvas.width, + WaterfallView.waterfallWidth, + "The canvas width is correct." + ); + is(WaterfallView.canvas.height, 1, "The canvas height is correct."); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_timeline-waterfall-generic.js b/devtools/client/performance/test/browser_timeline-waterfall-generic.js new file mode 100644 index 0000000000..f356845229 --- /dev/null +++ b/devtools/client/performance/test/browser_timeline-waterfall-generic.js @@ -0,0 +1,154 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the waterfall is properly built after finishing a recording. + */ + +const { SIMPLE_URL } = require("devtools/client/performance/test/helpers/urls"); +const { + initPerformanceInNewTab, + teardownToolboxAndRemoveTab, +} = require("devtools/client/performance/test/helpers/panel-utils"); +const { + startRecording, + stopRecording, + waitForOverviewRenderedWithMarkers, +} = require("devtools/client/performance/test/helpers/actions"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +add_task(async function() { + const { panel } = await initPerformanceInNewTab({ + url: SIMPLE_URL, + win: window, + }); + + const { $, $$, EVENTS, WaterfallView } = panel.panelWin; + + await startRecording(panel); + ok(true, "Recording has started."); + + // Ensure overview is rendering and some markers were received. + await waitForOverviewRenderedWithMarkers(panel); + + await stopRecording(panel); + ok(true, "Recording has ended."); + + // Test the header container. + + ok($(".waterfall-header"), "A header container should have been created."); + + // Test the header sidebar (left). + + ok( + $(".waterfall-header > .waterfall-sidebar"), + "A header sidebar node should have been created." + ); + + // 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 sidebar (left). + + ok( + $$(".waterfall-tree-item > .waterfall-sidebar").length, + "Some marker sidebar nodes should have been created." + ); + ok( + $$(".waterfall-tree-item > .waterfall-sidebar > .waterfall-marker-bullet") + .length, + "Some marker color bullets should have been created inside the sidebar." + ); + ok( + $$(".waterfall-tree-item > .waterfall-sidebar > .waterfall-marker-name") + .length, + "Some marker name labels should have been created inside the sidebar." + ); + + // Test the markers waterfall (right). + + ok( + $$(".waterfall-tree-item > .waterfall-marker").length, + "Some marker waterfall nodes should have been created." + ); + ok( + $$(".waterfall-tree-item > .waterfall-marker .waterfall-marker-bar").length, + "Some marker color bars should have been created inside the waterfall." + ); + + // Test the sidebar. + + const detailsView = WaterfallView.details; + // Make sure the bounds are up to date. + WaterfallView._recalculateBounds(); + + const parentWidthBefore = $("#waterfall-view").getBoundingClientRect().width; + const sidebarWidthBefore = $(".waterfall-sidebar").getBoundingClientRect() + .width; + const detailsWidthBefore = $("#waterfall-details").getBoundingClientRect() + .width; + + ok( + detailsView.hidden, + "The details view in the waterfall view is hidden by default." + ); + is(detailsWidthBefore, 0, "The details view width should be 0 when hidden."); + is( + WaterfallView.waterfallWidth, + parentWidthBefore - + sidebarWidthBefore - + WaterfallView.WATERFALL_MARKER_SIDEBAR_SAFE_BOUNDS, + "The waterfall width is correct (1)." + ); + + const waterfallRerendered = once(WaterfallView, EVENTS.UI_WATERFALL_RENDERED); + $$(".waterfall-tree-item")[0].click(); + await waterfallRerendered; + + const parentWidthAfter = $("#waterfall-view").getBoundingClientRect().width; + const sidebarWidthAfter = $(".waterfall-sidebar").getBoundingClientRect() + .width; + const detailsWidthAfter = $("#waterfall-details").getBoundingClientRect() + .width; + + ok( + !detailsView.hidden, + "The details view in the waterfall view is now visible." + ); + is( + parentWidthBefore, + parentWidthAfter, + "The parent view's width should not have changed." + ); + is( + sidebarWidthBefore, + sidebarWidthAfter, + "The sidebar view's width should not have changed." + ); + isnot( + detailsWidthAfter, + 0, + "The details view width should not be 0 when visible." + ); + is( + WaterfallView.waterfallWidth, + parentWidthAfter - + sidebarWidthAfter - + detailsWidthAfter - + WaterfallView.WATERFALL_MARKER_SIDEBAR_SAFE_BOUNDS, + "The waterfall width is correct (2)." + ); + + await teardownToolboxAndRemoveTab(panel); +}); diff --git a/devtools/client/performance/test/browser_timeline-waterfall-rerender.js b/devtools/client/performance/test/browser_timeline-waterfall-rerender.js new file mode 100644 index 0000000000..bab37542cf --- /dev/null +++ b/devtools/client/performance/test/browser_timeline-waterfall-rerender.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable */ +/** + * Tests if the waterfall remembers the selection when rerendering. + */ + +async function spawnTest() { + let { target, panel } = await initPerformance(SIMPLE_URL); + let { $, $$, EVENTS, PerformanceController, OverviewView, WaterfallView } = panel.panelWin; + + const MIN_MARKERS_COUNT = 50; + const MAX_MARKERS_SELECT = 20; + + await startRecording(panel); + ok(true, "Recording has started."); + + let updated = 0; + OverviewView.on(EVENTS.UI_OVERVIEW_RENDERED, () => updated++); + + ok((await waitUntil(() => updated > 0)), + "The overview graphs were updated a bunch of times."); + ok((await waitUntil(() => PerformanceController.getCurrentRecording().getMarkers().length > MIN_MARKERS_COUNT)), + "There are some markers available."); + + await stopRecording(panel); + ok(true, "Recording has ended."); + + let currentMarkers = PerformanceController.getCurrentRecording().getMarkers(); + info("Gathered markers: " + JSON.stringify(currentMarkers, null, 2)); + + let initialBarsCount = $$(".waterfall-marker-bar").length; + info("Initial bars count: " + initialBarsCount); + + // Select a portion of the overview. + let rerendered = WaterfallView.once(EVENTS.UI_WATERFALL_RENDERED); + OverviewView.setTimeInterval({ startTime: 0, endTime: currentMarkers[MAX_MARKERS_SELECT].end }); + await rerendered; + + ok(!$(".waterfall-tree-item:focus"), + "There is no item focused in the waterfall yet."); + ok($("#waterfall-details").hidden, + "The waterfall sidebar is initially hidden."); + + // Focus the second item in the tree. + WaterfallView._markersRoot.getChild(1).focus(); + + let beforeResizeBarsCount = $$(".waterfall-marker-bar").length; + info("Before resize bars count: " + beforeResizeBarsCount); + ok(beforeResizeBarsCount < initialBarsCount, + "A subset of the total markers was selected."); + + is(Array.prototype.indexOf.call($$(".waterfall-tree-item"), $(".waterfall-tree-item:focus")), 2, + "The correct item was focused in the tree."); + ok(!$("#waterfall-details").hidden, + "The waterfall sidebar is now visible."); + + // Simulate a resize on the marker details. + rerendered = WaterfallView.once(EVENTS.UI_WATERFALL_RENDERED); + await EventUtils.sendMouseEvent({ type: "mouseup" }, WaterfallView.detailsSplitter); + await rerendered; + + let afterResizeBarsCount = $$(".waterfall-marker-bar").length; + info("After resize bars count: " + afterResizeBarsCount); + is(afterResizeBarsCount, beforeResizeBarsCount, + "The same subset of the total markers remained visible."); + + is(Array.prototype.indexOf.call($$(".waterfall-tree-item"), $(".waterfall-tree-item:focus")), 2, + "The correct item is still focused in the tree."); + ok(!$("#waterfall-details").hidden, + "The waterfall sidebar is still visible."); + + await teardown(panel); + finish(); +} +/* eslint-enable */ diff --git a/devtools/client/performance/test/browser_timeline-waterfall-sidebar.js b/devtools/client/performance/test/browser_timeline-waterfall-sidebar.js new file mode 100644 index 0000000000..e82c463742 --- /dev/null +++ b/devtools/client/performance/test/browser_timeline-waterfall-sidebar.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable */ +/** + * Tests if the sidebar is properly updated when a marker is selected. + */ + +async function spawnTest() { + let { target, panel } = await initPerformance(SIMPLE_URL); + let { $, $$, PerformanceController, WaterfallView } = panel.panelWin; + let { L10N } = require("devtools/client/performance/modules/global"); + let { MarkerBlueprintUtils } = require("devtools/client/performance/modules/marker-blueprint-utils"); + + // Hijack the markers massaging part of creating the waterfall view, + // to prevent collapsing markers and allowing this test to verify + // everything individually. A better solution would be to just expand + // all markers first and then skip the meta nodes, but I'm lazy. + WaterfallView._prepareWaterfallTree = markers => { + return { submarkers: markers }; + }; + + await startRecording(panel); + ok(true, "Recording has started."); + + await waitUntil(() => { + // Wait until we get 3 different markers. + let markers = PerformanceController.getCurrentRecording().getMarkers(); + return markers.some(m => m.name == "Styles") && + markers.some(m => m.name == "Reflow") && + markers.some(m => m.name == "Paint"); + }); + + await stopRecording(panel); + ok(true, "Recording has ended."); + + info("No need to select everything in the timeline."); + info("All the markers should be displayed by default."); + + let bars = $$(".waterfall-marker-bar"); + let markers = PerformanceController.getCurrentRecording().getMarkers(); + + info(`Got ${bars.length} bars and ${markers.length} markers.`); + info("Markers types from datasrc: " + Array.from(markers, e => e.name)); + info("Markers names from sidebar: " + Array.from(bars, e => e.parentNode.parentNode.querySelector(".waterfall-marker-name").getAttribute("value"))); + + ok(bars.length > 2, "Got at least 3 markers (1)"); + ok(markers.length > 2, "Got at least 3 markers (2)"); + + let toMs = ms => L10N.getFormatStrWithNumbers("timeline.tick", ms); + + for (let i = 0; i < bars.length; i++) { + let bar = bars[i]; + let mkr = markers[i]; + await EventUtils.sendMouseEvent({ type: "mousedown" }, bar); + + let type = $(".marker-details-type").getAttribute("value"); + let tooltip = $(".marker-details-duration").getAttribute("tooltiptext"); + let duration = $(".marker-details-duration .marker-details-labelvalue").getAttribute("value"); + + info("Current marker data: " + mkr.toSource()); + info("Current marker output: " + $("#waterfall-details").innerHTML); + + is(type, MarkerBlueprintUtils.getMarkerLabel(mkr), "Sidebar title matches markers name."); + + // Values are rounded. We don't use a strict equality. + is(toMs(mkr.end - mkr.start), duration, "Sidebar duration is valid."); + + // For some reason, anything that creates "→" here turns it into a "â" for some reason. + // So just check that start and end time are in there somewhere. + ok(tooltip.includes(toMs(mkr.start)), "Tooltip has start time."); + ok(tooltip.includes(toMs(mkr.end)), "Tooltip has end time."); + } + + await teardown(panel); + finish(); +} +/* eslint-enable */ diff --git a/devtools/client/performance/test/browser_timeline-waterfall-workers.js b/devtools/client/performance/test/browser_timeline-waterfall-workers.js new file mode 100644 index 0000000000..595ca378a5 --- /dev/null +++ b/devtools/client/performance/test/browser_timeline-waterfall-workers.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/* eslint-disable */ +/** + * Tests if the sidebar is properly updated with worker markers. + */ + +async function spawnTest() { + let { panel } = await initPerformance(WORKER_URL); + let { $$, $, PerformanceController } = panel.panelWin; + + await startRecording(panel); + ok(true, "Recording has started."); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.wrappedJSObject.performWork(); + }); + + await waitUntil(() => { + // Wait until we get the worker markers. + let markers = PerformanceController.getCurrentRecording().getMarkers(); + if (!markers.some(m => m.name == "Worker") || + !markers.some(m => m.workerOperation == "serializeDataOffMainThread") || + !markers.some(m => m.workerOperation == "serializeDataOnMainThread") || + !markers.some(m => m.workerOperation == "deserializeDataOffMainThread") || + !markers.some(m => m.workerOperation == "deserializeDataOnMainThread")) { + return false; + } + + testWorkerMarkerData(markers.find(m => m.name == "Worker")); + return true; + }); + + await stopRecording(panel); + ok(true, "Recording has ended."); + + for (let node of $$(".waterfall-marker-name[value=Worker")) { + testWorkerMarkerUI(node.parentNode.parentNode); + } + + await teardown(panel); + finish(); +} + +function testWorkerMarkerData(marker) { + ok(true, "Found a worker marker."); + + ok("start" in marker, + "The start time is specified in the worker marker."); + ok("end" in marker, + "The end time is specified in the worker marker."); + + ok("workerOperation" in marker, + "The worker operation is specified in the worker marker."); + + ok("processType" in marker, + "The process type is specified in the worker marker."); + ok("isOffMainThread" in marker, + "The thread origin is specified in the worker marker."); +} + +function testWorkerMarkerUI(node) { + is(node.className, "waterfall-tree-item", + "The marker node has the correct class name."); + ok(node.hasAttribute("otmt"), + "The marker node specifies if it is off the main thread or not."); +} + +/* eslint-enable */ diff --git a/devtools/client/performance/test/doc_allocs.html b/devtools/client/performance/test/doc_allocs.html new file mode 100644 index 0000000000..83f927e43d --- /dev/null +++ b/devtools/client/performance/test/doc_allocs.html @@ -0,0 +1,26 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Performance test page</title> + </head> + + <body> + <script type="text/javascript"> + "use strict"; + const allocs = []; + function test() { + for (let i = 0; i < 10; i++) { + allocs.push({}); + } + } + + // Prevent this script from being garbage collected. + window.setInterval(test, 1); + </script> + </body> + +</html> diff --git a/devtools/client/performance/test/doc_innerHTML.html b/devtools/client/performance/test/doc_innerHTML.html new file mode 100644 index 0000000000..e58b32f51e --- /dev/null +++ b/devtools/client/performance/test/doc_innerHTML.html @@ -0,0 +1,21 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Performance tool + innerHTML test page</title> + </head> + + <body> + <script type="text/javascript"> + "use strict"; + window.test = function() { + document.body.innerHTML = "<h1>LOL</h1>"; + }; + setInterval(window.test, 100); + </script> + </body> + +</html> diff --git a/devtools/client/performance/test/doc_markers.html b/devtools/client/performance/test/doc_markers.html new file mode 100644 index 0000000000..e7cbf84b98 --- /dev/null +++ b/devtools/client/performance/test/doc_markers.html @@ -0,0 +1,38 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Performance tool marker generation</title> + </head> + + <body> + <script type="text/javascript"> + "use strict"; + function test() { + let i = 10; + // generate sync styles and reflows + while (--i) { + /* eslint-disable no-unused-vars */ + const h = document.body.clientHeight; + /* eslint-enable no-unused-vars */ + document.body.style.height = (200 + i) + "px"; + // paint + document.body.style.borderTop = i + "px solid red"; + } + console.time("!!!"); + test2(); + } + function test2() { + console.timeStamp("go"); + console.timeEnd("!!!"); + } + + // Prevent this script from being garbage collected. + window.setInterval(test, 1); + </script> + </body> + +</html> diff --git a/devtools/client/performance/test/doc_simple-test.html b/devtools/client/performance/test/doc_simple-test.html new file mode 100644 index 0000000000..5cda6eaa61 --- /dev/null +++ b/devtools/client/performance/test/doc_simple-test.html @@ -0,0 +1,27 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Performance test page</title> + </head> + + <body> + <script type="text/javascript"> + "use strict"; + let x = 1; + function test() { + document.body.style.borderTop = x + "px solid red"; + x = 1 ^ x; + // flush pending reflows + document.body.innerHeight; + } + + // Prevent this script from being garbage collected. + window.setInterval(test, 1); + </script> + </body> + +</html> diff --git a/devtools/client/performance/test/doc_worker.html b/devtools/client/performance/test/doc_worker.html new file mode 100644 index 0000000000..0637b17690 --- /dev/null +++ b/devtools/client/performance/test/doc_worker.html @@ -0,0 +1,29 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Performance test page</title> + </head> + + <body> + <script type="text/javascript"> + "use strict"; + + /* exported performWork */ + function performWork() { + const worker = new Worker("js_simpleWorker.js"); + + worker.addEventListener("message", function(e) { + console.log(e.data); + console.timeStamp("Done"); + }); + + worker.postMessage("Hello World"); + } + </script> + </body> + +</html> diff --git a/devtools/client/performance/test/head.js b/devtools/client/performance/test/head.js new file mode 100644 index 0000000000..f97eb393b9 --- /dev/null +++ b/devtools/client/performance/test/head.js @@ -0,0 +1,117 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from ../../shared/test/telemetry-test-helpers.js */ + +"use strict"; + +const { require, loader } = ChromeUtils.import( + "resource://devtools/shared/Loader.jsm" +); + +try { + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/telemetry-test-helpers.js", + this + ); +} catch (e) { + ok( + false, + "MISSING DEPENDENCY ON telemetry-test-helpers.js\n" + + "Please add the following line in browser.ini:\n" + + " !/devtools/client/shared/test/telemetry-test-helpers.js\n" + ); + throw e; +} + +/* exported loader, either, click, dblclick, mousedown, rightMousedown, key */ +// All tests are asynchronous. +waitForExplicitFinish(); + +// Performance tests are much heavier because of their reliance on the +// profiler module, memory measurements, frequent canvas operations etc. Many of +// of them take longer than 30 seconds to finish on try server VMs, even though +// they superficially do very little. +requestLongerTimeout(3); + +// Same as `is`, but takes in two possible values. +const either = (value, a, b, message) => { + if (value == a) { + is(value, a, message); + } else if (value == b) { + is(value, b, message); + } else { + ok(false, message); + } +}; + +// Shortcut for simulating a click on an element. +const click = async (node, win = window) => { + await EventUtils.sendMouseEvent({ type: "click" }, node, win); +}; + +// Shortcut for simulating a double click on an element. +const dblclick = async (node, win = window) => { + await EventUtils.sendMouseEvent({ type: "dblclick" }, node, win); +}; + +// Shortcut for simulating a mousedown on an element. +const mousedown = async (node, win = window) => { + await EventUtils.sendMouseEvent({ type: "mousedown" }, node, win); +}; + +// Shortcut for simulating a mousedown using the right mouse button on an element. +const rightMousedown = async (node, win = window) => { + await EventUtils.sendMouseEvent({ type: "mousedown", button: 2 }, node, win); +}; + +// Shortcut for firing a key event, like "VK_UP", "VK_DOWN", etc. +const key = (id, win = window) => { + EventUtils.synthesizeKey(id, {}, win); +}; + +// Don't pollute global scope. +(() => { + const PrefUtils = require("devtools/client/performance/test/helpers/prefs"); + + // Make sure all the prefs are reverted to their defaults once tests finish. + const stopObservingPrefs = PrefUtils.whenUnknownPrefChanged( + "devtools.performance", + pref => { + ok( + false, + `Unknown pref changed: ${pref}. Please add it to test/helpers/prefs.js ` + + "to make sure it's reverted to its default value when the tests finishes, " + + "and avoid interfering with future tests.\n" + ); + } + ); + + // By default, enable memory flame graphs for tests for now. + // TODO: remove when we have flame charts via bug 1148663. + Services.prefs.setBoolPref(PrefUtils.UI_ENABLE_MEMORY_FLAME_CHART, true); + + // By default, reduce the default buffer size to reduce the overhead when + // transfering the profile data. Hopefully this should help to reduce our + // intermittents for the performance tests. + Services.prefs.setIntPref(PrefUtils.PROFILER_BUFFER_SIZE_PREF, 100000); + + registerCleanupFunction(() => { + info("finish() was called, cleaning up..."); + + PrefUtils.rollbackPrefsToDefault(); + stopObservingPrefs(); + + // Manually stop the profiler module at the end of all tests, to hopefully + // avoid at least some leaks on OSX. Theoretically the module should never + // be active at this point. We shouldn't have to do this, but rather + // find and fix the leak in the module itself. Bug 1257439. + Services.profiler.StopProfiler(); + + // Forces GC, CC and shrinking GC to get rid of disconnected docshells + // and windows. + Cu.forceGC(); + Cu.forceCC(); + Cu.forceShrinkingGC(); + }); +})(); diff --git a/devtools/client/performance/test/helpers/actions.js b/devtools/client/performance/test/helpers/actions.js new file mode 100644 index 0000000000..4b169f6844 --- /dev/null +++ b/devtools/client/performance/test/helpers/actions.js @@ -0,0 +1,168 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const { Constants } = require("devtools/client/performance/modules/constants"); +const { + once, + times, +} = require("devtools/client/performance/test/helpers/event-utils"); +const { + waitUntil, +} = require("devtools/client/performance/test/helpers/wait-utils"); + +/** + * Starts a manual recording in the given performance tool panel and + * waits for it to finish starting. + */ +exports.startRecording = function(panel, options = {}) { + const controller = panel.panelWin.PerformanceController; + + return Promise.all([ + controller.startRecording(), + exports.waitForRecordingStartedEvents(panel, options), + ]); +}; + +/** + * Stops the latest recording in the given performance tool panel and + * waits for it to finish stopping. + */ +exports.stopRecording = function(panel, options = {}) { + const controller = panel.panelWin.PerformanceController; + + return Promise.all([ + controller.stopRecording(), + exports.waitForRecordingStoppedEvents(panel, options), + ]); +}; + +/** + * Waits for all the necessary events to be emitted after a recording starts. + */ +exports.waitForRecordingStartedEvents = function(panel, options = {}) { + options.expectedViewState = + options.expectedViewState || /^(console-)?recording$/; + + const EVENTS = panel.panelWin.EVENTS; + const controller = panel.panelWin.PerformanceController; + const view = panel.panelWin.PerformanceView; + const overview = panel.panelWin.OverviewView; + + return Promise.all([ + options.skipWaitingForBackendReady + ? null + : once(controller, EVENTS.BACKEND_READY_AFTER_RECORDING_START), + options.skipWaitingForRecordingStarted + ? null + : once(controller, EVENTS.RECORDING_STATE_CHANGE, { + expectedArgs: ["recording-started"], + }), + options.skipWaitingForViewState + ? null + : once(view, EVENTS.UI_STATE_CHANGED, { + expectedArgs: [options.expectedViewState], + }), + options.skipWaitingForOverview + ? null + : once(overview, EVENTS.UI_OVERVIEW_RENDERED, { + expectedArgs: [Constants.FRAMERATE_GRAPH_LOW_RES_INTERVAL], + }), + ]); +}; + +/** + * Waits for all the necessary events to be emitted after a recording finishes. + */ +exports.waitForRecordingStoppedEvents = function(panel, options = {}) { + options.expectedViewClass = options.expectedViewClass || "WaterfallView"; + options.expectedViewEvent = + options.expectedViewEvent || "UI_WATERFALL_RENDERED"; + options.expectedViewState = options.expectedViewState || "recorded"; + + const EVENTS = panel.panelWin.EVENTS; + const controller = panel.panelWin.PerformanceController; + const view = panel.panelWin.PerformanceView; + const overview = panel.panelWin.OverviewView; + const subview = panel.panelWin[options.expectedViewClass]; + + return Promise.all([ + options.skipWaitingForBackendReady + ? null + : once(controller, EVENTS.BACKEND_READY_AFTER_RECORDING_STOP), + options.skipWaitingForRecordingStop + ? null + : once(controller, EVENTS.RECORDING_STATE_CHANGE, { + expectedArgs: ["recording-stopping"], + }), + options.skipWaitingForRecordingStop + ? null + : once(controller, EVENTS.RECORDING_STATE_CHANGE, { + expectedArgs: ["recording-stopped"], + }), + options.skipWaitingForViewState + ? null + : once(view, EVENTS.UI_STATE_CHANGED, { + expectedArgs: [options.expectedViewState], + }), + options.skipWaitingForOverview + ? null + : once(overview, EVENTS.UI_OVERVIEW_RENDERED, { + expectedArgs: [Constants.FRAMERATE_GRAPH_HIGH_RES_INTERVAL], + }), + options.skipWaitingForSubview + ? null + : once(subview, EVENTS[options.expectedViewEvent]), + ]); +}; + +/** + * Waits for rendering to happen once on all the performance tool's widgets. + */ +exports.waitForAllWidgetsRendered = panel => { + const { panelWin } = panel; + const { EVENTS } = panelWin; + + return Promise.all([ + once(panelWin.OverviewView, EVENTS.UI_MARKERS_GRAPH_RENDERED), + once(panelWin.OverviewView, EVENTS.UI_MEMORY_GRAPH_RENDERED), + once(panelWin.OverviewView, EVENTS.UI_FRAMERATE_GRAPH_RENDERED), + once(panelWin.OverviewView, EVENTS.UI_OVERVIEW_RENDERED), + once(panelWin.WaterfallView, EVENTS.UI_WATERFALL_RENDERED), + once(panelWin.JsCallTreeView, EVENTS.UI_JS_CALL_TREE_RENDERED), + once(panelWin.JsFlameGraphView, EVENTS.UI_JS_FLAMEGRAPH_RENDERED), + once(panelWin.MemoryCallTreeView, EVENTS.UI_MEMORY_CALL_TREE_RENDERED), + once(panelWin.MemoryFlameGraphView, EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED), + ]); +}; + +/** + * Waits for rendering to happen on the performance tool's overview graph, + * making sure some markers were also rendered. + */ +exports.waitForOverviewRenderedWithMarkers = ( + panel, + minTimes = 3, + minMarkers = 1 +) => { + const { EVENTS, OverviewView, PerformanceController } = panel.panelWin; + + return Promise.all([ + times(OverviewView, EVENTS.UI_OVERVIEW_RENDERED, minTimes, { + expectedArgs: [Constants.FRAMERATE_GRAPH_LOW_RES_INTERVAL], + }), + waitUntil( + () => + PerformanceController.getCurrentRecording().getMarkers().length >= + minMarkers + ), + ]); +}; + +/** + * Reloads the given tab target. + */ +exports.reload = target => { + target.reload(); + return once(target, "navigate"); +}; diff --git a/devtools/client/performance/test/helpers/dom-utils.js b/devtools/client/performance/test/helpers/dom-utils.js new file mode 100644 index 0000000000..acc09499e2 --- /dev/null +++ b/devtools/client/performance/test/helpers/dom-utils.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const Services = require("Services"); +const { + waitForMozAfterPaint, +} = require("devtools/client/performance/test/helpers/wait-utils"); + +/** + * Checks if a DOM node is considered visible. + */ +exports.isVisible = element => { + return !element.classList.contains("hidden") && !element.hidden; +}; + +/** + * Appends the provided element to the provided parent node. If run in e10s + * mode, will also wait for MozAfterPaint to make sure the tab is rendered. + * Should be reviewed if Bug 1240509 lands. + */ +exports.appendAndWaitForPaint = function(parent, element) { + const isE10s = Services.appinfo.browserTabsRemoteAutostart; + if (isE10s) { + const win = parent.ownerDocument.defaultView; + const onMozAfterPaint = waitForMozAfterPaint(win); + parent.appendChild(element); + return onMozAfterPaint; + } + parent.appendChild(element); + return null; +}; diff --git a/devtools/client/performance/test/helpers/event-utils.js b/devtools/client/performance/test/helpers/event-utils.js new file mode 100644 index 0000000000..7f9e765f4f --- /dev/null +++ b/devtools/client/performance/test/helpers/event-utils.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/* globals dump */ + +const Services = require("Services"); + +const KNOWN_EE_APIS = [ + ["on", "off"], + ["addEventListener", "removeEventListener"], + ["addListener", "removeListener"], +]; + +/** + * Listens for any event for a single time on a target, no matter what kind of + * event emitter it is, returning a promise resolved with the passed arguments + * once the event is fired. + */ +exports.once = function(target, eventName, options = {}) { + return exports.times(target, eventName, 1, options); +}; + +/** + * Waits for any event to be fired a specified amount of times on a target, no + * matter what kind of event emitter. + * Possible options: `useCapture`, `spreadArgs`, `expectedArgs` + */ +exports.times = function(target, eventName, receiveCount, options = {}) { + const msg = `Waiting for event: '${eventName}' on ${target} for ${receiveCount} time(s)`; + if ("expectedArgs" in options) { + dump(`${msg} with arguments: ${JSON.stringify(options.expectedArgs)}.\n`); + } else { + dump(`${msg}.\n`); + } + + return new Promise((resolve, reject) => { + if (typeof eventName != "string") { + reject(new Error(`Unexpected event name: ${eventName}.`)); + } + + const API = KNOWN_EE_APIS.find(([a, r]) => a in target && r in target); + if (!API) { + reject(new Error("Target is not a supported event listener.")); + return; + } + + const [add, remove] = API; + + target[add]( + eventName, + function onEvent(...args) { + if ("expectedArgs" in options) { + for (const [index, expectedValue] of options.expectedArgs.entries()) { + const isExpectedValueRegExp = expectedValue instanceof RegExp; + if ( + (isExpectedValueRegExp && !expectedValue.exec(args[index])) || + (!isExpectedValueRegExp && expectedValue != args[index]) + ) { + dump( + `Ignoring event '${eventName}' with unexpected argument at index ` + + `${index}: ${args[index]} - expected ${expectedValue}\n` + ); + return; + } + } + } + if (--receiveCount > 0) { + dump( + `Event: '${eventName}' on ${target} needs to be fired ${receiveCount} ` + + `more time(s).\n` + ); + } else if (!receiveCount) { + dump(`Event: '${eventName}' on ${target} received.\n`); + target[remove](eventName, onEvent, options.useCapture); + resolve(options.spreadArgs ? args : args[0]); + } + }, + options.useCapture + ); + }); +}; + +/** + * Like `times`, but for observer notifications. + * Possible options: `expectedSubject` + */ +exports.observeTimes = function(notificationName, receiveCount, options = {}) { + dump( + `Waiting for notification: '${notificationName}' for ${receiveCount} time(s).\n` + ); + + return new Promise((resolve, reject) => { + if (typeof notificationName != "string") { + reject(new Error(`Unexpected notification name: ${notificationName}.`)); + } + + Services.obs.addObserver(function onObserve(subject, topic, data) { + if ("expectedSubject" in options && options.expectedSubject != subject) { + dump( + `Ignoring notification '${notificationName}' with unexpected subject: ` + + `${subject}\n` + ); + return; + } + if (--receiveCount > 0) { + dump( + `Notification: '${notificationName}' needs to be fired ${receiveCount} ` + + `more time(s).\n` + ); + } else if (!receiveCount) { + dump(`Notification: '${notificationName}' received.\n`); + Services.obs.removeObserver(onObserve, topic); + resolve(data); + } + }, notificationName); + }); +}; diff --git a/devtools/client/performance/test/helpers/input-utils.js b/devtools/client/performance/test/helpers/input-utils.js new file mode 100644 index 0000000000..52919fec5b --- /dev/null +++ b/devtools/client/performance/test/helpers/input-utils.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +exports.HORIZONTAL_AXIS = 1; +exports.VERTICAL_AXIS = 2; + +/** + * Simulates a command event on an element. + */ +exports.command = node => { + const ev = node.ownerDocument.createEvent("XULCommandEvent"); + ev.initCommandEvent( + "command", + true, + true, + node.ownerDocument.defaultView, + 0, + false, + false, + false, + false, + null, + 0 + ); + node.dispatchEvent(ev); +}; + +/** + * Simulates a click event on a devtools canvas graph. + */ +exports.clickCanvasGraph = (graph, { x, y }) => { + x = x || 0; + y = y || 0; + x /= graph._window.devicePixelRatio; + y /= graph._window.devicePixelRatio; + graph._onMouseMove({ testX: x, testY: y }); + graph._onMouseDown({ testX: x, testY: y }); + graph._onMouseUp({ testX: x, testY: y }); +}; + +/** + * Simulates a drag start event on a devtools canvas graph. + */ +exports.dragStartCanvasGraph = (graph, { x, y }) => { + x = x || 0; + y = y || 0; + x /= graph._window.devicePixelRatio; + y /= graph._window.devicePixelRatio; + graph._onMouseMove({ testX: x, testY: y }); + graph._onMouseDown({ testX: x, testY: y }); +}; + +/** + * Simulates a drag stop event on a devtools canvas graph. + */ +exports.dragStopCanvasGraph = (graph, { x, y }) => { + x = x || 0; + y = y || 0; + x /= graph._window.devicePixelRatio; + y /= graph._window.devicePixelRatio; + graph._onMouseMove({ testX: x, testY: y }); + graph._onMouseUp({ testX: x, testY: y }); +}; + +/** + * Simulates a scroll event on a devtools canvas graph. + */ +exports.scrollCanvasGraph = (graph, { axis, wheel, x, y }) => { + x = x || 1; + y = y || 1; + x /= graph._window.devicePixelRatio; + y /= graph._window.devicePixelRatio; + graph._onMouseMove({ + testX: x, + testY: y, + }); + graph._onMouseWheel({ + testX: x, + testY: y, + axis: axis, + detail: wheel, + HORIZONTAL_AXIS: exports.HORIZONTAL_AXIS, + VERTICAL_AXIS: exports.VERTICAL_AXIS, + }); +}; diff --git a/devtools/client/performance/test/helpers/moz.build b/devtools/client/performance/test/helpers/moz.build new file mode 100644 index 0000000000..d1b1b65472 --- /dev/null +++ b/devtools/client/performance/test/helpers/moz.build @@ -0,0 +1,20 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "actions.js", + "dom-utils.js", + "event-utils.js", + "input-utils.js", + "panel-utils.js", + "prefs.js", + "profiler-mm-utils.js", + "recording-utils.js", + "synth-utils.js", + "tab-utils.js", + "urls.js", + "wait-utils.js", +) diff --git a/devtools/client/performance/test/helpers/panel-utils.js b/devtools/client/performance/test/helpers/panel-utils.js new file mode 100644 index 0000000000..d078242349 --- /dev/null +++ b/devtools/client/performance/test/helpers/panel-utils.js @@ -0,0 +1,111 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/* globals dump */ + +const { gDevTools } = require("devtools/client/framework/devtools"); +const { TargetFactory } = require("devtools/client/framework/target"); +const { + addTab, + removeTab, +} = require("devtools/client/performance/test/helpers/tab-utils"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +/** + * Initializes a toolbox panel in a new tab. + */ +exports.initPanelInNewTab = async function({ tool, url, win }, options = {}) { + const tab = await addTab({ url, win }, options); + return exports.initPanelInTab({ tool, tab }); +}; + +/** + * Initializes a toolbox panel in the specified tab. + */ +exports.initPanelInTab = async function({ tool, tab }) { + dump(`Initializing a ${tool} panel.\n`); + + const target = await TargetFactory.forTab(tab); + + // Open a toolbox and wait for the connection to the performance actors + // to be opened. This is necessary because of the WebConsole's + // `profile` and `profileEnd` methods. + const toolbox = await gDevTools.showToolbox(target, tool); + // ensure that the performance front is ready + await target.getFront("performance"); + + const panel = toolbox.getCurrentPanel(); + return { target, toolbox, panel }; +}; + +/** + * Initializes a performance panel in a new tab. + */ +exports.initPerformanceInNewTab = async function({ url, win }, options = {}) { + const tab = await addTab({ url, win }, options); + return exports.initPerformanceInTab({ tab }); +}; + +/** + * Initializes a performance panel in the specified tab. + */ +exports.initPerformanceInTab = async function({ tab }) { + return exports.initPanelInTab({ + tool: "performance", + tab: tab, + }); +}; + +/** + * Initializes a webconsole panel in a new tab. + * Returns a console property that allows calls to `profile` and `profileEnd`. + */ +exports.initConsoleInNewTab = async function({ url, win }, options = {}) { + const tab = await addTab({ url, win }, options); + return exports.initConsoleInTab({ tab }); +}; + +/** + * Initializes a webconsole panel in the specified tab. + * Returns a console property that allows calls to `profile` and `profileEnd`. + */ +exports.initConsoleInTab = async function({ tab }) { + const { target, toolbox, panel } = await exports.initPanelInTab({ + tool: "webconsole", + tab: tab, + }); + + const consoleMethod = async function(method, label, event) { + const performanceFront = await toolbox.target.getFront("performance"); + const recordingEventReceived = once(performanceFront, event); + const expression = label + ? `console.${method}("${label}")` + : `console.${method}()`; + await panel.hud.ui.wrapper.dispatchEvaluateExpression(expression); + await recordingEventReceived; + }; + + const profile = async function(label) { + return consoleMethod("profile", label, "recording-started"); + }; + + const profileEnd = async function(label) { + return consoleMethod("profileEnd", label, "recording-stopped"); + }; + + return { target, toolbox, panel, console: { profile, profileEnd } }; +}; + +/** + * Tears down a toolbox panel and removes an associated tab. + */ +exports.teardownToolboxAndRemoveTab = async function(panel) { + dump("Destroying panel.\n"); + + const tab = panel.target.localTab; + await panel.toolbox.destroy(); + await removeTab(tab); +}; diff --git a/devtools/client/performance/test/helpers/prefs.js b/devtools/client/performance/test/helpers/prefs.js new file mode 100644 index 0000000000..1b9ec63545 --- /dev/null +++ b/devtools/client/performance/test/helpers/prefs.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const Services = require("Services"); +const { Preferences } = require("resource://gre/modules/Preferences.jsm"); + +// Prefs to revert to default once tests finish. Keep these in sync with +// all the preferences defined in devtools/client/preferences/devtools-client.js. +exports.MEMORY_SAMPLE_PROB_PREF = + "devtools.performance.memory.sample-probability"; +exports.MEMORY_MAX_LOG_LEN_PREF = "devtools.performance.memory.max-log-length"; +exports.PROFILER_BUFFER_SIZE_PREF = "devtools.performance.profiler.buffer-size"; +exports.PROFILER_SAMPLE_RATE_PREF = + "devtools.performance.profiler.sample-frequency-hz"; + +exports.UI_EXPERIMENTAL_PREF = "devtools.performance.ui.experimental"; +exports.UI_INVERT_CALL_TREE_PREF = "devtools.performance.ui.invert-call-tree"; +exports.UI_INVERT_FLAME_PREF = "devtools.performance.ui.invert-flame-graph"; +exports.UI_FLATTEN_RECURSION_PREF = + "devtools.performance.ui.flatten-tree-recursion"; +exports.UI_SHOW_PLATFORM_DATA_PREF = + "devtools.performance.ui.show-platform-data"; +exports.UI_SHOW_IDLE_BLOCKS_PREF = "devtools.performance.ui.show-idle-blocks"; +exports.UI_ENABLE_FRAMERATE_PREF = "devtools.performance.ui.enable-framerate"; +exports.UI_ENABLE_MEMORY_PREF = "devtools.performance.ui.enable-memory"; +exports.UI_ENABLE_ALLOCATIONS_PREF = + "devtools.performance.ui.enable-allocations"; +exports.UI_ENABLE_MEMORY_FLAME_CHART = + "devtools.performance.ui.enable-memory-flame"; + +exports.DEFAULT_PREF_VALUES = [ + "devtools.debugger.log", + "devtools.performance.enabled", + "devtools.performance.timeline.hidden-markers", + exports.MEMORY_SAMPLE_PROB_PREF, + exports.MEMORY_MAX_LOG_LEN_PREF, + exports.PROFILER_BUFFER_SIZE_PREF, + exports.PROFILER_SAMPLE_RATE_PREF, + exports.UI_EXPERIMENTAL_PREF, + exports.UI_INVERT_CALL_TREE_PREF, + exports.UI_INVERT_FLAME_PREF, + exports.UI_FLATTEN_RECURSION_PREF, + exports.UI_SHOW_PLATFORM_DATA_PREF, + exports.UI_SHOW_IDLE_BLOCKS_PREF, + exports.UI_ENABLE_FRAMERATE_PREF, + exports.UI_ENABLE_MEMORY_PREF, + exports.UI_ENABLE_ALLOCATIONS_PREF, + exports.UI_ENABLE_MEMORY_FLAME_CHART, + "devtools.performance.ui.show-jit-optimizations", + "devtools.performance.ui.show-triggers-for-gc-types", +].reduce((prefValues, prefName) => { + prefValues[prefName] = Preferences.get(prefName); + return prefValues; +}, {}); + +/** + * Invokes callback when a pref which is not in the `DEFAULT_PREF_VALUES` store + * is changed. Returns a cleanup function. + */ +exports.whenUnknownPrefChanged = function(branch, callback) { + function onObserve(subject, topic, data) { + if (!(data in exports.DEFAULT_PREF_VALUES)) { + callback(data); + } + } + Services.prefs.addObserver(branch, onObserve); + return () => Services.prefs.removeObserver(branch, onObserve); +}; + +/** + * Reverts all known preferences to their default values. + */ +exports.rollbackPrefsToDefault = function() { + for (const prefName of Object.keys(exports.DEFAULT_PREF_VALUES)) { + Preferences.set(prefName, exports.DEFAULT_PREF_VALUES[prefName]); + } +}; diff --git a/devtools/client/performance/test/helpers/profiler-mm-utils.js b/devtools/client/performance/test/helpers/profiler-mm-utils.js new file mode 100644 index 0000000000..c2c2100903 --- /dev/null +++ b/devtools/client/performance/test/helpers/profiler-mm-utils.js @@ -0,0 +1,82 @@ +/* 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 following functions are used in testing to control and inspect + * the nsIProfiler in child process content. These should be called from + * the parent process. + */ + +let gSelectedBrowser = null; + +/** + * Loads the relevant frame scripts into the provided browser's message manager. + */ +exports.pmmInitWithBrowser = gBrowser => { + gSelectedBrowser = gBrowser.selectedBrowser; +}; + +/** + * Checks if the nsProfiler module is active. + */ +exports.pmmIsProfilerActive = () => { + return exports.pmmSendProfilerCommand("IsActive"); +}; + +/** + * Starts the nsProfiler module. + */ +exports.pmmStartProfiler = async function({ entries, interval, features }) { + const isActive = (await exports.pmmSendProfilerCommand("IsActive")).isActive; + if (!isActive) { + return exports.pmmSendProfilerCommand("StartProfiler", [ + entries, + interval, + features, + features.length, + ]); + } + return null; +}; +/** + * Stops the nsProfiler module. + */ +exports.pmmStopProfiler = async function() { + const isActive = (await exports.pmmSendProfilerCommand("IsActive")).isActive; + if (isActive) { + return exports.pmmSendProfilerCommand("StopProfiler"); + } + return null; +}; + +/** + * Calls a method on the nsProfiler module. + */ +exports.pmmSendProfilerCommand = (method, args = []) => { + // This script is loaded via the CommonJS module so the global + // SpecialPowers isn't available, so get it from the browser's window. + return gSelectedBrowser.ownerGlobal.SpecialPowers.spawn( + gSelectedBrowser, + [method, args], + (methodChild, argsChild) => { + return Services.profiler[methodChild](...argsChild); + } + ); +}; + +/** + * Evaluates a console method in content. + */ +exports.pmmConsoleMethod = function(method, ...args) { + // This script is loaded via the CommonJS module so the global + // SpecialPowers isn't available, so get it from the browser's window. + return gSelectedBrowser.ownerGlobal.SpecialPowers.spawn( + gSelectedBrowser, + [method, args], + (methodChild, argsChild) => { + content.console[methodChild].apply(content.console, argsChild); + } + ); +}; diff --git a/devtools/client/performance/test/helpers/recording-utils.js b/devtools/client/performance/test/helpers/recording-utils.js new file mode 100644 index 0000000000..621193b798 --- /dev/null +++ b/devtools/client/performance/test/helpers/recording-utils.js @@ -0,0 +1,54 @@ +/* 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"; + +/** + * These utilities provide a functional interface for accessing the particulars + * about the recording's details. + */ + +/** + * Access the selected view from the panel's recording list. + * + * @param {object} panel - The current panel. + * @return {object} The recording model. + */ +exports.getSelectedRecording = function(panel) { + const view = panel.panelWin.RecordingsView; + return view.selected; +}; + +/** + * Set the selected index of the recording via the panel. + * + * @param {object} panel - The current panel. + * @return {number} index + */ +exports.setSelectedRecording = function(panel, index) { + const view = panel.panelWin.RecordingsView; + view.setSelectedByIndex(index); + return index; +}; + +/** + * Access the selected view from the panel's recording list. + * + * @param {object} panel - The current panel. + * @return {number} index + */ +exports.getSelectedRecordingIndex = function(panel) { + const view = panel.panelWin.RecordingsView; + return view.getSelectedIndex(); +}; + +exports.getDurationLabelText = function(panel, elementIndex) { + const { $$ } = panel.panelWin; + const elements = $$(".recording-list-item-duration", panel.panelWin.document); + return elements[elementIndex].innerHTML; +}; + +exports.getRecordingsCount = function(panel) { + const { $$ } = panel.panelWin; + return $$(".recording-list-item", panel.panelWin.document).length; +}; diff --git a/devtools/client/performance/test/helpers/synth-utils.js b/devtools/client/performance/test/helpers/synth-utils.js new file mode 100644 index 0000000000..6e8438d79d --- /dev/null +++ b/devtools/client/performance/test/helpers/synth-utils.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Generates a generalized profile with some samples. + */ +exports.synthesizeProfile = () => { + const { + CATEGORY_INDEX, + } = require("devtools/client/performance/modules/categories"); + const RecordingUtils = require("devtools/shared/performance/recording-utils"); + + return RecordingUtils.deflateProfile({ + meta: { version: 2 }, + threads: [ + { + samples: [ + { + time: 1, + frames: [ + { category: CATEGORY_INDEX("other"), location: "(root)" }, + { + category: CATEGORY_INDEX("js"), + location: "A (http://foo/bar/baz:12:9)", + }, + { + category: CATEGORY_INDEX("layout"), + location: "B InterruptibleLayout", + }, + { + category: CATEGORY_INDEX("js"), + location: "C (http://foo/bar/baz:56)", + }, + ], + }, + { + time: 1 + 1, + frames: [ + { category: CATEGORY_INDEX("other"), location: "(root)" }, + { + category: CATEGORY_INDEX("js"), + location: "A (http://foo/bar/baz:12:9)", + }, + { + category: CATEGORY_INDEX("layout"), + location: "B InterruptibleLayout", + }, + { + category: CATEGORY_INDEX("gc"), + location: "D INTER_SLICE_GC", + }, + ], + }, + { + time: 1 + 1 + 2, + frames: [ + { category: CATEGORY_INDEX("other"), location: "(root)" }, + { + category: CATEGORY_INDEX("js"), + location: "A (http://foo/bar/baz:12:9)", + }, + { + category: CATEGORY_INDEX("layout"), + location: "B InterruptibleLayout", + }, + { + category: CATEGORY_INDEX("gc"), + location: "D INTER_SLICE_GC", + }, + ], + }, + { + time: 1 + 1 + 2 + 3, + frames: [ + { category: CATEGORY_INDEX("other"), location: "(root)" }, + { + category: CATEGORY_INDEX("js"), + location: "A (http://foo/bar/baz:12:9)", + }, + { + category: CATEGORY_INDEX("gc"), + location: "E", + }, + { + category: CATEGORY_INDEX("network"), + location: "F", + }, + ], + }, + ], + }, + ], + }); +}; + +/** + * Generates a simple implementation for a tree class. + */ +exports.synthesizeCustomTreeClass = () => { + const { + AbstractTreeItem, + } = require("resource://devtools/client/shared/widgets/AbstractTreeItem.jsm"); + const { extend } = require("devtools/shared/extend"); + + function MyCustomTreeItem(dataSrc, properties) { + AbstractTreeItem.call(this, properties); + this.itemDataSrc = dataSrc; + } + + MyCustomTreeItem.prototype = extend(AbstractTreeItem.prototype, { + _displaySelf: function(document, arrowNode) { + const node = document.createXULElement("hbox"); + node.style.marginInlineStart = this.level * 10 + "px"; + node.appendChild(arrowNode); + node.appendChild(document.createTextNode(this.itemDataSrc.label)); + return node; + }, + + _populateSelf: function(children) { + for (const childDataSrc of this.itemDataSrc.children) { + children.push( + new MyCustomTreeItem(childDataSrc, { + parent: this, + level: this.level + 1, + }) + ); + } + }, + }); + + const myDataSrc = { + label: "root", + children: [ + { + label: "foo", + children: [], + }, + { + label: "bar", + children: [ + { + label: "baz", + children: [], + }, + ], + }, + ], + }; + + return { MyCustomTreeItem, myDataSrc }; +}; diff --git a/devtools/client/performance/test/helpers/tab-utils.js b/devtools/client/performance/test/helpers/tab-utils.js new file mode 100644 index 0000000000..e9bd6354de --- /dev/null +++ b/devtools/client/performance/test/helpers/tab-utils.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/* globals dump */ + +const { + BrowserTestUtils, +} = require("resource://testing-common/BrowserTestUtils.jsm"); + +/** + * Gets a random integer in between an interval. Used to uniquely identify + * added tabs by augmenting the URL. + */ +function getRandomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +/** + * Adds a browser tab with the given url in the specified window and waits + * for it to load. + */ +exports.addTab = function({ url, win }, options = {}) { + const id = getRandomInt(0, Number.MAX_SAFE_INTEGER - 1); + url += `#${id}`; + + dump(`Adding tab with url: ${url}.\n`); + + const { gBrowser } = win || window; + return BrowserTestUtils.openNewForegroundTab( + gBrowser, + url, + !options.dontWaitForTabReady + ); +}; + +/** + * Removes a browser tab from the specified window and waits for it to close. + */ +exports.removeTab = function(tab) { + dump(`Removing tab: ${tab.linkedBrowser.currentURI.spec}.\n`); + + BrowserTestUtils.removeTab(tab); +}; diff --git a/devtools/client/performance/test/helpers/urls.js b/devtools/client/performance/test/helpers/urls.js new file mode 100644 index 0000000000..cd5247823f --- /dev/null +++ b/devtools/client/performance/test/helpers/urls.js @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +exports.EXAMPLE_URL = + "http://example.com/browser/devtools/client/performance/test"; +exports.SIMPLE_URL = `${exports.EXAMPLE_URL}/doc_simple-test.html`; +// Used to test a page running on main process. +exports.MAIN_PROCESS_URL = "about:robots"; diff --git a/devtools/client/performance/test/helpers/wait-utils.js b/devtools/client/performance/test/helpers/wait-utils.js new file mode 100644 index 0000000000..48dc31c791 --- /dev/null +++ b/devtools/client/performance/test/helpers/wait-utils.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/* globals dump */ + +const { CC } = require("chrome"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { + once, +} = require("devtools/client/performance/test/helpers/event-utils"); + +/** + * Blocks the main thread for the specified amount of time. + */ +exports.busyWait = function(time) { + dump(`Busy waiting for: ${time} milliseconds.\n`); + const start = Date.now(); + /* eslint-disable no-unused-vars */ + let stack; + while (Date.now() - start < time) { + stack = CC.stack; + } + /* eslint-enable no-unused-vars */ +}; + +/** + * Idly waits for the specified amount of time. + */ +exports.idleWait = function(time) { + dump(`Idly waiting for: ${time} milliseconds.\n`); + return DevToolsUtils.waitForTime(time); +}; + +/** + * Waits until a predicate returns true. + */ +exports.waitUntil = async function(predicate, interval = 100, tries = 100) { + for (let i = 1; i <= tries; i++) { + if (await predicate()) { + dump(`Predicate returned true after ${i} tries.\n`); + return; + } + await exports.idleWait(interval); + } + throw new Error(`Predicate returned false after ${tries} tries, aborting.\n`); +}; + +/** + * Waits for a `MozAfterPaint` event to be fired on the specified window. + */ +exports.waitForMozAfterPaint = function(window) { + return once(window, "MozAfterPaint"); +}; diff --git a/devtools/client/performance/test/js_simpleWorker.js b/devtools/client/performance/test/js_simpleWorker.js new file mode 100644 index 0000000000..1f77e27e4f --- /dev/null +++ b/devtools/client/performance/test/js_simpleWorker.js @@ -0,0 +1,6 @@ +"use strict"; + +self.addEventListener("message", function(e) { + self.postMessage(e.data); + self.close(); +}); diff --git a/devtools/client/performance/test/moz.build b/devtools/client/performance/test/moz.build new file mode 100644 index 0000000000..728f6a155b --- /dev/null +++ b/devtools/client/performance/test/moz.build @@ -0,0 +1,8 @@ +# 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/. + +DIRS += [ + "helpers", +] diff --git a/devtools/client/performance/test/xpcshell/.eslintrc.js b/devtools/client/performance/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..7f6b62a9e5 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + extends: "../../../../.eslintrc.xpcshell.js", +}; diff --git a/devtools/client/performance/test/xpcshell/head.js b/devtools/client/performance/test/xpcshell/head.js new file mode 100644 index 0000000000..7b684c67d7 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/head.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/* exported Cc, Ci, Cu, Cr, Services, console, PLATFORM_DATA_PREF, getFrameNodePath, + synthesizeProfileForTest */ +var { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm"); +var Services = require("Services"); +const RecordingUtils = require("devtools/shared/performance/recording-utils"); +const PLATFORM_DATA_PREF = "devtools.performance.ui.show-platform-data"; + +/** + * Get a path in a FrameNode call tree. + */ +function getFrameNodePath(root, path) { + let calls = root.calls; + let foundNode; + for (const key of path.split(" > ")) { + foundNode = calls.find(node => node.key == key); + if (!foundNode) { + break; + } + calls = foundNode.calls; + } + return foundNode; +} + +/** + * Synthesize a profile for testing. + */ +function synthesizeProfileForTest(samples) { + samples.unshift({ + time: 0, + frames: [{ location: "(root)" }], + }); + + const uniqueStacks = new RecordingUtils.UniqueStacks(); + return RecordingUtils.deflateThread( + { + samples: samples, + markers: [], + }, + uniqueStacks + ); +} diff --git a/devtools/client/performance/test/xpcshell/test_frame-utils-01.js b/devtools/client/performance/test/xpcshell/test_frame-utils-01.js new file mode 100644 index 0000000000..3df04964e0 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_frame-utils-01.js @@ -0,0 +1,293 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that frame-utils isContent and parseLocation work as intended + * when parsing over frames from the profiler. + */ + +const CONTENT_LOCATIONS = [ + "hello/<.world (https://foo/bar.js:123:987)", + "hello/<.world (http://foo/bar.js:123:987)", + "hello/<.world (http://foo/bar.js:123)", + "hello/<.world (http://foo/bar.js#baz:123:987)", + "hello/<.world (http://foo/bar.js?myquery=params&search=1:123:987)", + "hello/<.world (http://foo/#bar:123:987)", + "hello/<.world (http://foo/:123:987)", + + // Test scripts with port numbers (bug 1164131) + "hello/<.world (http://localhost:8888/file.js:100:1)", + "hello/<.world (http://localhost:8888/file.js:100)", + + // Eval + "hello/<.world (http://localhost:8888/file.js line 65 > eval:1)", + + // Occurs when executing an inline script on a root html page with port + // (I've never seen it with a column number but check anyway) bug 1164131 + "hello/<.world (http://localhost:8888/:1)", + "hello/<.world (http://localhost:8888/:100:50)", + + // bug 1197636 + 'Native["arraycopy(blah)"] (http://localhost:8888/profiler.html:4)', + 'Native["arraycopy(blah)"] (http://localhost:8888/profiler.html:4:5)', +].map(argify); + +const CHROME_LOCATIONS = [ + { location: "Startup::XRE_InitChildProcess", line: 456, column: 123 }, + { location: "chrome://browser/content/content.js", line: 456, column: 123 }, + "setTimeout_timer (resource://gre/foo.js:123:434)", + "hello/<.world (jar:file://Users/mcurie/Dev/jetpacks.js)", + "hello/<.world (resource://foo.js -> http://bar/baz.js:123:987)", + "EnterJIT", +].map(argify); + +add_task(function() { + const { + computeIsContentAndCategory, + parseLocation, + } = require("devtools/client/performance/modules/logic/frame-utils"); + const isContent = frame => { + computeIsContentAndCategory(frame); + return frame.isContent; + }; + + for (const frame of CONTENT_LOCATIONS) { + ok( + isContent.apply(null, frameify(frame)), + `${frame[0]} should be considered a content frame.` + ); + } + + for (const frame of CHROME_LOCATIONS) { + ok( + !isContent.apply(null, frameify(frame)), + `${frame[0]} should not be considered a content frame.` + ); + } + + // functionName, fileName, host, url, line, column + const FIELDS = [ + "functionName", + "fileName", + "host", + "url", + "line", + "column", + "host", + "port", + ]; + + /* eslint-disable max-len */ + const PARSED_CONTENT = [ + [ + "hello/<.world", + "bar.js", + "foo", + "https://foo/bar.js", + 123, + 987, + "foo", + null, + ], + [ + "hello/<.world", + "bar.js", + "foo", + "http://foo/bar.js", + 123, + 987, + "foo", + null, + ], + [ + "hello/<.world", + "bar.js", + "foo", + "http://foo/bar.js", + 123, + null, + "foo", + null, + ], + [ + "hello/<.world", + "bar.js", + "foo", + "http://foo/bar.js#baz", + 123, + 987, + "foo", + null, + ], + [ + "hello/<.world", + "bar.js", + "foo", + "http://foo/bar.js?myquery=params&search=1", + 123, + 987, + "foo", + null, + ], + ["hello/<.world", "/", "foo", "http://foo/#bar", 123, 987, "foo", null], + ["hello/<.world", "/", "foo", "http://foo/", 123, 987, "foo", null], + [ + "hello/<.world", + "file.js", + "localhost:8888", + "http://localhost:8888/file.js", + 100, + 1, + "localhost:8888", + 8888, + ], + [ + "hello/<.world", + "file.js", + "localhost:8888", + "http://localhost:8888/file.js", + 100, + null, + "localhost:8888", + 8888, + ], + [ + "hello/<.world", + "file.js (eval:1)", + "localhost:8888", + "http://localhost:8888/file.js", + 65, + null, + "localhost:8888", + 8888, + ], + [ + "hello/<.world", + "/", + "localhost:8888", + "http://localhost:8888/", + 1, + null, + "localhost:8888", + 8888, + ], + [ + "hello/<.world", + "/", + "localhost:8888", + "http://localhost:8888/", + 100, + 50, + "localhost:8888", + 8888, + ], + [ + 'Native["arraycopy(blah)"]', + "profiler.html", + "localhost:8888", + "http://localhost:8888/profiler.html", + 4, + null, + "localhost:8888", + 8888, + ], + [ + 'Native["arraycopy(blah)"]', + "profiler.html", + "localhost:8888", + "http://localhost:8888/profiler.html", + 4, + 5, + "localhost:8888", + 8888, + ], + ]; + /* eslint-enable max-len */ + + for (let i = 0; i < PARSED_CONTENT.length; i++) { + const parsed = parseLocation.apply(null, CONTENT_LOCATIONS[i]); + for (let j = 0; j < FIELDS.length; j++) { + equal( + parsed[FIELDS[j]], + PARSED_CONTENT[i][j], + `${CONTENT_LOCATIONS[i]} was parsed to correct ${FIELDS[j]}` + ); + } + } + + const PARSED_CHROME = [ + ["Startup::XRE_InitChildProcess", null, null, null, 456, 123, null, null], + [ + "chrome://browser/content/content.js", + null, + null, + null, + 456, + 123, + null, + null, + ], + [ + "setTimeout_timer", + "foo.js", + null, + "resource://gre/foo.js", + 123, + 434, + null, + null, + ], + [ + "hello/<.world (jar:file://Users/mcurie/Dev/jetpacks.js)", + null, + null, + null, + null, + null, + null, + null, + ], + [ + "hello/<.world", + "baz.js", + "bar", + "http://bar/baz.js", + 123, + 987, + "bar", + null, + ], + ["EnterJIT", null, null, null, null, null, null, null], + ]; + + for (let i = 0; i < PARSED_CHROME.length; i++) { + const parsed = parseLocation.apply(null, CHROME_LOCATIONS[i]); + for (let j = 0; j < FIELDS.length; j++) { + equal( + parsed[FIELDS[j]], + PARSED_CHROME[i][j], + `${CHROME_LOCATIONS[i]} was parsed to correct ${FIELDS[j]}` + ); + } + } +}); + +/** + * Takes either a string or an object and turns it into an array that + * parseLocation.apply expects. + */ +function argify(val) { + if (typeof val === "string") { + return [val]; + } + return [val.location, val.line, val.column]; +} + +/** + * Takes the result of argify and turns it into an array that can be passed to + * isContent.apply. + */ +function frameify(val) { + return [{ location: val[0] }]; +} diff --git a/devtools/client/performance/test/xpcshell/test_frame-utils-02.js b/devtools/client/performance/test/xpcshell/test_frame-utils-02.js new file mode 100644 index 0000000000..9d607c4046 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_frame-utils-02.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests the function testing whether or not a frame is content or chrome + * works properly. + */ + +add_task(function() { + const FrameUtils = require("devtools/client/performance/modules/logic/frame-utils"); + + const isContent = frame => { + FrameUtils.computeIsContentAndCategory(frame); + return frame.isContent; + }; + + ok( + isContent({ location: "http://foo" }), + "Verifying content/chrome frames is working properly." + ); + ok( + isContent({ location: "https://foo" }), + "Verifying content/chrome frames is working properly." + ); + ok( + isContent({ location: "file://foo" }), + "Verifying content/chrome frames is working properly." + ); + + ok( + !isContent({ location: "chrome://foo" }), + "Verifying content/chrome frames is working properly." + ); + ok( + !isContent({ location: "resource://foo" }), + "Verifying content/chrome frames is working properly." + ); + + ok( + !isContent({ location: "chrome://foo -> http://bar" }), + "Verifying content/chrome frames is working properly." + ); + ok( + !isContent({ location: "chrome://foo -> https://bar" }), + "Verifying content/chrome frames is working properly." + ); + ok( + !isContent({ location: "chrome://foo -> file://bar" }), + "Verifying content/chrome frames is working properly." + ); + + ok( + !isContent({ location: "resource://foo -> http://bar" }), + "Verifying content/chrome frames is working properly." + ); + ok( + !isContent({ location: "resource://foo -> https://bar" }), + "Verifying content/chrome frames is working properly." + ); + ok( + !isContent({ location: "resource://foo -> file://bar" }), + "Verifying content/chrome frames is working properly." + ); + + ok( + !isContent({ category: 1, location: "chrome://foo" }), + "Verifying content/chrome frames is working properly." + ); + ok( + !isContent({ category: 1, location: "resource://foo" }), + "Verifying content/chrome frames is working properly." + ); + + ok( + isContent({ category: 1, location: "file://foo -> http://bar" }), + "Verifying content/chrome frames is working properly." + ); + ok( + isContent({ category: 1, location: "file://foo -> https://bar" }), + "Verifying content/chrome frames is working properly." + ); + ok( + isContent({ category: 1, location: "file://foo -> file://bar" }), + "Verifying content/chrome frames is working properly." + ); +}); diff --git a/devtools/client/performance/test/xpcshell/test_jit-graph-data.js b/devtools/client/performance/test/xpcshell/test_jit-graph-data.js new file mode 100644 index 0000000000..3f893cd8c3 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_jit-graph-data.js @@ -0,0 +1,238 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Unit test for `createTierGraphDataFromFrameNode` function. + */ + +const SAMPLE_COUNT = 1000; +const RESOLUTION = 50; +const TIME_PER_SAMPLE = 5; + +// Offset needed since ThreadNode requires the first sample to be strictly +// greater than its start time. This lets us still have pretty numbers +// in this test to keep it (more) simple, which it sorely needs. +const TIME_OFFSET = 5; + +add_task(function test() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + const { + createTierGraphDataFromFrameNode, + } = require("devtools/client/performance/modules/logic/jit"); + + // Select the second half of the set of samples + const startTime = (SAMPLE_COUNT / 2) * TIME_PER_SAMPLE - TIME_OFFSET; + const endTime = SAMPLE_COUNT * TIME_PER_SAMPLE - TIME_OFFSET; + const invertTree = true; + + const root = new ThreadNode(gThread, { invertTree, startTime, endTime }); + + equal(root.samples, SAMPLE_COUNT / 2, "root has correct amount of samples"); + equal( + root.sampleTimes.length, + SAMPLE_COUNT / 2, + "root has correct amount of sample times" + ); + // Add time offset since the first sample begins TIME_OFFSET after startTime + equal( + root.sampleTimes[0], + startTime + TIME_OFFSET, + "root recorded first sample time in scope" + ); + equal( + root.sampleTimes[root.sampleTimes.length - 1], + endTime, + "root recorded last sample time in scope" + ); + + const frame = getFrameNodePath(root, "X"); + let data = createTierGraphDataFromFrameNode( + frame, + root.sampleTimes, + (endTime - startTime) / RESOLUTION + ); + + const TIME_PER_WINDOW = (SAMPLE_COUNT / 2 / RESOLUTION) * TIME_PER_SAMPLE; + + // Filter out the dupes created with the same delta so the graph + // can render correctly. + const filteredData = []; + for (let i = 0; i < data.length; i++) { + if (!i || data[i].delta !== data[i - 1].delta) { + filteredData.push(data[i]); + } + } + data = filteredData; + + for (let i = 0; i < 11; i++) { + equal( + data[i].delta, + startTime + TIME_OFFSET + TIME_PER_WINDOW * i, + "first window has correct x" + ); + equal(data[i].values[0], 0.2, "first window has 2 frames in interpreter"); + equal(data[i].values[1], 0.2, "first window has 2 frames in baseline"); + equal(data[i].values[2], 0.2, "first window has 2 frames in ion"); + } + // Start on 11, since i===10 is where the values change, and the new value (0,0,0) + // is removed in `filteredData` + for (let i = 11; i < 20; i++) { + equal( + data[i].delta, + startTime + TIME_OFFSET + TIME_PER_WINDOW * i, + "second window has correct x" + ); + equal(data[i].values[0], 0, "second window observed no optimizations"); + equal(data[i].values[1], 0, "second window observed no optimizations"); + equal(data[i].values[2], 0, "second window observed no optimizations"); + } + // Start on 21, since i===20 is where the values change, and the new value (0.3,0,0) + // is removed in `filteredData` + for (let i = 21; i < 30; i++) { + equal( + data[i].delta, + startTime + TIME_OFFSET + TIME_PER_WINDOW * i, + "third window has correct x" + ); + equal(data[i].values[0], 0.3, "third window has 3 frames in interpreter"); + equal(data[i].values[1], 0, "third window has 0 frames in baseline"); + equal(data[i].values[2], 0, "third window has 0 frames in ion"); + } +}); + +var gUniqueStacks = new RecordingUtils.UniqueStacks(); + +function uniqStr(s) { + return gUniqueStacks.getOrAddStringIndex(s); +} + +const TIER_PATTERNS = [ + // 0-99 + ["X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0"], + // 100-199 + ["X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0"], + // 200-299 + ["X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0"], + // 300-399 + ["X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0"], + // 400-499 + ["X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0"], + + // 500-599 + // Test current frames in all opts + ["A", "A", "A", "A", "X_1", "X_2", "X_1", "X_2", "X_0", "X_0"], + + // 600-699 + // Nothing for current frame + ["A", "B", "A", "B", "A", "B", "A", "B", "A", "B"], + + // 700-799 + // A few frames where the frame is not the leaf node + ["X_2 -> Y", "X_2 -> Y", "X_2 -> Y", "X_0", "X_0", "X_0", "A", "A", "A", "A"], + + // 800-899 + ["X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0"], + // 900-999 + ["X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0", "X_0"], +]; + +function createSample(i, frames) { + const sample = {}; + sample.time = i * TIME_PER_SAMPLE; + sample.frames = [{ location: "(root)" }]; + if (i === 0) { + return sample; + } + if (frames) { + frames + .split(" -> ") + .forEach(frame => sample.frames.push({ location: frame })); + } + return sample; +} + +var SAMPLES = (function() { + const samples = []; + + for (let i = 0; i < SAMPLE_COUNT; ) { + const pattern = TIER_PATTERNS[Math.floor(i / 100)]; + for (let j = 0; j < pattern.length; j++) { + samples.push(createSample(i + j, pattern[j])); + } + i += 10; + } + + return samples; +})(); + +var gThread = RecordingUtils.deflateThread( + { samples: SAMPLES, markers: [] }, + gUniqueStacks +); + +var gRawSite1 = { + line: 12, + column: 2, + types: [ + { + mirType: uniqStr("Object"), + site: uniqStr("B (http://foo/bar:10)"), + typeset: [ + { + keyedBy: uniqStr("constructor"), + name: uniqStr("Foo"), + location: uniqStr("B (http://foo/bar:10)"), + }, + { + keyedBy: uniqStr("primitive"), + location: uniqStr("self-hosted"), + }, + ], + }, + ], + attempts: { + schema: { + outcome: 0, + strategy: 1, + }, + data: [ + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure2"), uniqStr("SomeGetter2")], + [uniqStr("Inlined"), uniqStr("SomeGetter3")], + ], + }, +}; + +function serialize(x) { + return JSON.parse(JSON.stringify(x)); +} + +gThread.frameTable.data.forEach(frame => { + const LOCATION_SLOT = gThread.frameTable.schema.location; + const OPTIMIZATIONS_SLOT = gThread.frameTable.schema.optimizations; + const IMPLEMENTATION_SLOT = gThread.frameTable.schema.implementation; + + const l = gThread.stringTable[frame[LOCATION_SLOT]]; + switch (l) { + // Rename some of the location sites so we can register different + // frames with different opt sites + case "X_0": + frame[LOCATION_SLOT] = uniqStr("X"); + frame[OPTIMIZATIONS_SLOT] = serialize(gRawSite1); + frame[IMPLEMENTATION_SLOT] = null; + break; + case "X_1": + frame[LOCATION_SLOT] = uniqStr("X"); + frame[OPTIMIZATIONS_SLOT] = serialize(gRawSite1); + frame[IMPLEMENTATION_SLOT] = uniqStr("baseline"); + break; + case "X_2": + frame[LOCATION_SLOT] = uniqStr("X"); + frame[OPTIMIZATIONS_SLOT] = serialize(gRawSite1); + frame[IMPLEMENTATION_SLOT] = uniqStr("ion"); + break; + } +}); diff --git a/devtools/client/performance/test/xpcshell/test_jit-model-01.js b/devtools/client/performance/test/xpcshell/test_jit-model-01.js new file mode 100644 index 0000000000..3dd4dad559 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_jit-model-01.js @@ -0,0 +1,154 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that JITOptimizations track optimization sites and create + * an OptimizationSiteProfile when adding optimization sites, like from the + * FrameNode, and the returning of that data is as expected. + */ + +add_task(function test() { + const { + JITOptimizations, + } = require("devtools/client/performance/modules/logic/jit"); + + const rawSites = []; + rawSites.push(gRawSite2); + rawSites.push(gRawSite2); + rawSites.push(gRawSite1); + rawSites.push(gRawSite1); + rawSites.push(gRawSite2); + rawSites.push(gRawSite3); + + const jit = new JITOptimizations(rawSites, gStringTable.stringTable); + const sites = jit.optimizationSites; + + const [first, second, third] = sites; + + equal(first.id, 0, "site id is array index"); + equal( + first.samples, + 3, + "first OptimizationSiteProfile has correct sample count" + ); + equal( + first.data.line, + 34, + "includes OptimizationSite as reference under `data`" + ); + equal(second.id, 1, "site id is array index"); + equal( + second.samples, + 2, + "second OptimizationSiteProfile has correct sample count" + ); + equal( + second.data.line, + 12, + "includes OptimizationSite as reference under `data`" + ); + equal(third.id, 2, "site id is array index"); + equal( + third.samples, + 1, + "third OptimizationSiteProfile has correct sample count" + ); + equal( + third.data.line, + 78, + "includes OptimizationSite as reference under `data`" + ); +}); + +var gStringTable = new RecordingUtils.UniqueStrings(); + +function uniqStr(s) { + return gStringTable.getOrAddStringIndex(s); +} + +var gRawSite1 = { + line: 12, + column: 2, + types: [ + { + mirType: uniqStr("Object"), + site: uniqStr("A (http://foo/bar/bar:12)"), + typeset: [ + { + keyedBy: uniqStr("constructor"), + name: uniqStr("Foo"), + location: uniqStr("A (http://foo/bar/baz:12)"), + }, + { + keyedBy: uniqStr("primitive"), + location: uniqStr("self-hosted"), + }, + ], + }, + ], + attempts: { + schema: { + outcome: 0, + strategy: 1, + }, + data: [ + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure2"), uniqStr("SomeGetter2")], + [uniqStr("Inlined"), uniqStr("SomeGetter3")], + ], + }, +}; + +var gRawSite2 = { + line: 34, + types: [ + { + mirType: uniqStr("Int32"), + site: uniqStr("Receiver"), + }, + ], + attempts: { + schema: { + outcome: 0, + strategy: 1, + }, + data: [ + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure2"), uniqStr("SomeGetter2")], + [uniqStr("Failure3"), uniqStr("SomeGetter3")], + ], + }, +}; + +var gRawSite3 = { + line: 78, + types: [ + { + mirType: uniqStr("Object"), + site: uniqStr("A (http://foo/bar/bar:12)"), + typeset: [ + { + keyedBy: uniqStr("constructor"), + name: uniqStr("Foo"), + location: uniqStr("A (http://foo/bar/baz:12)"), + }, + { + keyedBy: uniqStr("primitive"), + location: uniqStr("self-hosted"), + }, + ], + }, + ], + attempts: { + schema: { + outcome: 0, + strategy: 1, + }, + data: [ + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure2"), uniqStr("SomeGetter2")], + [uniqStr("GenericSuccess"), uniqStr("SomeGetter3")], + ], + }, +}; diff --git a/devtools/client/performance/test/xpcshell/test_jit-model-02.js b/devtools/client/performance/test/xpcshell/test_jit-model-02.js new file mode 100644 index 0000000000..3da3325c23 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_jit-model-02.js @@ -0,0 +1,196 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that JITOptimizations create OptimizationSites, and the underlying + * hasSuccessfulOutcome/isSuccessfulOutcome work as intended. + */ + +add_task(function test() { + const { + JITOptimizations, + hasSuccessfulOutcome, + isSuccessfulOutcome, + SUCCESSFUL_OUTCOMES, + } = require("devtools/client/performance/modules/logic/jit"); + + const rawSites = []; + rawSites.push(gRawSite2); + rawSites.push(gRawSite2); + rawSites.push(gRawSite1); + rawSites.push(gRawSite1); + rawSites.push(gRawSite2); + rawSites.push(gRawSite3); + + const jit = new JITOptimizations(rawSites, gStringTable.stringTable); + const sites = jit.optimizationSites; + + const [first, second, third] = sites; + + /* hasSuccessfulOutcome */ + equal( + hasSuccessfulOutcome(first), + false, + "hasSuccessfulOutcome() returns expected (1)" + ); + equal( + hasSuccessfulOutcome(second), + true, + "hasSuccessfulOutcome() returns expected (2)" + ); + equal( + hasSuccessfulOutcome(third), + true, + "hasSuccessfulOutcome() returns expected (3)" + ); + + /* .data.attempts */ + equal( + first.data.attempts.length, + 2, + "optSite.data.attempts has the correct amount of attempts (1)" + ); + equal( + second.data.attempts.length, + 5, + "optSite.data.attempts has the correct amount of attempts (2)" + ); + equal( + third.data.attempts.length, + 3, + "optSite.data.attempts has the correct amount of attempts (3)" + ); + + /* .data.types */ + equal( + first.data.types.length, + 1, + "optSite.data.types has the correct amount of IonTypes (1)" + ); + equal( + second.data.types.length, + 2, + "optSite.data.types has the correct amount of IonTypes (2)" + ); + equal( + third.data.types.length, + 1, + "optSite.data.types has the correct amount of IonTypes (3)" + ); + + /* isSuccessfulOutcome */ + ok( + SUCCESSFUL_OUTCOMES.length, + "Have some successful outcomes in SUCCESSFUL_OUTCOMES" + ); + SUCCESSFUL_OUTCOMES.forEach(outcome => + ok( + isSuccessfulOutcome(outcome), + `${outcome} considered a successful outcome via isSuccessfulOutcome()` + ) + ); +}); + +var gStringTable = new RecordingUtils.UniqueStrings(); + +function uniqStr(s) { + return gStringTable.getOrAddStringIndex(s); +} + +var gRawSite1 = { + line: 12, + column: 2, + types: [ + { + mirType: uniqStr("Object"), + site: uniqStr("A (http://foo/bar/bar:12)"), + typeset: [ + { + keyedBy: uniqStr("constructor"), + name: uniqStr("Foo"), + location: uniqStr("A (http://foo/bar/baz:12)"), + }, + { + keyedBy: uniqStr("constructor"), + location: uniqStr("A (http://foo/bar/baz:12)"), + }, + ], + }, + { + mirType: uniqStr("Int32"), + site: uniqStr("A (http://foo/bar/bar:12)"), + typeset: [ + { + keyedBy: uniqStr("primitive"), + location: uniqStr("self-hosted"), + }, + ], + }, + ], + attempts: { + schema: { + outcome: 0, + strategy: 1, + }, + data: [ + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure2"), uniqStr("SomeGetter2")], + [uniqStr("Inlined"), uniqStr("SomeGetter3")], + ], + }, +}; + +var gRawSite2 = { + line: 34, + types: [ + { + mirType: uniqStr("Int32"), + site: uniqStr("Receiver"), + }, + ], + attempts: { + schema: { + outcome: 0, + strategy: 1, + }, + data: [ + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure2"), uniqStr("SomeGetter2")], + ], + }, +}; + +var gRawSite3 = { + line: 78, + types: [ + { + mirType: uniqStr("Object"), + site: uniqStr("A (http://foo/bar/bar:12)"), + typeset: [ + { + keyedBy: uniqStr("constructor"), + name: uniqStr("Foo"), + location: uniqStr("A (http://foo/bar/baz:12)"), + }, + { + keyedBy: uniqStr("primitive"), + location: uniqStr("self-hosted"), + }, + ], + }, + ], + attempts: { + schema: { + outcome: 0, + strategy: 1, + }, + data: [ + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure2"), uniqStr("SomeGetter2")], + [uniqStr("GenericSuccess"), uniqStr("SomeGetter3")], + ], + }, +}; diff --git a/devtools/client/performance/test/xpcshell/test_marker-blueprint.js b/devtools/client/performance/test/xpcshell/test_marker-blueprint.js new file mode 100644 index 0000000000..986b8e399f --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_marker-blueprint.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/** + * Tests if the timeline blueprint has a correct structure. + */ + +add_task(function() { + const { + TIMELINE_BLUEPRINT, + } = require("devtools/client/performance/modules/markers"); + + ok(TIMELINE_BLUEPRINT, "A timeline blueprint should be available."); + + ok( + Object.keys(TIMELINE_BLUEPRINT).length, + "The timeline blueprint has at least one entry." + ); + + for (const value of Object.values(TIMELINE_BLUEPRINT)) { + ok( + "group" in value, + "Each entry in the timeline blueprint contains a `group` key." + ); + ok( + "colorName" in value, + "Each entry in the timeline blueprint contains a `colorName` key." + ); + ok( + "label" in value, + "Each entry in the timeline blueprint contains a `label` key." + ); + } +}); diff --git a/devtools/client/performance/test/xpcshell/test_marker-utils.js b/devtools/client/performance/test/xpcshell/test_marker-utils.js new file mode 100644 index 0000000000..1feef709ac --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_marker-utils.js @@ -0,0 +1,204 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests the marker utils methods. + */ + +add_task(function() { + const { + TIMELINE_BLUEPRINT, + } = require("devtools/client/performance/modules/markers"); + const { PREFS } = require("devtools/client/performance/modules/global"); + const { + MarkerBlueprintUtils, + } = require("devtools/client/performance/modules/marker-blueprint-utils"); + + PREFS.registerObserver(); + + Services.prefs.setBoolPref(PLATFORM_DATA_PREF, false); + + equal( + MarkerBlueprintUtils.getMarkerLabel({ name: "DOMEvent" }), + "DOM Event", + "getMarkerLabel() returns a simple label" + ); + equal( + MarkerBlueprintUtils.getMarkerLabel({ + name: "Javascript", + causeName: "setTimeout handler", + }), + "setTimeout", + "getMarkerLabel() returns a label defined via function" + ); + equal( + MarkerBlueprintUtils.getMarkerLabel({ + name: "GarbageCollection", + causeName: "ALLOC_TRIGGER", + }), + "Incremental GC", + "getMarkerLabel() returns a label for a function that is generalizable" + ); + + ok( + MarkerBlueprintUtils.getMarkerFields({ name: "Paint" }).length === 0, + "getMarkerFields() returns an empty array when no fields defined" + ); + + let fields = MarkerBlueprintUtils.getMarkerFields({ + name: "ConsoleTime", + causeName: "snowstorm", + }); + equal( + fields[0].label, + "Timer Name:", + "getMarkerFields() returns an array with proper label" + ); + equal( + fields[0].value, + "snowstorm", + "getMarkerFields() returns an array with proper value" + ); + + fields = MarkerBlueprintUtils.getMarkerFields({ + name: "DOMEvent", + type: "mouseclick", + }); + equal( + fields.length, + 1, + "getMarkerFields() ignores fields that are not found on marker" + ); + equal( + fields[0].label, + "Event Type:", + "getMarkerFields() returns an array with proper label" + ); + equal( + fields[0].value, + "mouseclick", + "getMarkerFields() returns an array with proper value" + ); + + fields = MarkerBlueprintUtils.getMarkerFields({ + name: "DOMEvent", + eventPhase: Event.AT_TARGET, + type: "mouseclick", + }); + equal( + fields.length, + 2, + "getMarkerFields() returns multiple fields when using a fields function" + ); + equal( + fields[0].label, + "Event Type:", + "getMarkerFields() correctly returns fields via function (1)" + ); + equal( + fields[0].value, + "mouseclick", + "getMarkerFields() correctly returns fields via function (2)" + ); + equal( + fields[1].label, + "Phase:", + "getMarkerFields() correctly returns fields via function (3)" + ); + equal( + fields[1].value, + "Target", + "getMarkerFields() correctly returns fields via function (4)" + ); + + fields = MarkerBlueprintUtils.getMarkerFields({ + name: "GarbageCollection", + causeName: "ALLOC_TRIGGER", + }); + equal(fields[0].value, "Too Many Allocations", "Uses L10N for GC reasons"); + + fields = MarkerBlueprintUtils.getMarkerFields({ + name: "GarbageCollection", + causeName: "NOT_A_GC_REASON", + }); + equal( + fields[0].value, + "NOT_A_GC_REASON", + "Defaults to enum for GC reasons when not L10N'd" + ); + + equal( + MarkerBlueprintUtils.getMarkerFields({ + name: "Javascript", + causeName: "Some Platform Field", + })[0].value, + "(Gecko)", + "Correctly obfuscates JS markers when platform data is off." + ); + Services.prefs.setBoolPref(PLATFORM_DATA_PREF, true); + equal( + MarkerBlueprintUtils.getMarkerFields({ + name: "Javascript", + causeName: "Some Platform Field", + })[0].value, + "Some Platform Field", + "Correctly deobfuscates JS markers when platform data is on." + ); + + equal( + MarkerBlueprintUtils.getMarkerGenericName("Javascript"), + "Function Call", + "getMarkerGenericName() returns correct string when defined via function" + ); + equal( + MarkerBlueprintUtils.getMarkerGenericName("GarbageCollection"), + "Garbage Collection", + "getMarkerGenericName() returns correct string when defined via function" + ); + equal( + MarkerBlueprintUtils.getMarkerGenericName("Reflow"), + "Layout", + "getMarkerGenericName() returns correct string when defined via string" + ); + + TIMELINE_BLUEPRINT.fakemarker = { group: 0 }; + try { + MarkerBlueprintUtils.getMarkerGenericName("fakemarker"); + ok( + false, + "getMarkerGenericName() should throw when no label on blueprint." + ); + } catch (e) { + ok(true, "getMarkerGenericName() should throw when no label on blueprint."); + } + + TIMELINE_BLUEPRINT.fakemarker = { group: 0, label: () => void 0 }; + try { + MarkerBlueprintUtils.getMarkerGenericName("fakemarker"); + ok( + false, + "getMarkerGenericName() should throw when label function returnd undefined." + ); + } catch (e) { + ok( + true, + "getMarkerGenericName() should throw when label function returnd undefined." + ); + } + + delete TIMELINE_BLUEPRINT.fakemarker; + + equal( + MarkerBlueprintUtils.getBlueprintFor({ name: "Reflow" }).label, + "Layout", + "getBlueprintFor() should return marker def for passed in marker." + ); + equal( + MarkerBlueprintUtils.getBlueprintFor({ name: "Not sure!" }).label(), + "Unknown", + "getBlueprintFor() should return a default marker def if the marker is undefined." + ); + + PREFS.unregisterObserver(); +}); diff --git a/devtools/client/performance/test/xpcshell/test_perf-utils-allocations-to-samples.js b/devtools/client/performance/test/xpcshell/test_perf-utils-allocations-to-samples.js new file mode 100644 index 0000000000..e1c2c145e8 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_perf-utils-allocations-to-samples.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if allocations data received from the performance actor is properly + * converted to something that follows the same structure as the samples data + * received from the profiler. + */ + +add_task(function() { + const { + getProfileThreadFromAllocations, + } = require("devtools/shared/performance/recording-utils"); + const output = getProfileThreadFromAllocations(TEST_DATA); + equal( + JSON.stringify(output), + JSON.stringify(EXPECTED_OUTPUT), + "The output is correct." + ); +}); + +var TEST_DATA = { + sites: [0, 0, 1, 2, 3], + timestamps: [50, 100, 150, 200, 250], + sizes: [0, 0, 100, 200, 300], + frames: [ + null, + { + source: "A", + line: 1, + column: 2, + functionDisplayName: "x", + parent: 0, + }, + { + source: "B", + line: 3, + column: 4, + functionDisplayName: "y", + parent: 1, + }, + { + source: "C", + line: 5, + column: 6, + functionDisplayName: null, + parent: 2, + }, + ], +}; + +var EXPECTED_OUTPUT = { + name: "allocations", + samples: { + schema: { + stack: 0, + time: 1, + size: 2, + }, + data: [ + [1, 150, 100], + [2, 200, 200], + [3, 250, 300], + ], + }, + stackTable: { + schema: { + prefix: 0, + frame: 1, + }, + data: [ + null, + [null, 1], // x (A:1:2) + [1, 2], // x (A:1:2) > y (B:3:4) + [2, 3], // x (A:1:2) > y (B:3:4) > C:5:6 + ], + }, + frameTable: { + schema: { + location: 0, + implementation: 1, + optimizations: 2, + line: 3, + category: 4, + }, + data: [null, [0], [1], [2]], + }, + stringTable: ["x (A:1:2)", "y (B:3:4)", "C:5:6"], +}; diff --git a/devtools/client/performance/test/xpcshell/test_profiler-categories.js b/devtools/client/performance/test/xpcshell/test_profiler-categories.js new file mode 100644 index 0000000000..ae21efbae9 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_profiler-categories.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the profiler categories are mapped correctly. + */ + +add_task(function() { + const { + CATEGORIES, + } = require("devtools/client/performance/modules/categories"); + const { L10N } = require("devtools/client/performance/modules/global"); + const count = CATEGORIES.length; + + ok(count, "Should have a non-empty list of categories available."); + + ok( + CATEGORIES.some(e => e.color), + "All categories have an associated color." + ); + + ok( + CATEGORIES.every(e => e.label), + "All categories have an associated label." + ); + + ok( + CATEGORIES.every(e => e.label === L10N.getStr("category." + e.abbrev)), + "All categories have a correctly localized label." + ); +}); diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-01.js b/devtools/client/performance/test/xpcshell/test_tree-model-01.js new file mode 100644 index 0000000000..2bb361496e --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-01.js @@ -0,0 +1,255 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if a call tree model can be correctly computed from a samples array. + */ + +add_task(function test() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + + // Create a root node from a given samples array. + + const threadNode = new ThreadNode(gThread, { startTime: 0, endTime: 20 }); + const root = getFrameNodePath(threadNode, "(root)"); + + // Test the root node. + + equal( + threadNode.getInfo().nodeType, + "Thread", + "The correct node type was retrieved for the root node." + ); + + equal( + threadNode.duration, + 20, + "The correct duration was calculated for the ThreadNode." + ); + equal( + root.getInfo().functionName, + "(root)", + "The correct function name was retrieved for the root node." + ); + equal( + root.getInfo().categoryData.abbrev, + "other", + "The correct empty category data was retrieved for the root node." + ); + + equal( + root.calls.length, + 1, + "The correct number of child calls were calculated for the root node." + ); + ok( + getFrameNodePath(root, "A"), + "The root node's only child call is correct." + ); + + // Test all the descendant nodes. + + equal( + getFrameNodePath(root, "A").calls.length, + 2, + "The correct number of child calls were calculated for the 'A' node." + ); + ok(getFrameNodePath(root, "A > B"), "The 'A' node has a 'B' child call."); + ok(getFrameNodePath(root, "A > E"), "The 'A' node has a 'E' child call."); + + equal( + getFrameNodePath(root, "A > B").calls.length, + 2, + "The correct number of child calls were calculated for the 'A > B' node." + ); + ok( + getFrameNodePath(root, "A > B > C"), + "The 'A > B' node has a 'C' child call." + ); + ok( + getFrameNodePath(root, "A > B > D"), + "The 'A > B' node has a 'D' child call." + ); + + equal( + getFrameNodePath(root, "A > E").calls.length, + 1, + "The correct number of child calls were calculated for the 'A > E' node." + ); + ok( + getFrameNodePath(root, "A > E > F"), + "The 'A > E' node has a 'F' child call." + ); + + equal( + getFrameNodePath(root, "A > B > C").calls.length, + 1, + "The correct number of child calls were calculated for the 'A > B > C' node." + ); + ok( + getFrameNodePath(root, "A > B > C > D"), + "The 'A > B > C' node has a 'D' child call." + ); + + equal( + getFrameNodePath(root, "A > B > C > D").calls.length, + 1, + "The correct number of child calls were calculated for the 'A > B > C > D' node." + ); + ok( + getFrameNodePath(root, "A > B > C > D > E"), + "The 'A > B > C > D' node has a 'E' child call." + ); + + equal( + getFrameNodePath(root, "A > B > C > D > E").calls.length, + 1, + "The correct number of child calls were calculated for the 'A > B > C > D > E' " + + "node." + ); + ok( + getFrameNodePath(root, "A > B > C > D > E > F"), + "The 'A > B > C > D > E' node has a 'F' child call." + ); + + equal( + getFrameNodePath(root, "A > B > C > D > E > F").calls.length, + 1, + "The correct number of child calls were calculated for the 'A > B > C > D > E > F' " + + "node." + ); + ok( + getFrameNodePath(root, "A > B > C > D > E > F > G"), + "The 'A > B > C > D > E > F' node has a 'G' child call." + ); + + equal( + getFrameNodePath(root, "A > B > C > D > E > F > G").calls.length, + 0, + "The correct number of child calls were calculated for the " + + "'A > B > C > D > E > F > G' node." + ); + equal( + getFrameNodePath(root, "A > B > D").calls.length, + 0, + "The correct number of child calls were calculated for the 'A > B > D' node." + ); + equal( + getFrameNodePath(root, "A > E > F").calls.length, + 0, + "The correct number of child calls were calculated for the 'A > E > F' node." + ); + + // Check the location, sample times, and samples of the root. + + equal( + getFrameNodePath(root, "A").location, + "A", + "The 'A' node has the correct location." + ); + equal( + getFrameNodePath(root, "A").youngestFrameSamples, + 0, + "The 'A' has correct `youngestFrameSamples`" + ); + equal( + getFrameNodePath(root, "A").samples, + 4, + "The 'A' has correct `samples`" + ); + + // A frame that is both a leaf and caught in another stack + equal( + getFrameNodePath(root, "A > B > C").youngestFrameSamples, + 1, + "The 'A > B > C' has correct `youngestFrameSamples`" + ); + equal( + getFrameNodePath(root, "A > B > C").samples, + 2, + "The 'A > B > C' has correct `samples`" + ); + + // ...and the rightmost leaf. + + equal( + getFrameNodePath(root, "A > E > F").location, + "F", + "The 'A > E > F' node has the correct location." + ); + equal( + getFrameNodePath(root, "A > E > F").samples, + 1, + "The 'A > E > F' node has the correct number of samples." + ); + equal( + getFrameNodePath(root, "A > E > F").youngestFrameSamples, + 1, + "The 'A > E > F' node has the correct number of youngestFrameSamples." + ); + + // ...and the leftmost leaf. + + equal( + getFrameNodePath(root, "A > B > C > D > E > F > G").location, + "G", + "The 'A > B > C > D > E > F > G' node has the correct location." + ); + equal( + getFrameNodePath(root, "A > B > C > D > E > F > G").samples, + 1, + "The 'A > B > C > D > E > F > G' node has the correct number of samples." + ); + equal( + getFrameNodePath(root, "A > B > C > D > E > F > G").youngestFrameSamples, + 1, + "The 'A > B > C > D > E > F > G' node has the correct number of " + + "youngestFrameSamples." + ); +}); + +var gThread = synthesizeProfileForTest([ + { + time: 5, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + ], + }, + { + time: 5 + 6, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "D" }, + ], + }, + { + time: 5 + 6 + 7, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "E" }, + { location: "F" }, + ], + }, + { + time: 20, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + { location: "D" }, + { location: "E" }, + { location: "F" }, + { location: "G" }, + ], + }, +]); diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-02.js b/devtools/client/performance/test/xpcshell/test_tree-model-02.js new file mode 100644 index 0000000000..91baca393a --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-02.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if a call tree model ignores samples with no timing information. + */ + +add_task(function test() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + + // Create a root node from a given samples array. + + const thread = new ThreadNode(gThread, { startTime: 0, endTime: 10 }); + const root = getFrameNodePath(thread, "(root)"); + + // Test the ThreadNode, only node with a duration. + equal( + thread.duration, + 10, + "The correct duration was calculated for the ThreadNode." + ); + + equal( + root.calls.length, + 1, + "The correct number of child calls were calculated for the root node." + ); + ok( + getFrameNodePath(root, "A"), + "The root node's only child call is correct." + ); + + // Test all the descendant nodes. + + equal( + getFrameNodePath(root, "A").calls.length, + 1, + "The correct number of child calls were calculated for the 'A' node." + ); + ok( + getFrameNodePath(root, "A > B"), + "The 'A' node's only child call is correct." + ); + + equal( + getFrameNodePath(root, "A > B").calls.length, + 1, + "The correct number of child calls were calculated for the 'A > B' node." + ); + ok( + getFrameNodePath(root, "A > B > C"), + "The 'A > B' node's only child call is correct." + ); + + equal( + getFrameNodePath(root, "A > B > C").calls.length, + 0, + "The correct number of child calls were calculated for the 'A > B > C' node." + ); +}); + +var gThread = synthesizeProfileForTest([ + { + time: 5, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + ], + }, + { + time: null, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "D" }, + ], + }, +]); diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-03.js b/devtools/client/performance/test/xpcshell/test_tree-model-03.js new file mode 100644 index 0000000000..6fb09e5ff2 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-03.js @@ -0,0 +1,123 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if a call tree model can be correctly computed from a samples array, + * while at the same time filtering by duration. + */ + +add_task(function test() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + + // Create a root node from a given samples array, filtering by time. + // + // Filtering from 5 to 18 includes the 2nd and 3rd samples. The 2nd sample + // starts exactly on 5 and ends at 11. The 3rd sample starts at 11 and ends + // exactly at 18. + const startTime = 5; + const endTime = 18; + const thread = new ThreadNode(gThread, { startTime, endTime }); + const root = getFrameNodePath(thread, "(root)"); + + // Test the root node. + + equal( + thread.duration, + endTime - startTime, + "The correct duration was calculated for the ThreadNode." + ); + + equal( + root.calls.length, + 1, + "The correct number of child calls were calculated for the root node." + ); + ok( + getFrameNodePath(root, "A"), + "The root node's only child call is correct." + ); + + // Test all the descendant nodes. + + equal( + getFrameNodePath(root, "A").calls.length, + 2, + "The correct number of child calls were calculated for the 'A' node." + ); + ok(getFrameNodePath(root, "A > B"), "The 'A' node has a 'B' child call."); + ok(getFrameNodePath(root, "A > E"), "The 'A' node has a 'E' child call."); + + equal( + getFrameNodePath(root, "A > B").calls.length, + 1, + "The correct number of child calls were calculated for the 'A > B' node." + ); + ok( + getFrameNodePath(root, "A > B > D"), + "The 'A > B' node's only child call is correct." + ); + + equal( + getFrameNodePath(root, "A > E").calls.length, + 1, + "The correct number of child calls were calculated for the 'A > E' node." + ); + ok( + getFrameNodePath(root, "A > E > F"), + "The 'A > E' node's only child call is correct." + ); + + equal( + getFrameNodePath(root, "A > B > D").calls.length, + 0, + "The correct number of child calls were calculated for the 'A > B > D' node." + ); + equal( + getFrameNodePath(root, "A > E > F").calls.length, + 0, + "The correct number of child calls were calculated for the 'A > E > F' node." + ); +}); + +var gThread = synthesizeProfileForTest([ + { + time: 5, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + ], + }, + { + time: 5 + 6, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "D" }, + ], + }, + { + time: 5 + 6 + 7, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "E" }, + { location: "F" }, + ], + }, + { + time: 5 + 6 + 7 + 8, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + { location: "D" }, + ], + }, +]); diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-04.js b/devtools/client/performance/test/xpcshell/test_tree-model-04.js new file mode 100644 index 0000000000..a89dcac86f --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-04.js @@ -0,0 +1,126 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if a call tree model can be correctly computed from a samples array, + * while at the same time filtering by duration and content-only frames. + */ + +add_task(function test() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + + // Create a root node from a given samples array, filtering by time. + + const startTime = 5; + const endTime = 18; + const thread = new ThreadNode(gThread, { + startTime, + endTime, + contentOnly: true, + }); + const root = getFrameNodePath(thread, "(root)"); + + // Test the ThreadNode, only node which should have duration + equal( + thread.duration, + endTime - startTime, + "The correct duration was calculated for the root ThreadNode." + ); + + equal( + root.calls.length, + 2, + "The correct number of child calls were calculated for the root node." + ); + ok( + getFrameNodePath(root, "http://D"), + "The root has a 'http://D' child call." + ); + ok( + getFrameNodePath(root, "http://A"), + "The root has a 'http://A' child call." + ); + + // Test all the descendant nodes. + + equal( + getFrameNodePath(root, "http://A").calls.length, + 1, + "The correct number of child calls were calculated for the 'http://A' node." + ); + ok( + getFrameNodePath(root, "http://A > https://E"), + "The 'http://A' node's only child call is correct." + ); + + equal( + getFrameNodePath(root, "http://A > https://E").calls.length, + 1, + "The correct number of child calls were calculated for the 'http://A > http://E' node." + ); + ok( + getFrameNodePath(root, "http://A > https://E > file://F"), + "The 'http://A > https://E' node's only child call is correct." + ); + + equal( + getFrameNodePath(root, "http://A > https://E > file://F").calls.length, + 1, + "The correct number of child calls were calculated for the 'http://A > https://E >> file://F' node." + ); + ok( + getFrameNodePath(root, "http://A > https://E > file://F > app://H"), + "The 'http://A > https://E >> file://F' node's only child call is correct." + ); + + equal( + getFrameNodePath(root, "http://D").calls.length, + 0, + "The correct number of child calls were calculated for the 'http://D' node." + ); +}); + +var gThread = synthesizeProfileForTest([ + { + time: 5, + frames: [ + { location: "(root)" }, + { location: "http://A" }, + { location: "http://B" }, + { location: "http://C" }, + ], + }, + { + time: 5 + 6, + frames: [ + { location: "(root)" }, + { location: "chrome://A" }, + { location: "resource://B" }, + { location: "jar:file://G" }, + { location: "http://D" }, + ], + }, + { + time: 5 + 6 + 7, + frames: [ + { location: "(root)" }, + { location: "http://A" }, + { location: "https://E" }, + { location: "file://F" }, + { location: "app://H" }, + ], + }, + { + time: 5 + 6 + 7 + 8, + frames: [ + { location: "(root)" }, + { location: "http://A" }, + { location: "http://B" }, + { location: "http://C" }, + { location: "http://D" }, + ], + }, +]); diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-05.js b/devtools/client/performance/test/xpcshell/test_tree-model-05.js new file mode 100644 index 0000000000..6c5f538c3b --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-05.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if an inverted call tree model can be correctly computed from a samples + * array. + */ + +var time = 1; + +var gThread = synthesizeProfileForTest([ + { + time: time++, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + ], + }, + { + time: time++, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "D" }, + { location: "C" }, + ], + }, + { + time: time++, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "E" }, + { location: "C" }, + ], + }, + { + time: time++, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "F" }, + ], + }, +]); + +add_task(function test() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + + const root = new ThreadNode(gThread, { + invertTree: true, + startTime: 0, + endTime: 4, + }); + + equal( + root.calls.length, + 2, + "Should get the 2 youngest frames, not the 1 oldest frame" + ); + + const C = getFrameNodePath(root, "C"); + ok(C, "Should have C as a child of the root."); + + equal(C.calls.length, 3, "Should have 3 frames that called C."); + ok(getFrameNodePath(C, "B"), "B called C."); + ok(getFrameNodePath(C, "D"), "D called C."); + ok(getFrameNodePath(C, "E"), "E called C."); + + equal(getFrameNodePath(C, "B").calls.length, 1); + ok(getFrameNodePath(C, "B > A"), "A called B called C"); + equal(getFrameNodePath(C, "D").calls.length, 1); + ok(getFrameNodePath(C, "D > A"), "A called D called C"); + equal(getFrameNodePath(C, "E").calls.length, 1); + ok(getFrameNodePath(C, "E > A"), "A called E called C"); + + const F = getFrameNodePath(root, "F"); + ok(F, "Should have F as a child of the root."); + + equal(F.calls.length, 1); + ok(getFrameNodePath(F, "B"), "B called F"); + + equal(getFrameNodePath(F, "B").calls.length, 1); + ok(getFrameNodePath(F, "B > A"), "A called B called F"); +}); diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-06.js b/devtools/client/performance/test/xpcshell/test_tree-model-06.js new file mode 100644 index 0000000000..9548ff0c8a --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-06.js @@ -0,0 +1,213 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that when constructing FrameNodes, if optimization data is available, + * the FrameNodes have the correct optimization data after iterating over samples, + * and only youngest frames capture optimization data. + */ + +add_task(function test() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + const root = getFrameNodePath( + new ThreadNode(gThread, { startTime: 0, endTime: 30 }), + "(root)" + ); + + const A = getFrameNodePath(root, "A"); + const B = getFrameNodePath(A, "B"); + const C = getFrameNodePath(B, "C"); + const Aopts = A.getOptimizations(); + const Bopts = B.getOptimizations(); + const Copts = C.getOptimizations(); + + ok( + !Aopts, + "A() was never youngest frame, so should not have optimization data" + ); + + equal( + Bopts.length, + 2, + "B() only has optimization data when it was a youngest frame" + ); + + // Check a few properties on the OptimizationSites. + const optSitesObserved = new Set(); + for (const opt of Bopts) { + if (opt.data.line === 12) { + equal( + opt.samples, + 2, + "Correct amount of samples for B()'s first opt site" + ); + equal(opt.data.attempts.length, 3, "First opt site has 3 attempts"); + equal( + opt.data.attempts[0].strategy, + "SomeGetter1", + "inflated strategy name" + ); + equal(opt.data.attempts[0].outcome, "Failure1", "inflated outcome name"); + equal( + opt.data.types[0].typeset[0].keyedBy, + "constructor", + "inflates type info" + ); + optSitesObserved.add("first"); + } else { + equal( + opt.samples, + 1, + "Correct amount of samples for B()'s second opt site" + ); + optSitesObserved.add("second"); + } + } + + ok(optSitesObserved.has("first"), "first opt site for B() was checked"); + ok(optSitesObserved.has("second"), "second opt site for B() was checked"); + + equal(Copts.length, 1, "C() always youngest frame, so has optimization data"); +}); + +var gUniqueStacks = new RecordingUtils.UniqueStacks(); + +function uniqStr(s) { + return gUniqueStacks.getOrAddStringIndex(s); +} + +var gThread = RecordingUtils.deflateThread( + { + samples: [ + { + time: 0, + frames: [{ location: "(root)" }], + }, + { + time: 10, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B_LEAF_1" }, + ], + }, + { + time: 15, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B_NOTLEAF" }, + { location: "C" }, + ], + }, + { + time: 20, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B_LEAF_2" }, + ], + }, + { + time: 25, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B_LEAF_2" }, + ], + }, + ], + markers: [], + }, + gUniqueStacks +); + +var gRawSite1 = { + line: 12, + column: 2, + types: [ + { + mirType: uniqStr("Object"), + site: uniqStr("B (http://foo/bar:10)"), + typeset: [ + { + keyedBy: uniqStr("constructor"), + name: uniqStr("Foo"), + location: uniqStr("B (http://foo/bar:10)"), + }, + { + keyedBy: uniqStr("primitive"), + location: uniqStr("self-hosted"), + }, + ], + }, + ], + attempts: { + schema: { + outcome: 0, + strategy: 1, + }, + data: [ + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure2"), uniqStr("SomeGetter2")], + [uniqStr("Inlined"), uniqStr("SomeGetter3")], + ], + }, +}; + +var gRawSite2 = { + line: 22, + types: [ + { + mirType: uniqStr("Int32"), + site: uniqStr("Receiver"), + }, + ], + attempts: { + schema: { + outcome: 0, + strategy: 1, + }, + data: [ + [uniqStr("Failure1"), uniqStr("SomeGetter1")], + [uniqStr("Failure2"), uniqStr("SomeGetter2")], + [uniqStr("Failure3"), uniqStr("SomeGetter3")], + ], + }, +}; + +function serialize(x) { + return JSON.parse(JSON.stringify(x)); +} + +gThread.frameTable.data.forEach(frame => { + const LOCATION_SLOT = gThread.frameTable.schema.location; + const OPTIMIZATIONS_SLOT = gThread.frameTable.schema.optimizations; + + const l = gThread.stringTable[frame[LOCATION_SLOT]]; + switch (l) { + case "A": + frame[OPTIMIZATIONS_SLOT] = serialize(gRawSite1); + break; + // Rename some of the location sites so we can register different + // frames with different opt sites + case "B_LEAF_1": + frame[OPTIMIZATIONS_SLOT] = serialize(gRawSite2); + frame[LOCATION_SLOT] = uniqStr("B"); + break; + case "B_LEAF_2": + frame[OPTIMIZATIONS_SLOT] = serialize(gRawSite1); + frame[LOCATION_SLOT] = uniqStr("B"); + break; + case "B_NOTLEAF": + frame[OPTIMIZATIONS_SLOT] = serialize(gRawSite1); + frame[LOCATION_SLOT] = uniqStr("B"); + break; + case "C": + frame[OPTIMIZATIONS_SLOT] = serialize(gRawSite1); + break; + } +}); diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-07.js b/devtools/client/performance/test/xpcshell/test_tree-model-07.js new file mode 100644 index 0000000000..899cde1988 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-07.js @@ -0,0 +1,133 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that when displaying only content nodes, platform nodes are generalized. + */ + +var { + CATEGORY_INDEX, +} = require("devtools/client/performance/modules/categories"); + +add_task(function test() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + const url = n => `http://content/${n}`; + + // Create a root node from a given samples array. + + const root = getFrameNodePath( + new ThreadNode(gThread, { startTime: 5, endTime: 30, contentOnly: true }), + "(root)" + ); + + /* + * should have a tree like: + * root + * - (JS) + * - A + * - (GC) + * - B + * - C + * - D + * - E + * - F + * - (JS) + */ + + // Test the root node. + + equal(root.calls.length, 2, "root has 2 children"); + ok(getFrameNodePath(root, url("A")), "root has content child"); + ok( + getFrameNodePath(root, `${CATEGORY_INDEX("js")}`), + "root has platform generalized child" + ); + equal( + getFrameNodePath(root, `${CATEGORY_INDEX("js")}`).calls.length, + 0, + "platform generalized child is a leaf." + ); + + ok( + getFrameNodePath(root, `${url("A")} > ${CATEGORY_INDEX("gc")}`), + "A has platform generalized child of another type" + ); + equal( + getFrameNodePath(root, `${url("A")} > ${CATEGORY_INDEX("gc")}`).calls + .length, + 0, + "second generalized type is a leaf." + ); + + ok( + getFrameNodePath( + root, + `${url("A")} > ${url("E")} > ${url("F")} > ${CATEGORY_INDEX("js")}` + ), + "a second leaf of the first generalized type exists deep in the tree." + ); + ok( + getFrameNodePath(root, `${url("A")} > ${CATEGORY_INDEX("gc")}`), + "A has platform generalized child of another type" + ); + + equal( + getFrameNodePath(root, `${CATEGORY_INDEX("js")}`).category, + getFrameNodePath( + root, + `${url("A")} > ${url("E")} > ${url("F")} > ${CATEGORY_INDEX("js")}` + ).category, + "generalized frames of same type are duplicated in top-down view" + ); +}); + +var gThread = synthesizeProfileForTest([ + { + time: 5, + frames: [ + { location: "(root)" }, + { location: "http://content/A" }, + { location: "http://content/B" }, + { location: "http://content/C" }, + ], + }, + { + time: 5 + 6, + frames: [ + { location: "(root)" }, + { location: "http://content/A" }, + { location: "http://content/B" }, + { location: "contentY", category: CATEGORY_INDEX("layout") }, + { location: "http://content/D" }, + ], + }, + { + time: 5 + 6 + 7, + frames: [ + { location: "(root)" }, + { location: "http://content/A" }, + { location: "contentY", category: CATEGORY_INDEX("layout") }, + { location: "http://content/E" }, + { location: "http://content/F" }, + { location: "contentY", category: CATEGORY_INDEX("js") }, + ], + }, + { + time: 5 + 20, + frames: [ + { location: "(root)" }, + { location: "contentX", category: CATEGORY_INDEX("js") }, + ], + }, + { + time: 5 + 25, + frames: [ + { location: "(root)" }, + { location: "http://content/A" }, + { location: "contentZ", category: CATEGORY_INDEX("gc") }, + ], + }, +]); diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-08.js b/devtools/client/performance/test/xpcshell/test_tree-model-08.js new file mode 100644 index 0000000000..075e9c265e --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-08.js @@ -0,0 +1,250 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Verifies if FrameNodes retain and parse their data appropriately. + */ + +add_task(function test() { + const FrameUtils = require("devtools/client/performance/modules/logic/frame-utils"); + const { + FrameNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + const { + CATEGORY_INDEX, + } = require("devtools/client/performance/modules/categories"); + const compute = frame => { + FrameUtils.computeIsContentAndCategory(frame); + return frame; + }; + + const frames = [ + new FrameNode( + "hello/<.world (http://foo/bar.js:123:987)", + compute({ + location: "hello/<.world (http://foo/bar.js:123:987)", + line: 456, + }), + false + ), + new FrameNode( + "hello/<.world (http://foo/bar.js#baz:123:987)", + compute({ + location: "hello/<.world (http://foo/bar.js#baz:123:987)", + line: 456, + }), + false + ), + new FrameNode( + "hello/<.world (http://foo/#bar:123:987)", + compute({ + location: "hello/<.world (http://foo/#bar:123:987)", + line: 456, + }), + false + ), + new FrameNode( + "hello/<.world (http://foo/:123:987)", + compute({ + location: "hello/<.world (http://foo/:123:987)", + line: 456, + }), + false + ), + new FrameNode( + "hello/<.world (resource://foo.js -> http://bar/baz.js:123:987)", + compute({ + location: + "hello/<.world (resource://foo.js -> http://bar/baz.js:123:987)", + line: 456, + }), + false + ), + new FrameNode( + "Foo::Bar::Baz", + compute({ + location: "Foo::Bar::Baz", + line: 456, + category: CATEGORY_INDEX("other"), + }), + false + ), + new FrameNode( + "EnterJIT", + compute({ + location: "EnterJIT", + }), + false + ), + new FrameNode( + "chrome://browser/content/content.js", + compute({ + location: "chrome://browser/content/content.js", + line: 456, + column: 123, + }), + false + ), + new FrameNode( + "hello/<.world (resource://gre/foo.js:123:434)", + compute({ + location: "hello/<.world (resource://gre/foo.js:123:434)", + line: 456, + }), + false + ), + new FrameNode( + "main (http://localhost:8888/file.js:123:987)", + compute({ + location: "main (http://localhost:8888/file.js:123:987)", + line: 123, + }), + false + ), + new FrameNode( + "main (resource://devtools/timeline.js:123)", + compute({ + location: "main (resource://devtools/timeline.js:123)", + }), + false + ), + ]; + + const fields = [ + "nodeType", + "functionName", + "fileName", + "host", + "url", + "line", + "column", + "categoryData.abbrev", + "isContent", + "port", + ]; + const expected = [ + // nodeType, functionName, fileName, host, url, line, column, categoryData.abbrev, + // isContent, port + [ + "Frame", + "hello/<.world", + "bar.js", + "foo", + "http://foo/bar.js", + 123, + 987, + void 0, + true, + ], + [ + "Frame", + "hello/<.world", + "bar.js", + "foo", + "http://foo/bar.js#baz", + 123, + 987, + void 0, + true, + ], + [ + "Frame", + "hello/<.world", + "/", + "foo", + "http://foo/#bar", + 123, + 987, + void 0, + true, + ], + [ + "Frame", + "hello/<.world", + "/", + "foo", + "http://foo/", + 123, + 987, + void 0, + true, + ], + [ + "Frame", + "hello/<.world", + "baz.js", + "bar", + "http://bar/baz.js", + 123, + 987, + "other", + false, + ], + ["Frame", "Foo::Bar::Baz", null, null, null, 456, void 0, "other", false], + ["Frame", "EnterJIT", null, null, null, null, null, "js", false], + [ + "Frame", + "chrome://browser/content/content.js", + null, + null, + null, + 456, + null, + "other", + false, + ], + [ + "Frame", + "hello/<.world", + "foo.js", + null, + "resource://gre/foo.js", + 123, + 434, + "other", + false, + ], + [ + "Frame", + "main", + "file.js", + "localhost:8888", + "http://localhost:8888/file.js", + 123, + 987, + null, + true, + 8888, + ], + [ + "Frame", + "main", + "timeline.js", + null, + "resource://devtools/timeline.js", + 123, + null, + "tools", + false, + ], + ]; + + for (let i = 0; i < frames.length; i++) { + const info = frames[i].getInfo(); + const expect = expected[i]; + + for (let j = 0; j < fields.length; j++) { + const field = fields[j]; + const value = + field === "categoryData.abbrev" + ? info.categoryData.abbrev + : info[field]; + equal( + value, + expect[j], + `${field} for frame #${i} is correct: ${expect[j]}` + ); + } + } +}); diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-09.js b/devtools/client/performance/test/xpcshell/test_tree-model-09.js new file mode 100644 index 0000000000..393fbce50c --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-09.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that when displaying only content nodes, platform nodes are generalized. + */ + +var { + CATEGORY_INDEX, +} = require("devtools/client/performance/modules/categories"); + +add_task(function test() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + const url = n => `http://content/${n}`; + + // Create a root node from a given samples array. + + const root = getFrameNodePath( + new ThreadNode(gThread, { startTime: 5, endTime: 25, contentOnly: true }), + "(root)" + ); + + /* + * should have a tree like: + * root + * - (Tools) + * - A + * - B + * - C + * - D + * - E + * - F + * - (Tools) + */ + + // Test the root node. + + equal(root.calls.length, 2, "root has 2 children"); + ok(getFrameNodePath(root, url("A")), "root has content child"); + ok( + getFrameNodePath(root, `${CATEGORY_INDEX("tools")}`), + "root has platform generalized child from Chrome JS" + ); + equal( + getFrameNodePath(root, `${CATEGORY_INDEX("tools")}`).calls.length, + 0, + "platform generalized child is a leaf." + ); + + ok( + getFrameNodePath( + root, + `${url("A")} > ${url("E")} > ${url("F")} > ${CATEGORY_INDEX("tools")}` + ), + "a second leaf of the generalized Chrome JS exists." + ); + + equal( + getFrameNodePath(root, `${CATEGORY_INDEX("tools")}`).category, + getFrameNodePath( + root, + `${url("A")} > ${url("E")} > ${url("F")} > ${CATEGORY_INDEX("tools")}` + ).category, + "generalized frames of same type are duplicated in top-down view" + ); +}); + +var gThread = synthesizeProfileForTest([ + { + time: 5, + frames: [ + { location: "(root)" }, + { location: "http://content/A" }, + { location: "http://content/B" }, + { location: "http://content/C" }, + ], + }, + { + time: 5 + 6, + frames: [ + { location: "(root)" }, + { location: "http://content/A" }, + { location: "http://content/B" }, + { + location: + "fn (resource://loader.js -> resource://devtools/timeline.js)", + }, + { location: "http://content/D" }, + ], + }, + { + time: 5 + 6 + 7, + frames: [ + { location: "(root)" }, + { location: "http://content/A" }, + { location: "http://content/E" }, + { location: "http://content/F" }, + { + location: "fn (resource://loader.js -> resource://devtools/promise.js)", + }, + ], + }, + { + time: 5 + 20, + frames: [ + { location: "(root)" }, + { + location: + "somefn (resource://loader.js -> resource://devtools/framerate.js)", + }, + ], + }, +]); diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-10.js b/devtools/client/performance/test/xpcshell/test_tree-model-10.js new file mode 100644 index 0000000000..b928c99e4b --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-10.js @@ -0,0 +1,157 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the tree model calculates correct costs/percentages for + * frame nodes. The model-only version of browser_profiler-tree-view-10.js + */ + +add_task(function() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + const thread = new ThreadNode(gThread, { + invertTree: true, + startTime: 0, + endTime: 50, + }); + + /** + * Samples + * + * A->C + * A->B + * A->B->C x4 + * A->B->D x4 + * + * Expected Tree + * +--total--+--self--+--tree-------------+ + * | 50% | 50% | C + * | 40% | 0 | -> B + * | 30% | 0 | -> A + * | 10% | 0 | -> A + * + * | 40% | 40% | D + * | 40% | 0 | -> B + * | 40% | 0 | -> A + * + * | 10% | 10% | B + * | 10% | 0 | -> A + */ + + [ + // total, self, name + [ + 50, + 50, + "C", + [ + [40, 0, "B", [[30, 0, "A"]]], + [10, 0, "A"], + ], + ], + [40, 40, "D", [[40, 0, "B", [[40, 0, "A"]]]]], + [10, 10, "B", [[10, 0, "A"]]], + ].forEach(compareFrameInfo(thread)); +}); + +function compareFrameInfo(root, parent) { + parent = parent || root; + return function(def) { + const [total, self, name, children] = def; + const node = getFrameNodePath(parent, name); + const data = node.getInfo({ root }); + equal( + total, + data.totalPercentage, + `${name} has correct total percentage: ${data.totalPercentage}` + ); + equal( + self, + data.selfPercentage, + `${name} has correct self percentage: ${data.selfPercentage}` + ); + if (children) { + children.forEach(compareFrameInfo(root, node)); + } + }; +} + +var gThread = synthesizeProfileForTest([ + { + time: 5, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + ], + }, + { + time: 10, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "D" }, + ], + }, + { + time: 15, + frames: [{ location: "(root)" }, { location: "A" }, { location: "C" }], + }, + { + time: 20, + frames: [{ location: "(root)" }, { location: "A" }, { location: "B" }], + }, + { + time: 25, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + ], + }, + { + time: 30, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + ], + }, + { + time: 35, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "D" }, + ], + }, + { + time: 40, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "D" }, + ], + }, + { + time: 45, + frames: [{ location: "(root)" }, { location: "B" }, { location: "C" }], + }, + { + time: 50, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "D" }, + ], + }, +]); diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-11.js b/devtools/client/performance/test/xpcshell/test_tree-model-11.js new file mode 100644 index 0000000000..859637fbe5 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-11.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the costs for recursive frames does not overcount the collapsed + * samples. + */ + +add_task(function() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + const thread = new ThreadNode(gThread, { + startTime: 0, + endTime: 50, + flattenRecursion: true, + }); + + /** + * Samples + * + * A->B->C + * A->B->B->B->C + * A->B + * A->B->B->B + */ + + [ + // total, self, name + [100, 0, "(root)", [[100, 0, "A", [[100, 50, "B", [[50, 50, "C"]]]]]]], + ].forEach(compareFrameInfo(thread)); +}); + +function compareFrameInfo(root, parent) { + parent = parent || root; + return function(def) { + const [total, self, name, children] = def; + const node = getFrameNodePath(parent, name); + const data = node.getInfo({ root }); + equal( + total, + data.totalPercentage, + `${name} has correct total percentage: ${data.totalPercentage}` + ); + equal( + self, + data.selfPercentage, + `${name} has correct self percentage: ${data.selfPercentage}` + ); + if (children) { + children.forEach(compareFrameInfo(root, node)); + } + }; +} + +var gThread = synthesizeProfileForTest([ + { + time: 5, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "B" }, + { location: "B" }, + { location: "C" }, + ], + }, + { + time: 10, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "C" }, + ], + }, + { + time: 15, + frames: [ + { location: "(root)" }, + { location: "A" }, + { location: "B" }, + { location: "B" }, + { location: "B" }, + ], + }, + { + time: 20, + frames: [{ location: "(root)" }, { location: "A" }, { location: "B" }], + }, +]); diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-12.js b/devtools/client/performance/test/xpcshell/test_tree-model-12.js new file mode 100644 index 0000000000..9576970784 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-12.js @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that uninverting the call tree works correctly when there are stacks +// in the profile that prefixes of other stacks. + +add_task(function() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + const thread = new ThreadNode(gThread, { startTime: 0, endTime: 50 }); + const root = getFrameNodePath(thread, "(root)"); + + /** + * Samples + * + * A->B + * C->B + * B + * A + * Z->Y->X + * W->Y->X + * Y->X + */ + + equal( + getFrameNodePath(root, "A > B").youngestFrameSamples, + 1, + "A > B has the correct self count" + ); + equal( + getFrameNodePath(root, "C > B").youngestFrameSamples, + 1, + "C > B has the correct self count" + ); + equal( + getFrameNodePath(root, "B").youngestFrameSamples, + 1, + "B has the correct self count" + ); + equal( + getFrameNodePath(root, "A").youngestFrameSamples, + 1, + "A has the correct self count" + ); + equal( + getFrameNodePath(root, "Z > Y > X").youngestFrameSamples, + 1, + "Z > Y > X has the correct self count" + ); + equal( + getFrameNodePath(root, "W > Y > X").youngestFrameSamples, + 1, + "W > Y > X has the correct self count" + ); + equal( + getFrameNodePath(root, "Y > X").youngestFrameSamples, + 1, + "Y > X has the correct self count" + ); +}); + +var gThread = synthesizeProfileForTest([ + { + time: 5, + frames: [{ location: "(root)" }, { location: "A" }, { location: "B" }], + }, + { + time: 10, + frames: [{ location: "(root)" }, { location: "C" }, { location: "B" }], + }, + { + time: 15, + frames: [{ location: "(root)" }, { location: "B" }], + }, + { + time: 20, + frames: [{ location: "(root)" }, { location: "A" }], + }, + { + time: 21, + frames: [ + { location: "(root)" }, + { location: "Z" }, + { location: "Y" }, + { location: "X" }, + ], + }, + { + time: 22, + frames: [ + { location: "(root)" }, + { location: "W" }, + { location: "Y" }, + { location: "X" }, + ], + }, + { + time: 23, + frames: [{ location: "(root)" }, { location: "Y" }, { location: "X" }], + }, +]); diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-13.js b/devtools/client/performance/test/xpcshell/test_tree-model-13.js new file mode 100644 index 0000000000..0580d49956 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-13.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Like test_tree-model-12, but inverted. + +add_task(function() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + const root = new ThreadNode(gThread, { + invertTree: true, + startTime: 0, + endTime: 50, + }); + + /** + * Samples + * + * A->B + * C->B + * B + * A + * Z->Y->X + * W->Y->X + * Y->X + */ + + equal( + getFrameNodePath(root, "B").youngestFrameSamples, + 3, + "B has the correct self count" + ); + equal( + getFrameNodePath(root, "A").youngestFrameSamples, + 1, + "A has the correct self count" + ); + equal( + getFrameNodePath(root, "X").youngestFrameSamples, + 3, + "X has the correct self count" + ); + equal( + getFrameNodePath(root, "X > Y").samples, + 3, + "X > Y has the correct total count" + ); +}); + +var gThread = synthesizeProfileForTest([ + { + time: 5, + frames: [{ location: "(root)" }, { location: "A" }, { location: "B" }], + }, + { + time: 10, + frames: [{ location: "(root)" }, { location: "C" }, { location: "B" }], + }, + { + time: 15, + frames: [{ location: "(root)" }, { location: "B" }], + }, + { + time: 20, + frames: [{ location: "(root)" }, { location: "A" }], + }, + { + time: 21, + frames: [ + { location: "(root)" }, + { location: "Z" }, + { location: "Y" }, + { location: "X" }, + ], + }, + { + time: 22, + frames: [ + { location: "(root)" }, + { location: "W" }, + { location: "Y" }, + { location: "X" }, + ], + }, + { + time: 23, + frames: [{ location: "(root)" }, { location: "Y" }, { location: "X" }], + }, +]); diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-allocations-01.js b/devtools/client/performance/test/xpcshell/test_tree-model-allocations-01.js new file mode 100644 index 0000000000..cd4850dc97 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-allocations-01.js @@ -0,0 +1,131 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; +/** + * Tests that the tree model calculates correct costs/percentages for + * allocation frame nodes. + */ + +add_task(function() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + const { + getProfileThreadFromAllocations, + } = require("devtools/shared/performance/recording-utils"); + const allocationData = getProfileThreadFromAllocations(TEST_DATA); + const thread = new ThreadNode(allocationData, { + startTime: 0, + endTime: 1000, + }); + + /* eslint-disable max-len */ + /** + * Values are in order according to: + * +-------------+------------+-------------+-------------+------------------------------+ + * | Self Bytes | Self Count | Total Bytes | Total Count | Function | + * +-------------+------------+-------------+-------------+------------------------------+ + * | 1790272 41% | 8307 17% | 1790372 42% | 8317 18% | V someFunc @ a.j:345:6 | + * | 100 1% | 10 1% | 100 1% | 10 1% | > callerFunc @ b.j:765:34 | + * +-------------+------------+-------------+-------------+------------------------------+ + */ + /* eslint-enable max-len */ + [ + [ + 100, + 10, + 1, + 33, + 1000, + 100, + 3, + 100, + "x (A:1:2)", + [ + [ + 200, + 20, + 1, + 33, + 900, + 90, + 2, + 66, + "y (B:3:4)", + [[700, 70, 1, 33, 700, 70, 1, 33, "z (C:5:6)"]], + ], + ], + ], + ].forEach(compareFrameInfo(thread)); +}); + +function compareFrameInfo(root, parent) { + parent = parent || root; + const fields = [ + "selfSize", + "selfSizePercentage", + "selfCount", + "selfCountPercentage", + "totalSize", + "totalSizePercentage", + "totalCount", + "totalCountPercentage", + ]; + return function(def) { + let children; + if (Array.isArray(def[def.length - 1])) { + children = def.pop(); + } + const name = def.pop(); + const expected = def; + + const node = getFrameNodePath(parent, name); + const data = node.getInfo({ root, allocations: true }); + + fields.forEach((field, i) => { + let actual = data[field]; + if (/percentage/i.test(field)) { + actual = Number.parseInt(actual, 10); + } + equal( + actual, + expected[i], + `${name} has correct ${field}: ${expected[i]}` + ); + }); + + if (children) { + children.forEach(compareFrameInfo(root, node)); + } + }; +} + +var TEST_DATA = { + sites: [1, 2, 3], + timestamps: [150, 200, 250], + sizes: [100, 200, 700], + frames: [ + null, + { + source: "A", + line: 1, + column: 2, + functionDisplayName: "x", + parent: 0, + }, + { + source: "B", + line: 3, + column: 4, + functionDisplayName: "y", + parent: 1, + }, + { + source: "C", + line: 5, + column: 6, + functionDisplayName: "z", + parent: 2, + }, + ], +}; diff --git a/devtools/client/performance/test/xpcshell/test_tree-model-allocations-02.js b/devtools/client/performance/test/xpcshell/test_tree-model-allocations-02.js new file mode 100644 index 0000000000..ec7f71eb6b --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_tree-model-allocations-02.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the tree model calculates correct costs/percentages for + * allocation frame nodes. Inverted version of test_tree-model-allocations-01.js + */ + +add_task(function() { + const { + ThreadNode, + } = require("devtools/client/performance/modules/logic/tree-model"); + const { + getProfileThreadFromAllocations, + } = require("devtools/shared/performance/recording-utils"); + const allocationData = getProfileThreadFromAllocations(TEST_DATA); + const thread = new ThreadNode(allocationData, { + invertTree: true, + startTime: 0, + endTime: 1000, + }); + + /* eslint-disable max-len */ + /** + * Values are in order according to: + * +-------------+------------+-------------+-------------+------------------------------+ + * | Self Bytes | Self Count | Total Bytes | Total Count | Function | + * +-------------+------------+-------------+-------------+------------------------------+ + * | 1790272 41% | 8307 17% | 1790372 42% | 8317 18% | V someFunc @ a.j:345:6 | + * | 100 1% | 10 1% | 100 1% | 10 1% | > callerFunc @ b.j:765:34 | + * +-------------+------------+-------------+-------------+------------------------------+ + */ + /* eslint-enable max-len */ + [ + [ + 700, + 70, + 1, + 33, + 700, + 70, + 1, + 33, + "z (C:5:6)", + [ + [ + 0, + 0, + 0, + 0, + 700, + 70, + 1, + 33, + "y (B:3:4)", + [[0, 0, 0, 0, 700, 70, 1, 33, "x (A:1:2)"]], + ], + ], + ], + [ + 200, + 20, + 1, + 33, + 200, + 20, + 1, + 33, + "y (B:3:4)", + [[0, 0, 0, 0, 200, 20, 1, 33, "x (A:1:2)"]], + ], + [100, 10, 1, 33, 100, 10, 1, 33, "x (A:1:2)"], + ].forEach(compareFrameInfo(thread)); +}); + +function compareFrameInfo(root, parent) { + parent = parent || root; + const fields = [ + "selfSize", + "selfSizePercentage", + "selfCount", + "selfCountPercentage", + "totalSize", + "totalSizePercentage", + "totalCount", + "totalCountPercentage", + ]; + + return function(def) { + let children; + + if (Array.isArray(def[def.length - 1])) { + children = def.pop(); + } + + const name = def.pop(); + const expected = def; + + const node = getFrameNodePath(parent, name); + const data = node.getInfo({ root, allocations: true }); + + fields.forEach((field, i) => { + let actual = data[field]; + if (/percentage/i.test(field)) { + actual = Number.parseInt(actual, 10); + } + equal( + actual, + expected[i], + `${name} has correct ${field}: ${expected[i]}` + ); + }); + + if (children) { + children.forEach(compareFrameInfo(root, node)); + } + }; +} + +var TEST_DATA = { + sites: [0, 1, 2, 3], + timestamps: [0, 150, 200, 250], + sizes: [0, 100, 200, 700], + frames: [ + { + source: "(root)", + }, + { + source: "A", + line: 1, + column: 2, + functionDisplayName: "x", + parent: 0, + }, + { + source: "B", + line: 3, + column: 4, + functionDisplayName: "y", + parent: 1, + }, + { + source: "C", + line: 5, + column: 6, + functionDisplayName: "z", + parent: 2, + }, + ], +}; diff --git a/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-01.js b/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-01.js new file mode 100644 index 0000000000..9dc5267cbc --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-01.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the waterfall collapsing logic works properly. + */ + +add_task(function test() { + const WaterfallUtils = require("devtools/client/performance/modules/logic/waterfall-utils"); + + const rootMarkerNode = WaterfallUtils.createParentNode({ name: "(root)" }); + + WaterfallUtils.collapseMarkersIntoNode({ + rootNode: rootMarkerNode, + markersList: gTestMarkers, + }); + + function compare(marker, expected) { + for (const prop in expected) { + if (prop === "submarkers") { + for (let i = 0; i < expected.submarkers.length; i++) { + compare(marker.submarkers[i], expected.submarkers[i]); + } + } else if (prop !== "uid") { + equal(marker[prop], expected[prop], `${expected.name} matches ${prop}`); + } + } + } + + compare(rootMarkerNode, gExpectedOutput); +}); + +const gTestMarkers = [ + { start: 1, end: 18, name: "DOMEvent" }, + // Test that JS markers can fold in DOM events and have marker children + { start: 2, end: 16, name: "Javascript" }, + // Test all these markers can be children + { start: 3, end: 4, name: "Paint" }, + { start: 5, end: 6, name: "Reflow" }, + { start: 7, end: 8, name: "Styles" }, + { start: 9, end: 9, name: "TimeStamp" }, + { start: 10, end: 11, name: "Parse HTML" }, + { start: 12, end: 13, name: "Parse XML" }, + { start: 14, end: 15, name: "GarbageCollection" }, + // Test that JS markers can be parents without being a child of DOM events + { start: 25, end: 30, name: "Javascript" }, + { start: 26, end: 27, name: "Paint" }, +]; + +const gExpectedOutput = { + name: "(root)", + submarkers: [ + { + start: 1, + end: 18, + name: "DOMEvent", + submarkers: [ + { + start: 2, + end: 16, + name: "Javascript", + submarkers: [ + { start: 3, end: 4, name: "Paint" }, + { start: 5, end: 6, name: "Reflow" }, + { start: 7, end: 8, name: "Styles" }, + { start: 9, end: 9, name: "TimeStamp" }, + { start: 10, end: 11, name: "Parse HTML" }, + { start: 12, end: 13, name: "Parse XML" }, + { start: 14, end: 15, name: "GarbageCollection" }, + ], + }, + ], + }, + { + start: 25, + end: 30, + name: "Javascript", + submarkers: [{ start: 26, end: 27, name: "Paint" }], + }, + ], +}; diff --git a/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-02.js b/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-02.js new file mode 100644 index 0000000000..eb550d7867 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-02.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the waterfall collapsing logic works properly for console.time/console.timeEnd + * markers, as they should ignore any sort of collapsing. + */ + +add_task(function test() { + const WaterfallUtils = require("devtools/client/performance/modules/logic/waterfall-utils"); + + const rootMarkerNode = WaterfallUtils.createParentNode({ name: "(root)" }); + + WaterfallUtils.collapseMarkersIntoNode({ + rootNode: rootMarkerNode, + markersList: gTestMarkers, + }); + + function compare(marker, expected) { + for (const prop in expected) { + if (prop === "submarkers") { + for (let i = 0; i < expected.submarkers.length; i++) { + compare(marker.submarkers[i], expected.submarkers[i]); + } + } else if (prop !== "uid") { + equal(marker[prop], expected[prop], `${expected.name} matches ${prop}`); + } + } + } + + compare(rootMarkerNode, gExpectedOutput); +}); + +const gTestMarkers = [ + { start: 2, end: 9, name: "Javascript" }, + { start: 3, end: 4, name: "Paint" }, + // Time range starting in nest, ending outside + { start: 5, end: 12, name: "ConsoleTime", causeName: "1" }, + + // Time range starting outside of nest, ending inside + { start: 15, end: 21, name: "ConsoleTime", causeName: "2" }, + { start: 18, end: 22, name: "Javascript" }, + { start: 19, end: 20, name: "Paint" }, + + // Time range completely eclipsing nest + { start: 30, end: 40, name: "ConsoleTime", causeName: "3" }, + { start: 34, end: 39, name: "Javascript" }, + { start: 35, end: 36, name: "Paint" }, + + // Time range completely eclipsed by nest + { start: 50, end: 60, name: "Javascript" }, + { start: 54, end: 59, name: "ConsoleTime", causeName: "4" }, + { start: 56, end: 57, name: "Paint" }, +]; + +const gExpectedOutput = { + name: "(root)", + submarkers: [ + { + start: 2, + end: 9, + name: "Javascript", + submarkers: [{ start: 3, end: 4, name: "Paint" }], + }, + { start: 5, end: 12, name: "ConsoleTime", causeName: "1" }, + + { start: 15, end: 21, name: "ConsoleTime", causeName: "2" }, + { + start: 18, + end: 22, + name: "Javascript", + submarkers: [{ start: 19, end: 20, name: "Paint" }], + }, + + { start: 30, end: 40, name: "ConsoleTime", causeName: "3" }, + { + start: 34, + end: 39, + name: "Javascript", + submarkers: [{ start: 35, end: 36, name: "Paint" }], + }, + + { + start: 50, + end: 60, + name: "Javascript", + submarkers: [{ start: 56, end: 57, name: "Paint" }], + }, + { start: 54, end: 59, name: "ConsoleTime", causeName: "4" }, + ], +}; diff --git a/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-03.js b/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-03.js new file mode 100644 index 0000000000..b9124c6dfd --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-03.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the waterfall collapsing works when atleast two + * collapsible markers downward, and the following marker is outside of both ranges. + */ + +add_task(function test() { + const WaterfallUtils = require("devtools/client/performance/modules/logic/waterfall-utils"); + + const rootMarkerNode = WaterfallUtils.createParentNode({ name: "(root)" }); + + WaterfallUtils.collapseMarkersIntoNode({ + rootNode: rootMarkerNode, + markersList: gTestMarkers, + }); + + function compare(marker, expected) { + for (const prop in expected) { + if (prop === "submarkers") { + for (let i = 0; i < expected.submarkers.length; i++) { + compare(marker.submarkers[i], expected.submarkers[i]); + } + } else if (prop !== "uid") { + equal(marker[prop], expected[prop], `${expected.name} matches ${prop}`); + } + } + } + + compare(rootMarkerNode, gExpectedOutput); +}); + +const gTestMarkers = [ + { start: 2, end: 10, name: "DOMEvent" }, + { start: 3, end: 9, name: "Javascript" }, + { start: 4, end: 8, name: "GarbageCollection" }, + { start: 11, end: 12, name: "Styles" }, + { start: 13, end: 14, name: "Styles" }, + { start: 15, end: 25, name: "DOMEvent" }, + { start: 17, end: 24, name: "Javascript" }, + { start: 18, end: 19, name: "GarbageCollection" }, +]; + +const gExpectedOutput = { + name: "(root)", + submarkers: [ + { + start: 2, + end: 10, + name: "DOMEvent", + submarkers: [ + { + start: 3, + end: 9, + name: "Javascript", + submarkers: [{ start: 4, end: 8, name: "GarbageCollection" }], + }, + ], + }, + { start: 11, end: 12, name: "Styles" }, + { start: 13, end: 14, name: "Styles" }, + { + start: 15, + end: 25, + name: "DOMEvent", + submarkers: [ + { + start: 17, + end: 24, + name: "Javascript", + submarkers: [{ start: 18, end: 19, name: "GarbageCollection" }], + }, + ], + }, + ], +}; diff --git a/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-04.js b/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-04.js new file mode 100644 index 0000000000..b0ba5c89b6 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-04.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the waterfall collapsing logic works properly + * when filtering parents and children. + */ + +add_task(function test() { + const WaterfallUtils = require("devtools/client/performance/modules/logic/waterfall-utils"); + + [ + [["DOMEvent"], gExpectedOutputNoDOMEvent], + [["Javascript"], gExpectedOutputNoJS], + [["DOMEvent", "Javascript"], gExpectedOutputNoDOMEventOrJS], + ].forEach(([filter, expected]) => { + const rootMarkerNode = WaterfallUtils.createParentNode({ name: "(root)" }); + + WaterfallUtils.collapseMarkersIntoNode({ + rootNode: rootMarkerNode, + markersList: gTestMarkers, + filter, + }); + + compare(rootMarkerNode, expected); + }); + + function compare(marker, expected) { + for (const prop in expected) { + if (prop === "submarkers") { + for (let i = 0; i < expected.submarkers.length; i++) { + compare(marker.submarkers[i], expected.submarkers[i]); + } + } else if (prop !== "uid") { + equal(marker[prop], expected[prop], `${expected.name} matches ${prop}`); + } + } + } +}); + +const gTestMarkers = [ + { start: 1, end: 18, name: "DOMEvent" }, + // Test that JS markers can fold in DOM events and have marker children + { start: 2, end: 16, name: "Javascript" }, + // Test all these markers can be children + { start: 3, end: 4, name: "Paint" }, + { start: 5, end: 6, name: "Reflow" }, + { start: 7, end: 8, name: "Styles" }, + { start: 9, end: 9, name: "TimeStamp" }, + { start: 10, end: 11, name: "Parse HTML" }, + { start: 12, end: 13, name: "Parse XML" }, + { start: 14, end: 15, name: "GarbageCollection" }, + // Test that JS markers can be parents without being a child of DOM events + { start: 25, end: 30, name: "Javascript" }, + { start: 26, end: 27, name: "Paint" }, +]; + +const gExpectedOutputNoJS = { + name: "(root)", + submarkers: [ + { + start: 1, + end: 18, + name: "DOMEvent", + submarkers: [ + { start: 3, end: 4, name: "Paint" }, + { start: 5, end: 6, name: "Reflow" }, + { start: 7, end: 8, name: "Styles" }, + { start: 9, end: 9, name: "TimeStamp" }, + { start: 10, end: 11, name: "Parse HTML" }, + { start: 12, end: 13, name: "Parse XML" }, + { start: 14, end: 15, name: "GarbageCollection" }, + ], + }, + { start: 26, end: 27, name: "Paint" }, + ], +}; + +const gExpectedOutputNoDOMEvent = { + name: "(root)", + submarkers: [ + { + start: 2, + end: 16, + name: "Javascript", + submarkers: [ + { start: 3, end: 4, name: "Paint" }, + { start: 5, end: 6, name: "Reflow" }, + { start: 7, end: 8, name: "Styles" }, + { start: 9, end: 9, name: "TimeStamp" }, + { start: 10, end: 11, name: "Parse HTML" }, + { start: 12, end: 13, name: "Parse XML" }, + { start: 14, end: 15, name: "GarbageCollection" }, + ], + }, + { + start: 25, + end: 30, + name: "Javascript", + submarkers: [{ start: 26, end: 27, name: "Paint" }], + }, + ], +}; + +const gExpectedOutputNoDOMEventOrJS = { + name: "(root)", + submarkers: [ + { start: 3, end: 4, name: "Paint" }, + { start: 5, end: 6, name: "Reflow" }, + { start: 7, end: 8, name: "Styles" }, + { start: 9, end: 9, name: "TimeStamp" }, + { start: 10, end: 11, name: "Parse HTML" }, + { start: 12, end: 13, name: "Parse XML" }, + { start: 14, end: 15, name: "GarbageCollection" }, + { start: 26, end: 27, name: "Paint" }, + ], +}; diff --git a/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-05.js b/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-05.js new file mode 100644 index 0000000000..da645dd526 --- /dev/null +++ b/devtools/client/performance/test/xpcshell/test_waterfall-utils-collapse-05.js @@ -0,0 +1,185 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests if the waterfall collapsing logic works properly + * when dealing with OTMT markers. + */ + +add_task(function test() { + const WaterfallUtils = require("devtools/client/performance/modules/logic/waterfall-utils"); + + const rootMarkerNode = WaterfallUtils.createParentNode({ name: "(root)" }); + + WaterfallUtils.collapseMarkersIntoNode({ + rootNode: rootMarkerNode, + markersList: gTestMarkers, + }); + + compare(rootMarkerNode, gExpectedOutput); + + function compare(marker, expected) { + for (const prop in expected) { + if (prop === "submarkers") { + for (let i = 0; i < expected.submarkers.length; i++) { + compare(marker.submarkers[i], expected.submarkers[i]); + } + } else if (prop !== "uid") { + equal(marker[prop], expected[prop], `${expected.name} matches ${prop}`); + } + } + } +}); + +const gTestMarkers = [ + { start: 1, end: 4, name: "A1-mt", processType: 1, isOffMainThread: false }, + // This should collapse only under A1-mt + { start: 2, end: 3, name: "B1", processType: 1, isOffMainThread: false }, + // This should never collapse. + { start: 2, end: 3, name: "C1", processType: 1, isOffMainThread: true }, + + { start: 5, end: 8, name: "A1-otmt", processType: 1, isOffMainThread: true }, + // This should collapse only under A1-mt + { start: 6, end: 7, name: "B2", processType: 1, isOffMainThread: false }, + // This should never collapse. + { start: 6, end: 7, name: "C2", processType: 1, isOffMainThread: true }, + + { start: 9, end: 12, name: "A2-mt", processType: 2, isOffMainThread: false }, + // This should collapse only under A2-mt + { start: 10, end: 11, name: "D1", processType: 2, isOffMainThread: false }, + // This should never collapse. + { start: 10, end: 11, name: "E1", processType: 2, isOffMainThread: true }, + + { + start: 13, + end: 16, + name: "A2-otmt", + processType: 2, + isOffMainThread: true, + }, + // This should collapse only under A2-mt + { start: 14, end: 15, name: "D2", processType: 2, isOffMainThread: false }, + // This should never collapse. + { start: 14, end: 15, name: "E2", processType: 2, isOffMainThread: true }, + + // This should not collapse, because there's no parent in this process. + { start: 14, end: 15, name: "F", processType: 3, isOffMainThread: false }, + + // This should never collapse. + { start: 14, end: 15, name: "G", processType: 3, isOffMainThread: true }, +]; + +const gExpectedOutput = { + name: "(root)", + submarkers: [ + { + start: 1, + end: 4, + name: "A1-mt", + processType: 1, + isOffMainThread: false, + submarkers: [ + { + start: 2, + end: 3, + name: "B1", + processType: 1, + isOffMainThread: false, + }, + ], + }, + { + start: 2, + end: 3, + name: "C1", + processType: 1, + isOffMainThread: true, + }, + { + start: 5, + end: 8, + name: "A1-otmt", + processType: 1, + isOffMainThread: true, + submarkers: [ + { + start: 6, + end: 7, + name: "B2", + processType: 1, + isOffMainThread: false, + }, + ], + }, + { + start: 6, + end: 7, + name: "C2", + processType: 1, + isOffMainThread: true, + }, + { + start: 9, + end: 12, + name: "A2-mt", + processType: 2, + isOffMainThread: false, + submarkers: [ + { + start: 10, + end: 11, + name: "D1", + processType: 2, + isOffMainThread: false, + }, + ], + }, + { + start: 10, + end: 11, + name: "E1", + processType: 2, + isOffMainThread: true, + }, + { + start: 13, + end: 16, + name: "A2-otmt", + processType: 2, + isOffMainThread: true, + submarkers: [ + { + start: 14, + end: 15, + name: "D2", + processType: 2, + isOffMainThread: false, + }, + ], + }, + { + start: 14, + end: 15, + name: "E2", + processType: 2, + isOffMainThread: true, + }, + { + start: 14, + end: 15, + name: "F", + processType: 3, + isOffMainThread: false, + submarkers: [], + }, + { + start: 14, + end: 15, + name: "G", + processType: 3, + isOffMainThread: true, + submarkers: [], + }, + ], +}; diff --git a/devtools/client/performance/test/xpcshell/xpcshell.ini b/devtools/client/performance/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..3860d0308f --- /dev/null +++ b/devtools/client/performance/test/xpcshell/xpcshell.ini @@ -0,0 +1,35 @@ +[DEFAULT] +tags = devtools +head = head.js +firefox-appdir = browser +skip-if = toolkit == 'android' + +[test_frame-utils-01.js] +[test_frame-utils-02.js] +[test_marker-blueprint.js] +[test_marker-utils.js] +[test_profiler-categories.js] +[test_jit-graph-data.js] +[test_jit-model-01.js] +[test_jit-model-02.js] +[test_perf-utils-allocations-to-samples.js] +[test_tree-model-01.js] +[test_tree-model-02.js] +[test_tree-model-03.js] +[test_tree-model-04.js] +[test_tree-model-05.js] +[test_tree-model-06.js] +[test_tree-model-07.js] +[test_tree-model-08.js] +[test_tree-model-09.js] +[test_tree-model-10.js] +[test_tree-model-11.js] +[test_tree-model-12.js] +[test_tree-model-13.js] +[test_tree-model-allocations-01.js] +[test_tree-model-allocations-02.js] +[test_waterfall-utils-collapse-01.js] +[test_waterfall-utils-collapse-02.js] +[test_waterfall-utils-collapse-03.js] +[test_waterfall-utils-collapse-04.js] +[test_waterfall-utils-collapse-05.js] diff --git a/devtools/client/performance/views/details-abstract-subview.js b/devtools/client/performance/views/details-abstract-subview.js new file mode 100644 index 0000000000..074d003229 --- /dev/null +++ b/devtools/client/performance/views/details-abstract-subview.js @@ -0,0 +1,232 @@ +/* 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/. */ +/* globals PerformanceController, OverviewView, DetailsView */ +"use strict"; + +const { + setNamedTimeout, + clearNamedTimeout, +} = require("devtools/client/shared/widgets/view-helpers"); +const EVENTS = require("devtools/client/performance/events"); + +/** + * A base class from which all detail views inherit. + */ +const DetailsSubview = { + /** + * Sets up the view with event binding. + */ + initialize: function() { + this._onRecordingStoppedOrSelected = this._onRecordingStoppedOrSelected.bind( + this + ); + this._onOverviewRangeChange = this._onOverviewRangeChange.bind(this); + this._onDetailsViewSelected = this._onDetailsViewSelected.bind(this); + this._onPrefChanged = this._onPrefChanged.bind(this); + + PerformanceController.on( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStoppedOrSelected + ); + PerformanceController.on( + EVENTS.RECORDING_SELECTED, + this._onRecordingStoppedOrSelected + ); + PerformanceController.on(EVENTS.PREF_CHANGED, this._onPrefChanged); + OverviewView.on( + EVENTS.UI_OVERVIEW_RANGE_SELECTED, + this._onOverviewRangeChange + ); + DetailsView.on( + EVENTS.UI_DETAILS_VIEW_SELECTED, + this._onDetailsViewSelected + ); + + const self = this; + const originalRenderFn = this.render; + const afterRenderFn = () => { + this._wasRendered = true; + }; + + this.render = async function(...args) { + const maybeRetval = await originalRenderFn.apply(self, args); + afterRenderFn(); + return maybeRetval; + }; + }, + + /** + * Unbinds events. + */ + destroy: function() { + clearNamedTimeout("range-change-debounce"); + + PerformanceController.off( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStoppedOrSelected + ); + PerformanceController.off( + EVENTS.RECORDING_SELECTED, + this._onRecordingStoppedOrSelected + ); + PerformanceController.off(EVENTS.PREF_CHANGED, this._onPrefChanged); + OverviewView.off( + EVENTS.UI_OVERVIEW_RANGE_SELECTED, + this._onOverviewRangeChange + ); + DetailsView.off( + EVENTS.UI_DETAILS_VIEW_SELECTED, + this._onDetailsViewSelected + ); + }, + + /** + * Returns true if this view was rendered at least once. + */ + get wasRenderedAtLeastOnce() { + return !!this._wasRendered; + }, + + /** + * Amount of time (in milliseconds) to wait until this view gets updated, + * when the range is changed in the overview. + */ + rangeChangeDebounceTime: 0, + + /** + * When the overview range changes, all details views will require a + * rerendering at a later point, determined by `shouldUpdateWhenShown` and + * `canUpdateWhileHidden` and whether or not its the current view. + * Set `requiresUpdateOnRangeChange` to false to not invalidate the view + * when the range changes. + */ + requiresUpdateOnRangeChange: true, + + /** + * Flag specifying if this view should be updated when selected. This will + * be set to true, for example, when the range changes in the overview and + * this view is not currently visible. + */ + shouldUpdateWhenShown: false, + + /** + * Flag specifying if this view may get updated even when it's not selected. + * Should only be used in tests. + */ + canUpdateWhileHidden: false, + + /** + * An array of preferences under `devtools.performance.ui.` that the view should + * rerender and callback `this._onRerenderPrefChanged` upon change. + */ + rerenderPrefs: [], + + /** + * An array of preferences under `devtools.performance.` that the view should + * observe and callback `this._onObservedPrefChange` upon change. + */ + observedPrefs: [], + + /** + * Flag specifying if this view should update while the overview selection + * area is actively being dragged by the mouse. + */ + shouldUpdateWhileMouseIsActive: false, + + /** + * Called when recording stops or is selected. + */ + _onRecordingStoppedOrSelected: function(state, recording) { + if (typeof state !== "string") { + recording = state; + } + if (arguments.length === 3 && state !== "recording-stopped") { + return; + } + + if (!recording || !recording.isCompleted()) { + return; + } + if (DetailsView.isViewSelected(this) || this.canUpdateWhileHidden) { + this.render(OverviewView.getTimeInterval()); + } else { + this.shouldUpdateWhenShown = true; + } + }, + + /** + * Fired when a range is selected or cleared in the OverviewView. + */ + _onOverviewRangeChange: function(interval) { + if (!this.requiresUpdateOnRangeChange) { + return; + } + if (DetailsView.isViewSelected(this)) { + const debounced = () => { + if ( + !this.shouldUpdateWhileMouseIsActive && + OverviewView.isMouseActive + ) { + // Don't render yet, while the selection is still being dragged. + setNamedTimeout( + "range-change-debounce", + this.rangeChangeDebounceTime, + debounced + ); + } else { + this.render(interval); + } + }; + setNamedTimeout( + "range-change-debounce", + this.rangeChangeDebounceTime, + debounced + ); + } else { + this.shouldUpdateWhenShown = true; + } + }, + + /** + * Fired when a view is selected in the DetailsView. + */ + _onDetailsViewSelected: function() { + if (DetailsView.isViewSelected(this) && this.shouldUpdateWhenShown) { + this.render(OverviewView.getTimeInterval()); + this.shouldUpdateWhenShown = false; + } + }, + + /** + * Fired when a preference in `devtools.performance.ui.` is changed. + */ + _onPrefChanged: function(prefName, prefValue) { + if (~this.observedPrefs.indexOf(prefName) && this._onObservedPrefChange) { + this._onObservedPrefChange(prefName); + } + + // All detail views require a recording to be complete, so do not + // attempt to render if recording is in progress or does not exist. + const recording = PerformanceController.getCurrentRecording(); + if (!recording || !recording.isCompleted()) { + return; + } + + if (!~this.rerenderPrefs.indexOf(prefName)) { + return; + } + + if (this._onRerenderPrefChanged) { + this._onRerenderPrefChanged(prefName); + } + + if (DetailsView.isViewSelected(this) || this.canUpdateWhileHidden) { + this.render(OverviewView.getTimeInterval()); + } else { + this.shouldUpdateWhenShown = true; + } + }, +}; + +exports.DetailsSubview = DetailsSubview; diff --git a/devtools/client/performance/views/details-js-call-tree.js b/devtools/client/performance/views/details-js-call-tree.js new file mode 100644 index 0000000000..7ef2d05483 --- /dev/null +++ b/devtools/client/performance/views/details-js-call-tree.js @@ -0,0 +1,234 @@ +/* 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/. */ +/* globals $, PerformanceController */ +"use strict"; + +const { extend } = require("devtools/shared/extend"); + +const React = require("devtools/client/shared/vendor/react"); +const ReactDOM = require("devtools/client/shared/vendor/react-dom"); + +const EVENTS = require("devtools/client/performance/events"); +const { + CallView, +} = require("devtools/client/performance/modules/widgets/tree-view"); +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); +const { + DetailsSubview, +} = require("devtools/client/performance/views/details-abstract-subview"); + +const JITOptimizationsView = React.createFactory( + require("devtools/client/performance/components/JITOptimizations") +); + +const EventEmitter = require("devtools/shared/event-emitter"); + +/** + * CallTree view containing profiler call tree, controlled by DetailsView. + */ +const JsCallTreeView = extend(DetailsSubview, { + rerenderPrefs: [ + "invert-call-tree", + "show-platform-data", + "flatten-tree-recursion", + "show-jit-optimizations", + ], + + // Units are in milliseconds. + rangeChangeDebounceTime: 75, + + /** + * Sets up the view with event binding. + */ + initialize: function() { + DetailsSubview.initialize.call(this); + + this._onLink = this._onLink.bind(this); + this._onFocus = this._onFocus.bind(this); + + this.container = $("#js-calltree-view .call-tree-cells-container"); + + this.optimizationsElement = $("#jit-optimizations-view"); + }, + + /** + * Unbinds events. + */ + destroy: function() { + ReactDOM.unmountComponentAtNode(this.optimizationsElement); + this.optimizationsElement = null; + this.container = null; + this.threadNode = null; + DetailsSubview.destroy.call(this); + }, + + /** + * Method for handling all the set up for rendering a new call tree. + * + * @param object interval [optional] + * The { startTime, endTime }, in milliseconds. + */ + render: function(interval = {}) { + const recording = PerformanceController.getCurrentRecording(); + const profile = recording.getProfile(); + const showOptimizations = PerformanceController.getOption( + "show-jit-optimizations" + ); + + const options = { + contentOnly: !PerformanceController.getOption("show-platform-data"), + invertTree: PerformanceController.getOption("invert-call-tree"), + flattenRecursion: PerformanceController.getOption( + "flatten-tree-recursion" + ), + showOptimizationHint: showOptimizations, + }; + const threadNode = (this.threadNode = this._prepareCallTree( + profile, + interval, + options + )); + this._populateCallTree(threadNode, options); + + // For better or worse, re-rendering loses frame selection, + // so we should always hide opts on rerender + this.hideOptimizations(); + + this.emit(EVENTS.UI_JS_CALL_TREE_RENDERED); + }, + + showOptimizations: function() { + this.optimizationsElement.classList.remove("hidden"); + }, + + hideOptimizations: function() { + this.optimizationsElement.classList.add("hidden"); + }, + + _onFocus: function(treeItem) { + const showOptimizations = PerformanceController.getOption( + "show-jit-optimizations" + ); + const frameNode = treeItem.frame; + const optimizationSites = + frameNode && frameNode.hasOptimizations() + ? frameNode.getOptimizations().optimizationSites + : []; + + if (!showOptimizations || !frameNode || optimizationSites.length === 0) { + this.hideOptimizations(); + this.emit("focus", treeItem); + return; + } + + this.showOptimizations(); + + const frameData = frameNode.getInfo(); + const optimizations = JITOptimizationsView({ + frameData, + optimizationSites, + onViewSourceInDebugger: ({ url, line, column }) => { + PerformanceController.viewSourceInDebugger(url, line, column).then( + success => { + if (success) { + this.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER); + } else { + this.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER); + } + } + ); + }, + }); + + ReactDOM.render(optimizations, this.optimizationsElement); + + this.emit("focus", treeItem); + }, + + /** + * Fired on the "link" event for the call tree in this container. + */ + _onLink: function(treeItem) { + const { url, line, column } = treeItem.frame.getInfo(); + PerformanceController.viewSourceInDebugger(url, line, column).then( + success => { + if (success) { + this.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER); + } else { + this.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER); + } + } + ); + }, + + /** + * Called when the recording is stopped and prepares data to + * populate the call tree. + */ + _prepareCallTree: function(profile, { startTime, endTime }, options) { + const thread = profile.threads[0]; + const { contentOnly, invertTree, flattenRecursion } = options; + const threadNode = new ThreadNode(thread, { + startTime, + endTime, + contentOnly, + invertTree, + flattenRecursion, + }); + + // Real profiles from nsProfiler (i.e. not synthesized from allocation + // logs) always have a (root) node. Go down one level in the uninverted + // view to avoid displaying both the synthesized root node and the (root) + // node from the profiler. + if (!invertTree) { + threadNode.calls = threadNode.calls[0].calls; + } + + return threadNode; + }, + + /** + * Renders the call tree. + */ + _populateCallTree: function(frameNode, options = {}) { + // If we have an empty profile (no samples), then don't invert the tree, as + // it would hide the root node and a completely blank call tree space can be + // mis-interpreted as an error. + const inverted = options.invertTree && frameNode.samples > 0; + + const root = new CallView({ + frame: frameNode, + inverted: inverted, + // The synthesized root node is hidden in inverted call trees. + hidden: inverted, + // Call trees should only auto-expand when not inverted. Passing undefined + // will default to the CALL_TREE_AUTO_EXPAND depth. + autoExpandDepth: inverted ? 0 : undefined, + showOptimizationHint: options.showOptimizationHint, + }); + + // Bind events. + root.on("link", this._onLink); + root.on("focus", this._onFocus); + + // Clear out other call trees. + this.container.innerHTML = ""; + root.attachTo(this.container); + + // When platform data isn't shown, hide the cateogry labels, since they're + // only available for C++ frames. Pass *false* to make them invisible. + root.toggleCategories(!options.contentOnly); + + // Return the CallView for tests + return root; + }, + + toString: () => "[object JsCallTreeView]", +}); + +EventEmitter.decorate(JsCallTreeView); + +exports.JsCallTreeView = JsCallTreeView; diff --git a/devtools/client/performance/views/details-js-flamegraph.js b/devtools/client/performance/views/details-js-flamegraph.js new file mode 100644 index 0000000000..bacdb018f5 --- /dev/null +++ b/devtools/client/performance/views/details-js-flamegraph.js @@ -0,0 +1,143 @@ +/* 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/. */ +/* globals $, PerformanceController, OverviewView */ +"use strict"; + +const { extend } = require("devtools/shared/extend"); + +const EVENTS = require("devtools/client/performance/events"); +const { L10N } = require("devtools/client/performance/modules/global"); +const { + DetailsSubview, +} = require("devtools/client/performance/views/details-abstract-subview"); + +const { + FlameGraph, + FlameGraphUtils, +} = require("devtools/client/shared/widgets/FlameGraph"); + +const EventEmitter = require("devtools/shared/event-emitter"); + +/** + * FlameGraph view containing a pyramid-like visualization of a profile, + * controlled by DetailsView. + */ +const JsFlameGraphView = extend(DetailsSubview, { + shouldUpdateWhileMouseIsActive: true, + + rerenderPrefs: [ + "invert-flame-graph", + "flatten-tree-recursion", + "show-platform-data", + "show-idle-blocks", + ], + + /** + * Sets up the view with event binding. + */ + async initialize() { + DetailsSubview.initialize.call(this); + + this.graph = new FlameGraph($("#js-flamegraph-view")); + this.graph.timelineTickUnits = L10N.getStr("graphs.ms"); + this.graph.setTheme(PerformanceController.getTheme()); + await this.graph.ready(); + + this._onRangeChangeInGraph = this._onRangeChangeInGraph.bind(this); + this._onThemeChanged = this._onThemeChanged.bind(this); + + PerformanceController.on(EVENTS.THEME_CHANGED, this._onThemeChanged); + this.graph.on("selecting", this._onRangeChangeInGraph); + }, + + /** + * Unbinds events. + */ + async destroy() { + DetailsSubview.destroy.call(this); + + PerformanceController.off(EVENTS.THEME_CHANGED, this._onThemeChanged); + this.graph.off("selecting", this._onRangeChangeInGraph); + + await this.graph.destroy(); + }, + + /** + * Method for handling all the set up for rendering a new flamegraph. + * + * @param object interval [optional] + * The { startTime, endTime }, in milliseconds. + */ + render: function(interval = {}) { + const recording = PerformanceController.getCurrentRecording(); + const duration = recording.getDuration(); + const profile = recording.getProfile(); + const thread = profile.threads[0]; + + const data = FlameGraphUtils.createFlameGraphDataFromThread(thread, { + invertTree: PerformanceController.getOption("invert-flame-graph"), + flattenRecursion: PerformanceController.getOption( + "flatten-tree-recursion" + ), + contentOnly: !PerformanceController.getOption("show-platform-data"), + showIdleBlocks: + PerformanceController.getOption("show-idle-blocks") && + L10N.getStr("table.idle"), + }); + + this.graph.setData({ + data, + bounds: { + startTime: 0, + endTime: duration, + }, + visible: { + startTime: interval.startTime || 0, + endTime: interval.endTime || duration, + }, + }); + + this.graph.focus(); + + this.emit(EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + }, + + /** + * Fired when a range is selected or cleared in the FlameGraph. + */ + _onRangeChangeInGraph: function() { + const interval = this.graph.getViewRange(); + + // Squelch rerendering this view when we update the range here + // to avoid recursion, as our FlameGraph handles rerendering itself + // when originating from within the graph. + this.requiresUpdateOnRangeChange = false; + OverviewView.setTimeInterval(interval); + this.requiresUpdateOnRangeChange = true; + }, + + /** + * Called whenever a pref is changed and this view needs to be rerendered. + */ + _onRerenderPrefChanged: function() { + const recording = PerformanceController.getCurrentRecording(); + const profile = recording.getProfile(); + const thread = profile.threads[0]; + FlameGraphUtils.removeFromCache(thread); + }, + + /** + * Called when `devtools.theme` changes. + */ + _onThemeChanged: function(theme) { + this.graph.setTheme(theme); + this.graph.refresh({ force: true }); + }, + + toString: () => "[object JsFlameGraphView]", +}); + +EventEmitter.decorate(JsFlameGraphView); + +exports.JsFlameGraphView = JsFlameGraphView; diff --git a/devtools/client/performance/views/details-memory-call-tree.js b/devtools/client/performance/views/details-memory-call-tree.js new file mode 100644 index 0000000000..17fcfcad0c --- /dev/null +++ b/devtools/client/performance/views/details-memory-call-tree.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/. */ +/* globals $, PerformanceController */ +"use strict"; + +const { extend } = require("devtools/shared/extend"); + +const EVENTS = require("devtools/client/performance/events"); +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); +const { + CallView, +} = require("devtools/client/performance/modules/widgets/tree-view"); +const { + DetailsSubview, +} = require("devtools/client/performance/views/details-abstract-subview"); + +const RecordingUtils = require("devtools/shared/performance/recording-utils"); +const EventEmitter = require("devtools/shared/event-emitter"); + +/** + * CallTree view containing memory allocation sites, controlled by DetailsView. + */ +const MemoryCallTreeView = extend(DetailsSubview, { + rerenderPrefs: ["invert-call-tree"], + + // Units are in milliseconds. + rangeChangeDebounceTime: 100, + + /** + * Sets up the view with event binding. + */ + initialize: function() { + DetailsSubview.initialize.call(this); + + this._onLink = this._onLink.bind(this); + + this.container = $("#memory-calltree-view > .call-tree-cells-container"); + }, + + /** + * Unbinds events. + */ + destroy: function() { + DetailsSubview.destroy.call(this); + }, + + /** + * Method for handling all the set up for rendering a new call tree. + * + * @param object interval [optional] + * The { startTime, endTime }, in milliseconds. + */ + render: function(interval = {}) { + const options = { + invertTree: PerformanceController.getOption("invert-call-tree"), + }; + const recording = PerformanceController.getCurrentRecording(); + const allocations = recording.getAllocations(); + const threadNode = this._prepareCallTree(allocations, interval, options); + this._populateCallTree(threadNode, options); + this.emit(EVENTS.UI_MEMORY_CALL_TREE_RENDERED); + }, + + /** + * Fired on the "link" event for the call tree in this container. + */ + _onLink: function(treeItem) { + const { url, line, column } = treeItem.frame.getInfo(); + PerformanceController.viewSourceInDebugger(url, line, column).then( + success => { + if (success) { + this.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER); + } else { + this.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER); + } + } + ); + }, + + /** + * Called when the recording is stopped and prepares data to + * populate the call tree. + */ + _prepareCallTree: function(allocations, { startTime, endTime }, options) { + const thread = RecordingUtils.getProfileThreadFromAllocations(allocations); + const { invertTree } = options; + + return new ThreadNode(thread, { startTime, endTime, invertTree }); + }, + + /** + * Renders the call tree. + */ + _populateCallTree: function(frameNode, options = {}) { + // If we have an empty profile (no samples), then don't invert the tree, as + // it would hide the root node and a completely blank call tree space can be + // mis-interpreted as an error. + const inverted = options.invertTree && frameNode.samples > 0; + + const root = new CallView({ + frame: frameNode, + inverted: inverted, + // Root nodes are hidden in inverted call trees. + hidden: inverted, + // Call trees should only auto-expand when not inverted. Passing undefined + // will default to the CALL_TREE_AUTO_EXPAND depth. + autoExpandDepth: inverted ? 0 : undefined, + // Some cells like the time duration and cost percentage don't make sense + // for a memory allocations call tree. + visibleCells: { + selfCount: true, + count: true, + selfSize: true, + size: true, + selfCountPercentage: true, + countPercentage: true, + selfSizePercentage: true, + sizePercentage: true, + function: true, + }, + }); + + // Bind events. + root.on("link", this._onLink); + + // Pipe "focus" events to the view, mostly for tests + root.on("focus", () => this.emit("focus")); + + // Clear out other call trees. + this.container.innerHTML = ""; + root.attachTo(this.container); + + // Memory allocation samples don't contain cateogry labels. + root.toggleCategories(false); + }, + + toString: () => "[object MemoryCallTreeView]", +}); + +EventEmitter.decorate(MemoryCallTreeView); + +exports.MemoryCallTreeView = MemoryCallTreeView; diff --git a/devtools/client/performance/views/details-memory-flamegraph.js b/devtools/client/performance/views/details-memory-flamegraph.js new file mode 100644 index 0000000000..e8f1f51fd1 --- /dev/null +++ b/devtools/client/performance/views/details-memory-flamegraph.js @@ -0,0 +1,138 @@ +/* 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/. */ +/* globals $, PerformanceController, OverviewView */ +"use strict"; + +const { + FlameGraph, + FlameGraphUtils, +} = require("devtools/client/shared/widgets/FlameGraph"); +const { extend } = require("devtools/shared/extend"); +const RecordingUtils = require("devtools/shared/performance/recording-utils"); +const EventEmitter = require("devtools/shared/event-emitter"); + +const EVENTS = require("devtools/client/performance/events"); +const { + DetailsSubview, +} = require("devtools/client/performance/views/details-abstract-subview"); +const { L10N } = require("devtools/client/performance/modules/global"); + +/** + * FlameGraph view containing a pyramid-like visualization of memory allocation + * sites, controlled by DetailsView. + */ +const MemoryFlameGraphView = extend(DetailsSubview, { + shouldUpdateWhileMouseIsActive: true, + + rerenderPrefs: [ + "invert-flame-graph", + "flatten-tree-recursion", + "show-idle-blocks", + ], + + /** + * Sets up the view with event binding. + */ + async initialize() { + DetailsSubview.initialize.call(this); + + this.graph = new FlameGraph($("#memory-flamegraph-view")); + this.graph.timelineTickUnits = L10N.getStr("graphs.ms"); + this.graph.setTheme(PerformanceController.getTheme()); + await this.graph.ready(); + + this._onRangeChangeInGraph = this._onRangeChangeInGraph.bind(this); + this._onThemeChanged = this._onThemeChanged.bind(this); + + PerformanceController.on(EVENTS.THEME_CHANGED, this._onThemeChanged); + this.graph.on("selecting", this._onRangeChangeInGraph); + }, + + /** + * Unbinds events. + */ + async destroy() { + DetailsSubview.destroy.call(this); + + PerformanceController.off(EVENTS.THEME_CHANGED, this._onThemeChanged); + this.graph.off("selecting", this._onRangeChangeInGraph); + + await this.graph.destroy(); + }, + + /** + * Method for handling all the set up for rendering a new flamegraph. + * + * @param object interval [optional] + * The { startTime, endTime }, in milliseconds. + */ + render: function(interval = {}) { + const recording = PerformanceController.getCurrentRecording(); + const duration = recording.getDuration(); + const allocations = recording.getAllocations(); + + const thread = RecordingUtils.getProfileThreadFromAllocations(allocations); + const data = FlameGraphUtils.createFlameGraphDataFromThread(thread, { + invertStack: PerformanceController.getOption("invert-flame-graph"), + flattenRecursion: PerformanceController.getOption( + "flatten-tree-recursion" + ), + showIdleBlocks: + PerformanceController.getOption("show-idle-blocks") && + L10N.getStr("table.idle"), + }); + + this.graph.setData({ + data, + bounds: { + startTime: 0, + endTime: duration, + }, + visible: { + startTime: interval.startTime || 0, + endTime: interval.endTime || duration, + }, + }); + + this.emit(EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED); + }, + + /** + * Fired when a range is selected or cleared in the FlameGraph. + */ + _onRangeChangeInGraph: function() { + const interval = this.graph.getViewRange(); + + // Squelch rerendering this view when we update the range here + // to avoid recursion, as our FlameGraph handles rerendering itself + // when originating from within the graph. + this.requiresUpdateOnRangeChange = false; + OverviewView.setTimeInterval(interval); + this.requiresUpdateOnRangeChange = true; + }, + + /** + * Called whenever a pref is changed and this view needs to be rerendered. + */ + _onRerenderPrefChanged: function() { + const recording = PerformanceController.getCurrentRecording(); + const allocations = recording.getAllocations(); + const thread = RecordingUtils.getProfileThreadFromAllocations(allocations); + FlameGraphUtils.removeFromCache(thread); + }, + + /** + * Called when `devtools.theme` changes. + */ + _onThemeChanged: function(theme) { + this.graph.setTheme(theme); + this.graph.refresh({ force: true }); + }, + + toString: () => "[object MemoryFlameGraphView]", +}); + +EventEmitter.decorate(MemoryFlameGraphView); + +exports.MemoryFlameGraphView = MemoryFlameGraphView; diff --git a/devtools/client/performance/views/details-waterfall.js b/devtools/client/performance/views/details-waterfall.js new file mode 100644 index 0000000000..975aa43874 --- /dev/null +++ b/devtools/client/performance/views/details-waterfall.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/. */ +/* globals $, PerformanceController, OverviewView, DetailsView */ +"use strict"; + +const { extend } = require("devtools/shared/extend"); +const EventEmitter = require("devtools/shared/event-emitter"); +const { + setNamedTimeout, + clearNamedTimeout, +} = require("devtools/client/shared/widgets/view-helpers"); + +const React = require("devtools/client/shared/vendor/react"); +const ReactDOM = require("devtools/client/shared/vendor/react-dom"); + +const EVENTS = require("devtools/client/performance/events"); +const WaterfallUtils = require("devtools/client/performance/modules/logic/waterfall-utils"); +const { + TickUtils, +} = require("devtools/client/performance/modules/waterfall-ticks"); +const { + MarkerDetails, +} = require("devtools/client/performance/modules/widgets/marker-details"); +const { + DetailsSubview, +} = require("devtools/client/performance/views/details-abstract-subview"); + +const Waterfall = React.createFactory( + require("devtools/client/performance/components/Waterfall") +); + +const MARKER_DETAILS_WIDTH = 200; +// Units are in milliseconds. +const WATERFALL_RESIZE_EVENTS_DRAIN = 100; + +/** + * Waterfall view containing the timeline markers, controlled by DetailsView. + */ +const WaterfallView = extend(DetailsSubview, { + // Smallest unit of time between two markers. Larger by 10x^3 than Number.EPSILON. + MARKER_EPSILON: 0.000000000001, + // px + WATERFALL_MARKER_SIDEBAR_WIDTH: 175, + // px + WATERFALL_MARKER_SIDEBAR_SAFE_BOUNDS: 20, + + observedPrefs: ["hidden-markers"], + + rerenderPrefs: ["hidden-markers"], + + // Units are in milliseconds. + rangeChangeDebounceTime: 75, + + /** + * Sets up the view with event binding. + */ + initialize: function() { + DetailsSubview.initialize.call(this); + + this._cache = new WeakMap(); + + this._onMarkerSelected = this._onMarkerSelected.bind(this); + this._onResize = this._onResize.bind(this); + this._onViewSource = this._onViewSource.bind(this); + this._onShowAllocations = this._onShowAllocations.bind(this); + this._hiddenMarkers = PerformanceController.getPref("hidden-markers"); + + this.treeContainer = $("#waterfall-tree"); + this.detailsContainer = $("#waterfall-details"); + this.detailsSplitter = $("#waterfall-view > splitter"); + + this.details = new MarkerDetails( + $("#waterfall-details"), + $("#waterfall-view > splitter") + ); + this.details.hidden = true; + + this.details.on("resize", this._onResize); + this.details.on("view-source", this._onViewSource); + this.details.on("show-allocations", this._onShowAllocations); + window.addEventListener("resize", this._onResize); + + // TODO bug 1167093 save the previously set width, and ensure minimum width + this.details.width = MARKER_DETAILS_WIDTH; + }, + + /** + * Unbinds events. + */ + destroy: function() { + DetailsSubview.destroy.call(this); + + clearNamedTimeout("waterfall-resize"); + + this._cache = null; + + this.details.off("resize", this._onResize); + this.details.off("view-source", this._onViewSource); + this.details.off("show-allocations", this._onShowAllocations); + window.removeEventListener("resize", this._onResize); + + ReactDOM.unmountComponentAtNode(this.treeContainer); + }, + + /** + * Method for handling all the set up for rendering a new waterfall. + * + * @param object interval [optional] + * The { startTime, endTime }, in milliseconds. + */ + render: function(interval = {}) { + const recording = PerformanceController.getCurrentRecording(); + if (recording.isRecording()) { + return; + } + const startTime = interval.startTime || 0; + const endTime = interval.endTime || recording.getDuration(); + const markers = recording.getMarkers(); + const rootMarkerNode = this._prepareWaterfallTree(markers); + + this._populateWaterfallTree(rootMarkerNode, { startTime, endTime }); + this.emit(EVENTS.UI_WATERFALL_RENDERED); + }, + + /** + * Called when a marker is selected in the waterfall view, + * updating the markers detail view. + */ + _onMarkerSelected: function(event, marker) { + const recording = PerformanceController.getCurrentRecording(); + const frames = recording.getFrames(); + const allocations = recording.getConfiguration().withAllocations; + + if (event === "selected") { + this.details.render({ marker, frames, allocations }); + this.details.hidden = false; + } + if (event === "unselected") { + this.details.empty(); + } + }, + + /** + * Called when the marker details view is resized. + */ + _onResize: function() { + setNamedTimeout("waterfall-resize", WATERFALL_RESIZE_EVENTS_DRAIN, () => { + this.render(OverviewView.getTimeInterval()); + }); + }, + + /** + * Called whenever an observed pref is changed. + */ + _onObservedPrefChange: function(prefName) { + this._hiddenMarkers = PerformanceController.getPref("hidden-markers"); + + // Clear the cache as we'll need to recompute the collapsed + // marker model + this._cache = new WeakMap(); + }, + + /** + * Called when MarkerDetails view emits an event to view source. + */ + _onViewSource: function(data) { + PerformanceController.viewSourceInDebugger( + data.url, + data.line, + data.column + ); + }, + + /** + * Called when MarkerDetails view emits an event to snap to allocations. + */ + _onShowAllocations: function(data) { + let { endTime } = data; + let startTime = 0; + const recording = PerformanceController.getCurrentRecording(); + const markers = recording.getMarkers(); + + let lastGCMarkerFromPreviousCycle = null; + let lastGCMarker = null; + // Iterate over markers looking for the most recent GC marker + // from the cycle before the marker's whose allocations we're interested in. + for (const marker of markers) { + // We found the marker whose allocations we're tracking; abort + if (marker.start === endTime) { + break; + } + + if (marker.name === "GarbageCollection") { + if (lastGCMarker && lastGCMarker.cycle !== marker.cycle) { + lastGCMarkerFromPreviousCycle = lastGCMarker; + } + lastGCMarker = marker; + } + } + + if (lastGCMarkerFromPreviousCycle) { + startTime = lastGCMarkerFromPreviousCycle.end; + } + + // Adjust times so we don't include the range of these markers themselves. + endTime -= this.MARKER_EPSILON; + startTime += startTime !== 0 ? this.MARKER_EPSILON : 0; + + OverviewView.setTimeInterval({ startTime, endTime }); + DetailsView.selectView("memory-calltree"); + }, + + /** + * Called when the recording is stopped and prepares data to + * populate the waterfall tree. + */ + _prepareWaterfallTree: function(markers) { + const cached = this._cache.get(markers); + if (cached) { + return cached; + } + + const rootMarkerNode = WaterfallUtils.createParentNode({ name: "(root)" }); + + WaterfallUtils.collapseMarkersIntoNode({ + rootNode: rootMarkerNode, + markersList: markers, + filter: this._hiddenMarkers, + }); + + this._cache.set(markers, rootMarkerNode); + return rootMarkerNode; + }, + + /** + * Calculates the available width for the waterfall. + * This should be invoked every time the container node is resized. + */ + _recalculateBounds: function() { + this.waterfallWidth = + this.treeContainer.clientWidth - + this.WATERFALL_MARKER_SIDEBAR_WIDTH - + this.WATERFALL_MARKER_SIDEBAR_SAFE_BOUNDS; + }, + + /** + * Renders the waterfall tree. + */ + _populateWaterfallTree: function(rootMarkerNode, interval) { + this._recalculateBounds(); + + const doc = this.treeContainer.ownerDocument; + const startTime = interval.startTime | 0; + const endTime = interval.endTime | 0; + const dataScale = this.waterfallWidth / (endTime - startTime); + + this.canvas = TickUtils.drawWaterfallBackground( + doc, + dataScale, + this.waterfallWidth + ); + + const treeView = Waterfall({ + marker: rootMarkerNode, + startTime, + endTime, + dataScale, + sidebarWidth: this.WATERFALL_MARKER_SIDEBAR_WIDTH, + waterfallWidth: this.waterfallWidth, + onFocus: node => this._onMarkerSelected("selected", node), + }); + + ReactDOM.render(treeView, this.treeContainer); + }, + + toString: () => "[object WaterfallView]", +}); + +EventEmitter.decorate(WaterfallView); + +exports.WaterfallView = WaterfallView; diff --git a/devtools/client/performance/views/details.js b/devtools/client/performance/views/details.js new file mode 100644 index 0000000000..67c97d6bb5 --- /dev/null +++ b/devtools/client/performance/views/details.js @@ -0,0 +1,295 @@ +/* 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/. */ +/* globals $, $$, PerformanceController */ + +"use strict"; + +const EVENTS = require("devtools/client/performance/events"); + +const { + WaterfallView, +} = require("devtools/client/performance/views/details-waterfall"); +const { + JsCallTreeView, +} = require("devtools/client/performance/views/details-js-call-tree"); +const { + JsFlameGraphView, +} = require("devtools/client/performance/views/details-js-flamegraph"); +const { + MemoryCallTreeView, +} = require("devtools/client/performance/views/details-memory-call-tree"); +const { + MemoryFlameGraphView, +} = require("devtools/client/performance/views/details-memory-flamegraph"); + +const EventEmitter = require("devtools/shared/event-emitter"); + +/** + * Details view containing call trees, flamegraphs and markers waterfall. + * Manages subviews and toggles visibility between them. + */ +const DetailsView = { + /** + * Name to (node id, view object, actor requirements, pref killswitch) + * mapping of subviews. + */ + components: { + waterfall: { + id: "waterfall-view", + view: WaterfallView, + features: ["withMarkers"], + }, + "js-calltree": { + id: "js-profile-view", + view: JsCallTreeView, + }, + "js-flamegraph": { + id: "js-flamegraph-view", + view: JsFlameGraphView, + }, + "memory-calltree": { + id: "memory-calltree-view", + view: MemoryCallTreeView, + features: ["withAllocations"], + }, + "memory-flamegraph": { + id: "memory-flamegraph-view", + view: MemoryFlameGraphView, + features: ["withAllocations"], + prefs: ["enable-memory-flame"], + }, + }, + + /** + * Sets up the view with event binding, initializes subviews. + */ + async initialize() { + this.el = $("#details-pane"); + this.toolbar = $("#performance-toolbar-controls-detail-views"); + + this._onViewToggle = this._onViewToggle.bind(this); + this._onRecordingStoppedOrSelected = this._onRecordingStoppedOrSelected.bind( + this + ); + this.setAvailableViews = this.setAvailableViews.bind(this); + + for (const button of $$("toolbarbutton[data-view]", this.toolbar)) { + button.addEventListener("command", this._onViewToggle); + } + + await this.setAvailableViews(); + + PerformanceController.on( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStoppedOrSelected + ); + PerformanceController.on( + EVENTS.RECORDING_SELECTED, + this._onRecordingStoppedOrSelected + ); + PerformanceController.on(EVENTS.PREF_CHANGED, this.setAvailableViews); + }, + + /** + * Unbinds events, destroys subviews. + */ + async destroy() { + for (const button of $$("toolbarbutton[data-view]", this.toolbar)) { + button.removeEventListener("command", this._onViewToggle); + } + + for (const component of Object.values(this.components)) { + component.initialized && (await component.view.destroy()); + } + + PerformanceController.off( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStoppedOrSelected + ); + PerformanceController.off( + EVENTS.RECORDING_SELECTED, + this._onRecordingStoppedOrSelected + ); + PerformanceController.off(EVENTS.PREF_CHANGED, this.setAvailableViews); + }, + + /** + * Sets the possible views based off of recording features and server actor support + * by hiding/showing the buttons that select them and going to default view + * if currently selected. Called when a preference changes in + * `devtools.performance.ui.`. + */ + async setAvailableViews() { + const recording = PerformanceController.getCurrentRecording(); + const isCompleted = recording && recording.isCompleted(); + let invalidCurrentView = false; + + for (const [name, { view }] of Object.entries(this.components)) { + const isSupported = this._isViewSupported(name); + + $(`toolbarbutton[data-view=${name}]`).hidden = !isSupported; + + // If the view is currently selected and not supported, go back to the + // default view. + if (!isSupported && this.isViewSelected(view)) { + invalidCurrentView = true; + } + } + + // Two scenarios in which we select the default view. + // + // 1: If we currently have selected a view that is no longer valid due + // to feature support, and this isn't the first view, and the current recording + // is completed. + // + // 2. If we have a finished recording and no panel was selected yet, + // use a default now that we have the recording configurations + if ( + (this._initialized && isCompleted && invalidCurrentView) || + (!this._initialized && isCompleted && recording) + ) { + await this.selectDefaultView(); + } + }, + + /** + * Takes a view name and determines if the current recording + * can support the view. + * + * @param {string} viewName + * @return {boolean} + */ + _isViewSupported: function(viewName) { + const { features, prefs } = this.components[viewName]; + const recording = PerformanceController.getCurrentRecording(); + + if (!recording || !recording.isCompleted()) { + return false; + } + + const prefSupported = prefs?.length + ? prefs.every(p => PerformanceController.getPref(p)) + : true; + return PerformanceController.isFeatureSupported(features) && prefSupported; + }, + + /** + * Select one of the DetailView's subviews to be rendered, + * hiding the others. + * + * @param String viewName + * Name of the view to be shown. + */ + async selectView(viewName) { + const component = this.components[viewName]; + this.el.selectedPanel = $("#" + component.id); + + await this._whenViewInitialized(component); + + for (const button of $$("toolbarbutton[data-view]", this.toolbar)) { + if (button.getAttribute("data-view") === viewName) { + button.setAttribute("checked", true); + } else { + button.removeAttribute("checked"); + } + } + + // Set a flag indicating that a view was explicitly set based on a + // recording's features. + this._initialized = true; + + this.emit(EVENTS.UI_DETAILS_VIEW_SELECTED, viewName); + }, + + /** + * Selects a default view based off of protocol support + * and preferences enabled. + */ + selectDefaultView: function() { + // We want the waterfall to be default view in almost all cases, except when + // timeline actor isn't supported, or we have markers disabled (which should only + // occur temporarily via bug 1156499 + if (this._isViewSupported("waterfall")) { + return this.selectView("waterfall"); + } + // The JS CallTree should always be supported since the profiler + // actor is as old as the world. + return this.selectView("js-calltree"); + }, + + /** + * Checks if the provided view is currently selected. + * + * @param object viewObject + * @return boolean + */ + isViewSelected: function(viewObject) { + // If not initialized, and we have no recordings, + // no views are selected (even though there's a selected panel) + if (!this._initialized) { + return false; + } + + const selectedPanel = this.el.selectedPanel; + const selectedId = selectedPanel.id; + + for (const { id, view } of Object.values(this.components)) { + if (id == selectedId && view == viewObject) { + return true; + } + } + + return false; + }, + + /** + * Initializes a subview if it wasn't already set up, and makes sure + * it's populated with recording data if there is some available. + * + * @param object component + * A component descriptor from DetailsView.components + */ + async _whenViewInitialized(component) { + if (component.initialized) { + return; + } + component.initialized = true; + await component.view.initialize(); + + // If this view is initialized *after* a recording is shown, it won't display + // any data. Make sure it's populated by setting `shouldUpdateWhenShown`. + // All detail views require a recording to be complete, so do not + // attempt to render if recording is in progress or does not exist. + const recording = PerformanceController.getCurrentRecording(); + if (recording && recording.isCompleted()) { + component.view.shouldUpdateWhenShown = true; + } + }, + + /** + * Called when recording stops or is selected. + */ + _onRecordingStoppedOrSelected: function(state, recording) { + if (typeof state === "string" && state !== "recording-stopped") { + return; + } + this.setAvailableViews(); + }, + + /** + * Called when a view button is clicked. + */ + _onViewToggle: function(e) { + this.selectView(e.target.getAttribute("data-view")); + }, + + toString: () => "[object DetailsView]", +}; + +/** + * Convenient way of emitting events from the view. + */ +EventEmitter.decorate(DetailsView); + +exports.DetailsView = DetailsView; diff --git a/devtools/client/performance/views/moz.build b/devtools/client/performance/views/moz.build new file mode 100644 index 0000000000..d10fbf3dac --- /dev/null +++ b/devtools/client/performance/views/moz.build @@ -0,0 +1,17 @@ +# 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/. + +DevToolsModules( + "details-abstract-subview.js", + "details-js-call-tree.js", + "details-js-flamegraph.js", + "details-memory-call-tree.js", + "details-memory-flamegraph.js", + "details-waterfall.js", + "details.js", + "overview.js", + "recordings.js", + "toolbar.js", +) diff --git a/devtools/client/performance/views/overview.js b/devtools/client/performance/views/overview.js new file mode 100644 index 0000000000..9257a48fff --- /dev/null +++ b/devtools/client/performance/views/overview.js @@ -0,0 +1,457 @@ +/* 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/. */ +/* globals $ PerformanceController */ +"use strict"; + +// No sense updating the overview more often than receiving data from the +// backend. Make sure this isn't lower than DEFAULT_TIMELINE_DATA_PULL_TIMEOUT +// in devtools/server/actors/timeline.js + +const EVENTS = require("devtools/client/performance/events"); +const { + GraphsController, +} = require("devtools/client/performance/modules/widgets/graphs"); + +const EventEmitter = require("devtools/shared/event-emitter"); + +// The following units are in milliseconds. +const OVERVIEW_UPDATE_INTERVAL = 200; +const FRAMERATE_GRAPH_LOW_RES_INTERVAL = 100; +const FRAMERATE_GRAPH_HIGH_RES_INTERVAL = 16; +const GRAPH_REQUIREMENTS = { + timeline: { + features: ["withMarkers"], + }, + framerate: { + features: ["withTicks"], + }, + memory: { + features: ["withMemory"], + }, +}; + +/** + * View handler for the overview panel's time view, displaying + * framerate, timeline and memory over time. + */ +const OverviewView = { + /** + * How frequently we attempt to render the graphs. Overridden + * in tests. + */ + OVERVIEW_UPDATE_INTERVAL: OVERVIEW_UPDATE_INTERVAL, + + /** + * Sets up the view with event binding. + */ + initialize: function() { + this.graphs = new GraphsController({ + root: $("#overview-pane"), + getFilter: () => PerformanceController.getPref("hidden-markers"), + getTheme: () => PerformanceController.getTheme(), + }); + + // If no timeline support, shut it all down. + if (!PerformanceController.getTraits().features.withMarkers) { + this.disable(); + return; + } + + // Store info on multiprocess support. + this._multiprocessData = PerformanceController.getMultiprocessStatus(); + + this._onRecordingStateChange = this._onRecordingStateChange.bind(this); + this._onRecordingSelected = this._onRecordingSelected.bind(this); + this._onRecordingTick = this._onRecordingTick.bind(this); + this._onGraphSelecting = this._onGraphSelecting.bind(this); + this._onGraphRendered = this._onGraphRendered.bind(this); + this._onPrefChanged = this._onPrefChanged.bind(this); + this._onThemeChanged = this._onThemeChanged.bind(this); + + // Toggle the initial visibility of memory and framerate graph containers + // based off of prefs. + PerformanceController.on(EVENTS.PREF_CHANGED, this._onPrefChanged); + PerformanceController.on(EVENTS.THEME_CHANGED, this._onThemeChanged); + PerformanceController.on( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStateChange + ); + PerformanceController.on( + EVENTS.RECORDING_SELECTED, + this._onRecordingSelected + ); + this.graphs.on("selecting", this._onGraphSelecting); + this.graphs.on("rendered", this._onGraphRendered); + }, + + /** + * Unbinds events. + */ + async destroy() { + PerformanceController.off(EVENTS.PREF_CHANGED, this._onPrefChanged); + PerformanceController.off(EVENTS.THEME_CHANGED, this._onThemeChanged); + PerformanceController.off( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStateChange + ); + PerformanceController.off( + EVENTS.RECORDING_SELECTED, + this._onRecordingSelected + ); + this.graphs.off("selecting", this._onGraphSelecting); + this.graphs.off("rendered", this._onGraphRendered); + await this.graphs.destroy(); + }, + + /** + * Returns true if any of the overview graphs have mouse dragging active, + * false otherwise. + */ + get isMouseActive() { + // Fetch all graphs currently stored in the GraphsController. + // These graphs are not necessarily active, but will not have + // an active mouse, in that case. + return !!this.graphs.getWidgets().some(e => e.isMouseActive); + }, + + /** + * Disabled in the event we're using a Timeline mock, so we'll have no + * timeline, ticks or memory data to show, so just block rendering and hide + * the panel. + */ + disable: function() { + this._disabled = true; + this.graphs.disableAll(); + }, + + /** + * Returns the disabled status. + * + * @return boolean + */ + isDisabled: function() { + return this._disabled; + }, + + /** + * Sets the time interval selection for all graphs in this overview. + * + * @param object interval + * The { startTime, endTime }, in milliseconds. + */ + setTimeInterval: function(interval, options = {}) { + const recording = PerformanceController.getCurrentRecording(); + if (recording == null) { + throw new Error( + "A recording should be available in order to set the selection." + ); + } + if (this.isDisabled()) { + return; + } + const mapStart = () => 0; + const mapEnd = () => recording.getDuration(); + const selection = { start: interval.startTime, end: interval.endTime }; + this._stopSelectionChangeEventPropagation = options.stopPropagation; + this.graphs.setMappedSelection(selection, { mapStart, mapEnd }); + this._stopSelectionChangeEventPropagation = false; + }, + + /** + * Gets the time interval selection for all graphs in this overview. + * + * @return object + * The { startTime, endTime }, in milliseconds. + */ + getTimeInterval: function() { + const recording = PerformanceController.getCurrentRecording(); + if (recording == null) { + throw new Error( + "A recording should be available in order to get the selection." + ); + } + if (this.isDisabled()) { + return { startTime: 0, endTime: recording.getDuration() }; + } + const mapStart = () => 0; + const mapEnd = () => recording.getDuration(); + const selection = this.graphs.getMappedSelection({ mapStart, mapEnd }); + // If no selection returned, this means the overview graphs have not been rendered + // yet, so act as if we have no selection (the full recording). Also + // if the selection range distance is tiny, assume the range was cleared or just + // clicked, and we do not have a range. + if (!selection || selection.max - selection.min < 1) { + return { startTime: 0, endTime: recording.getDuration() }; + } + return { startTime: selection.min, endTime: selection.max }; + }, + + /** + * Method for handling all the set up for rendering the overview graphs. + * + * @param number resolution + * The fps graph resolution. @see Graphs.js + */ + async render(resolution) { + if (this.isDisabled()) { + return; + } + + const recording = PerformanceController.getCurrentRecording(); + await this.graphs.render(recording.getAllData(), resolution); + + // Finished rendering all graphs in this overview. + this.emit(EVENTS.UI_OVERVIEW_RENDERED, resolution); + }, + + /** + * Called at most every OVERVIEW_UPDATE_INTERVAL milliseconds + * and uses data fetched from the controller to render + * data into all the corresponding overview graphs. + */ + async _onRecordingTick() { + await this.render(FRAMERATE_GRAPH_LOW_RES_INTERVAL); + this._prepareNextTick(); + }, + + /** + * Called to refresh the timer to keep firing _onRecordingTick. + */ + _prepareNextTick: function() { + // Check here to see if there's still a _timeoutId, incase + // `stop` was called before the _prepareNextTick call was executed. + if (this.isRendering()) { + this._timeoutId = setTimeout( + this._onRecordingTick, + this.OVERVIEW_UPDATE_INTERVAL + ); + } + }, + + /** + * Called when recording state changes. + */ + _onRecordingStateChange: OverviewViewOnStateChange(async function( + state, + recording + ) { + if (state !== "recording-stopped") { + return; + } + // Check to see if the recording that just stopped is the current recording. + // If it is, render the high-res graphs. For manual recordings, it will also + // be the current recording, but profiles generated by `console.profile` can stop + // while having another profile selected -- in this case, OverviewView should keep + // rendering the current recording. + if (recording !== PerformanceController.getCurrentRecording()) { + return; + } + await this.render(FRAMERATE_GRAPH_HIGH_RES_INTERVAL); + await this._checkSelection(recording); + }), + + /** + * Called when a new recording is selected. + */ + _onRecordingSelected: OverviewViewOnStateChange(async function(recording) { + this._setGraphVisibilityFromRecordingFeatures(recording); + + // If this recording is complete, render the high res graph + if (recording.isCompleted()) { + await this.render(FRAMERATE_GRAPH_HIGH_RES_INTERVAL); + } + await this._checkSelection(recording); + this.graphs.dropSelection(); + }), + + /** + * Start the polling for rendering the overview graph. + */ + _startPolling: function() { + this._timeoutId = setTimeout( + this._onRecordingTick, + this.OVERVIEW_UPDATE_INTERVAL + ); + }, + + /** + * Stop the polling for rendering the overview graph. + */ + _stopPolling: function() { + clearTimeout(this._timeoutId); + this._timeoutId = null; + }, + + /** + * Whether or not the overview view is in a state of polling rendering. + */ + isRendering: function() { + return !!this._timeoutId; + }, + + /** + * Makes sure the selection is enabled or disabled in all the graphs, + * based on whether a recording currently exists and is not in progress. + */ + async _checkSelection(recording) { + const isEnabled = recording ? recording.isCompleted() : false; + await this.graphs.selectionEnabled(isEnabled); + }, + + /** + * Fired when the graph selection has changed. Called by + * mouseup and scroll events. + */ + _onGraphSelecting: function() { + if (this._stopSelectionChangeEventPropagation) { + return; + } + + this.emit(EVENTS.UI_OVERVIEW_RANGE_SELECTED, this.getTimeInterval()); + }, + + _onGraphRendered: function(graphName) { + switch (graphName) { + case "timeline": + this.emit(EVENTS.UI_MARKERS_GRAPH_RENDERED); + break; + case "memory": + this.emit(EVENTS.UI_MEMORY_GRAPH_RENDERED); + break; + case "framerate": + this.emit(EVENTS.UI_FRAMERATE_GRAPH_RENDERED); + break; + } + }, + + /** + * Called whenever a preference in `devtools.performance.ui.` changes. + * Does not care about the enabling of memory/framerate graphs, + * because those will set values on a recording model, and + * the graphs will render based on the existence. + */ + async _onPrefChanged(prefName, prefValue) { + switch (prefName) { + case "hidden-markers": { + const graph = await this.graphs.isAvailable("timeline"); + if (graph) { + const filter = PerformanceController.getPref("hidden-markers"); + graph.setFilter(filter); + graph.refresh({ force: true }); + } + break; + } + } + }, + + _setGraphVisibilityFromRecordingFeatures: function(recording) { + for (const [graphName, requirements] of Object.entries( + GRAPH_REQUIREMENTS + )) { + this.graphs.enable( + graphName, + PerformanceController.isFeatureSupported(requirements.features) + ); + } + }, + + /** + * Fetch the multiprocess status and if e10s is not currently on, disable + * realtime rendering. + * + * @return {boolean} + */ + isRealtimeRenderingEnabled: function() { + return this._multiprocessData.enabled; + }, + + /** + * Show the graphs overview panel when a recording is finished + * when non-realtime graphs are enabled. Also set the graph visibility + * so the performance graphs know which graphs to render. + * + * @param {RecordingModel} recording + */ + _showGraphsPanel: function(recording) { + this._setGraphVisibilityFromRecordingFeatures(recording); + $("#overview-pane").classList.remove("hidden"); + }, + + /** + * Hide the graphs container completely. + */ + _hideGraphsPanel: function() { + $("#overview-pane").classList.add("hidden"); + }, + + /** + * Called when `devtools.theme` changes. + */ + _onThemeChanged: function(theme) { + this.graphs.setTheme({ theme, redraw: true }); + }, + + toString: () => "[object OverviewView]", +}; + +/** + * Utility that can wrap a method of OverviewView that + * handles a recording state change like when a recording is starting, + * stopping, or about to start/stop, and determines whether or not + * the polling for rendering the overview graphs needs to start or stop. + * Must be called with the OverviewView context. + * + * @param {function?} fn + * @return {function} + */ +function OverviewViewOnStateChange(fn) { + return function _onRecordingStateChange(recording) { + // Normalize arguments for the RECORDING_STATE_CHANGE event, + // as it also has a `state` argument. + if (typeof recording === "string") { + recording = arguments[1]; + } + + const currentRecording = PerformanceController.getCurrentRecording(); + + // All these methods require a recording to exist selected and + // from the event name, since there is a delay between starting + // a recording and changing the selection. + if (!currentRecording || !recording) { + // If no recording (this can occur when having a console.profile recording, and + // we do not stop it from the backend), and we are still rendering updates, + // stop that. + if (this.isRendering()) { + this._stopPolling(); + } + return; + } + + // If realtime rendering is not enabed (e10s not on), then + // show the disabled message, or the full graphs if the recording is completed + if (!this.isRealtimeRenderingEnabled()) { + if (recording.isRecording()) { + this._hideGraphsPanel(); + // Abort, as we do not want to change polling status. + return; + } + this._showGraphsPanel(recording); + } + + if (this.isRendering() && !currentRecording.isRecording()) { + this._stopPolling(); + } else if (currentRecording.isRecording() && !this.isRendering()) { + this._startPolling(); + } + + if (fn) { + fn.apply(this, arguments); + } + }; +} + +// Decorates the OverviewView as an EventEmitter +EventEmitter.decorate(OverviewView); + +exports.OverviewView = OverviewView; diff --git a/devtools/client/performance/views/recordings.js b/devtools/client/performance/views/recordings.js new file mode 100644 index 0000000000..d73b1ac083 --- /dev/null +++ b/devtools/client/performance/views/recordings.js @@ -0,0 +1,249 @@ +/* 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/. */ +/* globals $, PerformanceController */ +"use strict"; + +const EVENTS = require("devtools/client/performance/events"); +const { L10N } = require("devtools/client/performance/modules/global"); + +const PerformanceUtils = require("devtools/client/performance/modules/utils"); + +const React = require("devtools/client/shared/vendor/react"); +const ReactDOM = require("devtools/client/shared/vendor/react-dom"); +const RecordingList = React.createFactory( + require("devtools/client/performance/components/RecordingList") +); +const RecordingListItem = React.createFactory( + require("devtools/client/performance/components/RecordingListItem") +); + +const EventEmitter = require("devtools/shared/event-emitter"); + +/** + * Functions handling the recordings UI. + */ +const RecordingsView = { + /** + * Initialization function, called when the tool is started. + */ + initialize: function() { + this._onSelect = this._onSelect.bind(this); + this._onRecordingStateChange = this._onRecordingStateChange.bind(this); + this._onNewRecording = this._onNewRecording.bind(this); + this._onSaveButtonClick = this._onSaveButtonClick.bind(this); + this._onRecordingDeleted = this._onRecordingDeleted.bind(this); + this._onRecordingExported = this._onRecordingExported.bind(this); + + PerformanceController.on( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStateChange + ); + PerformanceController.on(EVENTS.RECORDING_ADDED, this._onNewRecording); + PerformanceController.on( + EVENTS.RECORDING_DELETED, + this._onRecordingDeleted + ); + PerformanceController.on( + EVENTS.RECORDING_EXPORTED, + this._onRecordingExported + ); + + // DE-XUL: Begin migrating the recording sidebar to React. Temporarily hold state + // here. + this._listState = { + recordings: [], + labels: new WeakMap(), + selected: null, + }; + this._listMount = PerformanceUtils.createHtmlMount( + $("#recording-list-mount") + ); + this._renderList(); + }, + + /** + * Get the index of the currently selected recording. Only used by tests. + * @return {integer} index + */ + getSelectedIndex() { + const { recordings, selected } = this._listState; + return recordings.indexOf(selected); + }, + + /** + * Set the currently selected recording via its index. Only used by tests. + * @param {integer} index + */ + setSelectedByIndex(index) { + this._onSelect(this._listState.recordings[index]); + this._renderList(); + }, + + /** + * DE-XUL: During the migration, this getter will access the selected recording from + * the private _listState object so that tests will continue to pass. + */ + get selected() { + return this._listState.selected; + }, + + /** + * DE-XUL: During the migration, this getter will access the number of recordings. + */ + get itemCount() { + return this._listState.recordings.length; + }, + + /** + * DE-XUL: Render the recording list using React. + */ + _renderList: function() { + const { recordings, labels, selected } = this._listState; + + const recordingList = RecordingList({ + itemComponent: RecordingListItem, + items: recordings.map(recording => ({ + onSelect: () => this._onSelect(recording), + onSave: () => this._onSaveButtonClick(recording), + isLoading: !recording.isRecording() && !recording.isCompleted(), + isRecording: recording.isRecording(), + isSelected: recording === selected, + duration: recording.getDuration().toFixed(0), + label: labels.get(recording), + })), + }); + + ReactDOM.render(recordingList, this._listMount); + }, + + /** + * Destruction function, called when the tool is closed. + */ + destroy: function() { + PerformanceController.off( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStateChange + ); + PerformanceController.off(EVENTS.RECORDING_ADDED, this._onNewRecording); + PerformanceController.off( + EVENTS.RECORDING_DELETED, + this._onRecordingDeleted + ); + PerformanceController.off( + EVENTS.RECORDING_EXPORTED, + this._onRecordingExported + ); + }, + + /** + * Called when a new recording is stored in the UI. This handles + * when recordings are lazily loaded (like a console.profile occurring + * before the tool is loaded) or imported. In normal manual recording cases, + * this will also be fired. + */ + _onNewRecording: function(recording) { + this._onRecordingStateChange(null, recording); + }, + + /** + * Signals that a recording has changed state. + * + * @param string state + * Can be "recording-started", "recording-stopped", "recording-stopping" + * @param RecordingModel recording + * Model of the recording that was started. + */ + _onRecordingStateChange: function(state, recording) { + const { recordings, labels } = this._listState; + + if (!recordings.includes(recording)) { + recordings.push(recording); + labels.set( + recording, + recording.getLabel() || + L10N.getFormatStr("recordingsList.itemLabel", recordings.length) + ); + + // If this is a manual recording, immediately select it, or + // select a console profile if its the only one + if (!recording.isConsole() || !this._listState.selected) { + this._onSelect(recording); + } + } + + // Determine if the recording needs to be selected. + const isCompletedManualRecording = + !recording.isConsole() && recording.isCompleted(); + if (recording.isImported() || isCompletedManualRecording) { + this._onSelect(recording); + } + + this._renderList(); + }, + + /** + * Clears out all non-console recordings. + */ + _onRecordingDeleted: function(recording) { + const { recordings } = this._listState; + const index = recordings.indexOf(recording); + if (index === -1) { + throw new Error("Attempting to remove a recording that doesn't exist."); + } + recordings.splice(index, 1); + this._renderList(); + }, + + /** + * The select listener for this container. + */ + async _onSelect(recording) { + this._listState.selected = recording; + this.emit(EVENTS.UI_RECORDING_SELECTED, recording); + this._renderList(); + }, + + /** + * The click listener for the "save" button of each item in this container. + */ + _onSaveButtonClick: function(recording) { + const fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init( + window, + L10N.getStr("recordingsList.saveDialogTitle"), + Ci.nsIFilePicker.modeSave + ); + fp.appendFilter( + L10N.getStr("recordingsList.saveDialogJSONFilter"), + "*.json" + ); + fp.appendFilter(L10N.getStr("recordingsList.saveDialogAllFilter"), "*.*"); + fp.defaultString = "profile.json"; + + fp.open({ + done: result => { + if (result == Ci.nsIFilePicker.returnCancel) { + return; + } + this.emit(EVENTS.UI_EXPORT_RECORDING, recording, fp.file); + }, + }); + }, + + _onRecordingExported: function(recording, file) { + if (recording.isConsole()) { + return; + } + const name = file.leafName.replace(/\..+$/, ""); + this._listState.labels.set(recording, name); + this._renderList(); + }, +}; + +/** + * Convenient way of emitting events from the RecordingsView. + */ +EventEmitter.decorate(RecordingsView); + +exports.RecordingsView = window.RecordingsView = RecordingsView; diff --git a/devtools/client/performance/views/toolbar.js b/devtools/client/performance/views/toolbar.js new file mode 100644 index 0000000000..cd156e4c4d --- /dev/null +++ b/devtools/client/performance/views/toolbar.js @@ -0,0 +1,188 @@ +/* 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/. */ +/* globals $, $$, PerformanceController */ +"use strict"; + +const EVENTS = require("devtools/client/performance/events"); +const { + TIMELINE_BLUEPRINT, +} = require("devtools/client/performance/modules/markers"); +const { + MarkerBlueprintUtils, +} = require("devtools/client/performance/modules/marker-blueprint-utils"); + +const { OptionsView } = require("devtools/client/shared/options-view"); +const EventEmitter = require("devtools/shared/event-emitter"); + +const BRANCH_NAME = "devtools.performance.ui."; + +/** + * View handler for toolbar events (mostly option toggling and triggering) + */ +const ToolbarView = { + /** + * Sets up the view with event binding. + */ + async initialize() { + this._onFilterPopupShowing = this._onFilterPopupShowing.bind(this); + this._onFilterPopupHiding = this._onFilterPopupHiding.bind(this); + this._onHiddenMarkersChanged = this._onHiddenMarkersChanged.bind(this); + this._onPrefChanged = this._onPrefChanged.bind(this); + this._popup = $("#performance-options-menupopup"); + + this.optionsView = new OptionsView({ + branchName: BRANCH_NAME, + menupopup: this._popup, + }); + + // Set the visibility of experimental UI options on load + // based off of `devtools.performance.ui.experimental` preference + const experimentalEnabled = PerformanceController.getOption("experimental"); + this._toggleExperimentalUI(experimentalEnabled); + + await this.optionsView.initialize(); + this.optionsView.on("pref-changed", this._onPrefChanged); + + this._buildMarkersFilterPopup(); + this._updateHiddenMarkersPopup(); + $("#performance-filter-menupopup").addEventListener( + "popupshowing", + this._onFilterPopupShowing + ); + $("#performance-filter-menupopup").addEventListener( + "popuphiding", + this._onFilterPopupHiding + ); + }, + + /** + * Unbinds events and cleans up view. + */ + destroy: function() { + $("#performance-filter-menupopup").removeEventListener( + "popupshowing", + this._onFilterPopupShowing + ); + $("#performance-filter-menupopup").removeEventListener( + "popuphiding", + this._onFilterPopupHiding + ); + this._popup = null; + + this.optionsView.off("pref-changed", this._onPrefChanged); + this.optionsView.destroy(); + }, + + /** + * Creates the timeline markers filter popup. + */ + _buildMarkersFilterPopup: function() { + for (const [markerName, markerDetails] of Object.entries( + TIMELINE_BLUEPRINT + )) { + const menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("closemenu", "none"); + menuitem.setAttribute("type", "checkbox"); + menuitem.setAttribute("align", "center"); + menuitem.setAttribute("flex", "1"); + menuitem.setAttribute( + "label", + MarkerBlueprintUtils.getMarkerGenericName(markerName) + ); + menuitem.setAttribute("marker-type", markerName); + menuitem.className = `marker-color-${markerDetails.colorName}`; + + menuitem.addEventListener("command", this._onHiddenMarkersChanged); + + $("#performance-filter-menupopup").appendChild(menuitem); + } + }, + + /** + * Updates the menu items checked state in the timeline markers filter popup. + */ + _updateHiddenMarkersPopup: function() { + const menuItems = $$("#performance-filter-menupopup menuitem[marker-type]"); + const hiddenMarkers = PerformanceController.getPref("hidden-markers"); + + for (const menuitem of menuItems) { + if (~hiddenMarkers.indexOf(menuitem.getAttribute("marker-type"))) { + menuitem.removeAttribute("checked"); + } else { + menuitem.setAttribute("checked", "true"); + } + } + }, + + /** + * Fired when `devtools.performance.ui.experimental` is changed, or + * during init. Toggles the visibility of experimental performance tool options + * in the UI options. + * + * Sets or removes "experimental-enabled" on the menu and main elements, + * hiding or showing all elements with class "experimental-option". + * + * TODO re-enable "#option-enable-memory" permanently once stable in bug 1163350 + * TODO re-enable "#option-show-jit-optimizations" permanently once stable in + * bug 1163351 + * + * @param {boolean} isEnabled + */ + _toggleExperimentalUI: function(isEnabled) { + if (isEnabled) { + $(".theme-body").classList.add("experimental-enabled"); + this._popup.classList.add("experimental-enabled"); + } else { + $(".theme-body").classList.remove("experimental-enabled"); + this._popup.classList.remove("experimental-enabled"); + } + }, + + /** + * Fired when the markers filter popup starts to show. + */ + _onFilterPopupShowing: function() { + $("#filter-button").setAttribute("open", "true"); + }, + + /** + * Fired when the markers filter popup starts to hide. + */ + _onFilterPopupHiding: function() { + $("#filter-button").removeAttribute("open"); + }, + + /** + * Fired when a menu item in the markers filter popup is checked or unchecked. + */ + _onHiddenMarkersChanged: function() { + const checkedMenuItems = $$( + "#performance-filter-menupopup menuitem[marker-type]:not([checked])" + ); + const hiddenMarkers = Array.from(checkedMenuItems, e => + e.getAttribute("marker-type") + ); + PerformanceController.setPref("hidden-markers", hiddenMarkers); + }, + + /** + * Fired when a preference changes in the underlying OptionsView. + * Propogated by the PerformanceController. + */ + _onPrefChanged: function(prefName) { + const value = PerformanceController.getOption(prefName); + + if (prefName === "experimental") { + this._toggleExperimentalUI(value); + } + + this.emit(EVENTS.UI_PREF_CHANGED, prefName, value); + }, + + toString: () => "[object ToolbarView]", +}; + +EventEmitter.decorate(ToolbarView); + +exports.ToolbarView = window.ToolbarView = ToolbarView; |