diff options
Diffstat (limited to 'devtools/client/memory')
155 files changed, 16235 insertions, 0 deletions
diff --git a/devtools/client/memory/.eslintrc.js b/devtools/client/memory/.eslintrc.js new file mode 100644 index 0000000000..5500a7eb10 --- /dev/null +++ b/devtools/client/memory/.eslintrc.js @@ -0,0 +1,15 @@ +/* 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"; + +module.exports = { + env: { + browser: true, + }, + globals: { + d3: true, + dagreD3: true, + }, +}; diff --git a/devtools/client/memory/actions/allocations.js b/devtools/client/memory/actions/allocations.js new file mode 100644 index 0000000000..47bc68e1c2 --- /dev/null +++ b/devtools/client/memory/actions/allocations.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 { + actions, + ALLOCATION_RECORDING_OPTIONS, +} = require("resource://devtools/client/memory/constants.js"); + +exports.toggleRecordingAllocationStacks = function (commands) { + return async function ({ dispatch, getState }) { + dispatch({ type: actions.TOGGLE_RECORD_ALLOCATION_STACKS_START }); + + if (commands.targetCommand.hasTargetWatcherSupport()) { + await commands.targetConfigurationCommand.updateConfiguration({ + recordAllocations: getState().recordingAllocationStacks + ? null + : ALLOCATION_RECORDING_OPTIONS, + }); + } else { + const front = await commands.targetCommand.targetFront.getFront("memory"); + if (getState().recordingAllocationStacks) { + await front.stopRecordingAllocations(); + } else { + await front.startRecordingAllocations(ALLOCATION_RECORDING_OPTIONS); + } + } + + dispatch({ type: actions.TOGGLE_RECORD_ALLOCATION_STACKS_END }); + }; +}; diff --git a/devtools/client/memory/actions/census-display.js b/devtools/client/memory/actions/census-display.js new file mode 100644 index 0000000000..d266fe04a5 --- /dev/null +++ b/devtools/client/memory/actions/census-display.js @@ -0,0 +1,39 @@ +/* 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 { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { actions } = require("resource://devtools/client/memory/constants.js"); +const { + refresh, +} = require("resource://devtools/client/memory/actions/refresh.js"); + +exports.setCensusDisplayAndRefresh = function (heapWorker, display) { + return async function ({ dispatch, getState }) { + dispatch(setCensusDisplay(display)); + await dispatch(refresh(heapWorker)); + }; +}; + +/** + * Clears out all cached census data in the snapshots and sets new display data + * for censuses. + * + * @param {censusDisplayModel} display + */ +const setCensusDisplay = (exports.setCensusDisplay = function (display) { + assert( + typeof display === "object" && + display && + display.breakdown && + display.breakdown.by, + "Breakdowns must be an object with a `by` property, attempted to set: " + + JSON.stringify(display) + ); + + return { + type: actions.SET_CENSUS_DISPLAY, + display, + }; +}); diff --git a/devtools/client/memory/actions/diffing.js b/devtools/client/memory/actions/diffing.js new file mode 100644 index 0000000000..1442cbc0a7 --- /dev/null +++ b/devtools/client/memory/actions/diffing.js @@ -0,0 +1,221 @@ +/* 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 { + assert, + reportException, +} = require("resource://devtools/shared/DevToolsUtils.js"); +const { + actions, + diffingState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + getSnapshot, + censusIsUpToDate, + snapshotIsDiffable, + findSelectedSnapshot, +} = require("resource://devtools/client/memory/utils.js"); + +/** + * Toggle diffing mode on or off. + */ +exports.toggleDiffing = function () { + return function ({ dispatch, getState }) { + dispatch({ + type: actions.CHANGE_VIEW, + newViewState: getState().diffing ? viewState.CENSUS : viewState.DIFFING, + oldDiffing: getState().diffing, + oldSelected: findSelectedSnapshot(getState()), + }); + }; +}; + +/** + * Select the given snapshot for diffing. + * + * @param {snapshotModel} snapshot + */ +const selectSnapshotForDiffing = (exports.selectSnapshotForDiffing = function ( + snapshot +) { + assert( + snapshotIsDiffable(snapshot), + "To select a snapshot for diffing, it must be diffable" + ); + return { type: actions.SELECT_SNAPSHOT_FOR_DIFFING, snapshot }; +}); + +/** + * Compute the difference between the first and second snapshots. + * + * @param {HeapAnalysesClient} heapWorker + * @param {snapshotModel} first + * @param {snapshotModel} second + */ +const takeCensusDiff = (exports.takeCensusDiff = function ( + heapWorker, + first, + second +) { + return async function ({ dispatch, getState }) { + assert( + snapshotIsDiffable(first), + `First snapshot must be in a diffable state, found ${first.state}` + ); + assert( + snapshotIsDiffable(second), + `Second snapshot must be in a diffable state, found ${second.state}` + ); + + let report, parentMap; + let display = getState().censusDisplay; + let filter = getState().filter; + + if (censusIsUpToDate(filter, display, getState().diffing.census)) { + return; + } + + do { + if ( + !getState().diffing || + getState().diffing.firstSnapshotId !== first.id || + getState().diffing.secondSnapshotId !== second.id + ) { + // If we stopped diffing or stopped and then started diffing a different + // pair of snapshots, then just give up with diffing this pair. In the + // latter case, a newly spawned task will handle the diffing for the new + // pair. + return; + } + + display = getState().censusDisplay; + filter = getState().filter; + + dispatch({ + type: actions.TAKE_CENSUS_DIFF_START, + first, + second, + filter, + display, + }); + + const opts = display.inverted + ? { asInvertedTreeNode: true } + : { asTreeNode: true }; + opts.filter = filter || null; + + try { + ({ delta: report, parentMap } = await heapWorker.takeCensusDiff( + first.path, + second.path, + { breakdown: display.breakdown }, + opts + )); + } catch (error) { + reportException("actions/diffing/takeCensusDiff", error); + dispatch({ type: actions.DIFFING_ERROR, error }); + return; + } + } while ( + filter !== getState().filter || + display !== getState().censusDisplay + ); + + dispatch({ + type: actions.TAKE_CENSUS_DIFF_END, + first, + second, + report, + parentMap, + filter, + display, + }); + }; +}); + +/** + * Ensure that the current diffing data is up to date with the currently + * selected display, filter, etc. If the state is not up-to-date, then a + * recompute is triggered. + * + * @param {HeapAnalysesClient} heapWorker + */ +const refreshDiffing = (exports.refreshDiffing = function (heapWorker) { + return function ({ dispatch, getState }) { + if (getState().diffing.secondSnapshotId === null) { + return; + } + + assert(getState().diffing.firstSnapshotId, "Should have first snapshot id"); + + if (getState().diffing.state === diffingState.TAKING_DIFF) { + // There is an existing task that will ensure that the diffing data is + // up-to-date. + return; + } + + const { firstSnapshotId, secondSnapshotId } = getState().diffing; + + const first = getSnapshot(getState(), firstSnapshotId); + const second = getSnapshot(getState(), secondSnapshotId); + dispatch(takeCensusDiff(heapWorker, first, second)); + }; +}); + +/** + * Select the given snapshot for diffing and refresh the diffing data if + * necessary (for example, if two snapshots are now selected for diffing). + * + * @param {HeapAnalysesClient} heapWorker + * @param {snapshotModel} snapshot + */ +exports.selectSnapshotForDiffingAndRefresh = function (heapWorker, snapshot) { + return async function ({ dispatch, getState }) { + assert( + getState().diffing, + "If we are selecting for diffing, we must be in diffing mode" + ); + dispatch(selectSnapshotForDiffing(snapshot)); + await dispatch(refreshDiffing(heapWorker)); + }; +}; + +/** + * Expand the given node in the diffing's census's delta-report. + * + * @param {CensusTreeNode} node + */ +exports.expandDiffingCensusNode = function (node) { + return { + type: actions.EXPAND_DIFFING_CENSUS_NODE, + node, + }; +}; + +/** + * Collapse the given node in the diffing's census's delta-report. + * + * @param {CensusTreeNode} node + */ +exports.collapseDiffingCensusNode = function (node) { + return { + type: actions.COLLAPSE_DIFFING_CENSUS_NODE, + node, + }; +}; + +/** + * Focus the given node in the snapshot's census's report. + * + * @param {DominatorTreeNode} node + */ +exports.focusDiffingCensusNode = function (node) { + return { + type: actions.FOCUS_DIFFING_CENSUS_NODE, + node, + }; +}; diff --git a/devtools/client/memory/actions/filter.js b/devtools/client/memory/actions/filter.js new file mode 100644 index 0000000000..1bc9fd35db --- /dev/null +++ b/devtools/client/memory/actions/filter.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { actions } = require("resource://devtools/client/memory/constants.js"); +const { + refresh, +} = require("resource://devtools/client/memory/actions/refresh.js"); +const { debounce } = require("resource://devtools/shared/debounce.js"); + +const setFilterString = (exports.setFilterString = function (filterString) { + return { + type: actions.SET_FILTER_STRING, + filter: filterString, + }; +}); + +// The number of milliseconds we should wait before kicking off a new census +// when the filter string is updated. This helps us avoid doing any work while +// the user is still typing. +const FILTER_INPUT_DEBOUNCE_MS = 250; +const debouncedRefreshDispatcher = debounce( + (dispatch, heapWorker) => dispatch(refresh(heapWorker)), + FILTER_INPUT_DEBOUNCE_MS +); + +exports.setFilterStringAndRefresh = function (filterString, heapWorker) { + return ({ dispatch, getState }) => { + dispatch(setFilterString(filterString)); + debouncedRefreshDispatcher(dispatch, heapWorker); + }; +}; diff --git a/devtools/client/memory/actions/front.js b/devtools/client/memory/actions/front.js new file mode 100644 index 0000000000..3ae10ea9cc --- /dev/null +++ b/devtools/client/memory/actions/front.js @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { actions } = require("resource://devtools/client/memory/constants.js"); + +/** + * Update the memory front. + */ +exports.updateMemoryFront = front => { + return { + type: actions.UPDATE_MEMORY_FRONT, + front, + }; +}; diff --git a/devtools/client/memory/actions/io.js b/devtools/client/memory/actions/io.js new file mode 100644 index 0000000000..c811478df5 --- /dev/null +++ b/devtools/client/memory/actions/io.js @@ -0,0 +1,103 @@ +/* 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 { + immutableUpdate, + reportException, + assert, +} = require("resource://devtools/shared/DevToolsUtils.js"); +const { + snapshotState: states, + actions, +} = require("resource://devtools/client/memory/constants.js"); +const { + L10N, + openFilePicker, + createSnapshot, +} = require("resource://devtools/client/memory/utils.js"); +const { + selectSnapshot, + computeSnapshotData, + readSnapshot, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const VALID_EXPORT_STATES = [states.SAVED, states.READ]; + +exports.pickFileAndExportSnapshot = function (snapshot) { + return async function ({ dispatch, getState }) { + const outputFile = await openFilePicker({ + title: L10N.getFormatStr("snapshot.io.save.window"), + defaultName: PathUtils.filename(snapshot.path), + filters: [[L10N.getFormatStr("snapshot.io.filter"), "*.fxsnapshot"]], + mode: "save", + }); + + if (!outputFile) { + return; + } + + await dispatch(exportSnapshot(snapshot, outputFile.path)); + }; +}; + +const exportSnapshot = (exports.exportSnapshot = function (snapshot, dest) { + return async function ({ dispatch, getState }) { + dispatch({ type: actions.EXPORT_SNAPSHOT_START, snapshot }); + + assert( + VALID_EXPORT_STATES.includes(snapshot.state), + `Snapshot is in invalid state for exporting: ${snapshot.state}` + ); + + try { + await IOUtils.copy(snapshot.path, dest); + } catch (error) { + reportException("exportSnapshot", error); + dispatch({ type: actions.EXPORT_SNAPSHOT_ERROR, snapshot, error }); + } + + dispatch({ type: actions.EXPORT_SNAPSHOT_END, snapshot }); + }; +}); + +exports.pickFileAndImportSnapshotAndCensus = function (heapWorker) { + return async function ({ dispatch, getState }) { + const input = await openFilePicker({ + title: L10N.getFormatStr("snapshot.io.import.window"), + filters: [[L10N.getFormatStr("snapshot.io.filter"), "*.fxsnapshot"]], + mode: "open", + }); + + if (!input) { + return; + } + + await dispatch(importSnapshotAndCensus(heapWorker, input.path)); + }; +}; + +const importSnapshotAndCensus = function (heapWorker, path) { + return async function ({ dispatch, getState }) { + const snapshot = immutableUpdate(createSnapshot(getState()), { + path, + state: states.IMPORTING, + imported: true, + }); + const id = snapshot.id; + + dispatch({ type: actions.IMPORT_SNAPSHOT_START, snapshot }); + dispatch(selectSnapshot(snapshot.id)); + + try { + await dispatch(readSnapshot(heapWorker, id)); + await dispatch(computeSnapshotData(heapWorker, id)); + } catch (error) { + reportException("importSnapshot", error); + dispatch({ type: actions.IMPORT_SNAPSHOT_ERROR, error, id }); + } + + dispatch({ type: actions.IMPORT_SNAPSHOT_END, id }); + }; +}; +exports.importSnapshotAndCensus = importSnapshotAndCensus; diff --git a/devtools/client/memory/actions/label-display.js b/devtools/client/memory/actions/label-display.js new file mode 100644 index 0000000000..c8a6db35ec --- /dev/null +++ b/devtools/client/memory/actions/label-display.js @@ -0,0 +1,43 @@ +/* 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 { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { actions } = require("resource://devtools/client/memory/constants.js"); +const { + refresh, +} = require("resource://devtools/client/memory/actions/refresh.js"); + +/** + * Change the display we use for labeling individual nodes and refresh the + * current data. + */ +exports.setLabelDisplayAndRefresh = function (heapWorker, display) { + return async function ({ dispatch, getState }) { + // Clears out all stored census data and sets the display. + dispatch(setLabelDisplay(display)); + await dispatch(refresh(heapWorker)); + }; +}; + +/** + * Change the display we use for labeling individual nodes. + * + * @param {labelDisplayModel} display + */ +const setLabelDisplay = (exports.setLabelDisplay = function (display) { + assert( + typeof display === "object" && + display && + display.breakdown && + display.breakdown.by, + "Breakdowns must be an object with a `by` property, attempted to set: " + + JSON.stringify(display) + ); + + return { + type: actions.SET_LABEL_DISPLAY, + display, + }; +}); diff --git a/devtools/client/memory/actions/moz.build b/devtools/client/memory/actions/moz.build new file mode 100644 index 0000000000..939712ca32 --- /dev/null +++ b/devtools/client/memory/actions/moz.build @@ -0,0 +1,20 @@ +# 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( + "allocations.js", + "census-display.js", + "diffing.js", + "filter.js", + "front.js", + "io.js", + "label-display.js", + "refresh.js", + "sizes.js", + "snapshot.js", + "task-cache.js", + "tree-map-display.js", + "view.js", +) diff --git a/devtools/client/memory/actions/refresh.js b/devtools/client/memory/actions/refresh.js new file mode 100644 index 0000000000..33401fa299 --- /dev/null +++ b/devtools/client/memory/actions/refresh.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { viewState } = require("resource://devtools/client/memory/constants.js"); +const { + refreshDiffing, +} = require("resource://devtools/client/memory/actions/diffing.js"); +const snapshot = require("resource://devtools/client/memory/actions/snapshot.js"); + +/** + * Refresh the main thread's data from the heap analyses worker, if needed. + * + * @param {HeapAnalysesWorker} heapWorker + */ +exports.refresh = function (heapWorker) { + return async function ({ dispatch, getState }) { + switch (getState().view.state) { + case viewState.DIFFING: + assert( + getState().diffing, + "Should have diffing state if in diffing view" + ); + await dispatch(refreshDiffing(heapWorker)); + return; + + case viewState.CENSUS: + await dispatch(snapshot.refreshSelectedCensus(heapWorker)); + return; + + case viewState.DOMINATOR_TREE: + await dispatch(snapshot.refreshSelectedDominatorTree(heapWorker)); + return; + + case viewState.TREE_MAP: + await dispatch(snapshot.refreshSelectedTreeMap(heapWorker)); + return; + + case viewState.INDIVIDUALS: + await dispatch(snapshot.refreshIndividuals(heapWorker)); + return; + + default: + assert(false, `Unexpected view state: ${getState().view.state}`); + } + }; +}; diff --git a/devtools/client/memory/actions/sizes.js b/devtools/client/memory/actions/sizes.js new file mode 100644 index 0000000000..c5a98e23e9 --- /dev/null +++ b/devtools/client/memory/actions/sizes.js @@ -0,0 +1,13 @@ +/* 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 { actions } = require("resource://devtools/client/memory/constants.js"); + +exports.resizeShortestPaths = function (newSize) { + return { + type: actions.RESIZE_SHORTEST_PATHS, + size: newSize, + }; +}; diff --git a/devtools/client/memory/actions/snapshot.js b/devtools/client/memory/actions/snapshot.js new file mode 100644 index 0000000000..6593eec7a3 --- /dev/null +++ b/devtools/client/memory/actions/snapshot.js @@ -0,0 +1,939 @@ +/* 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 { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); +const { + assert, + reportException, + isSet, +} = require("resource://devtools/shared/DevToolsUtils.js"); +const { + censusIsUpToDate, + getSnapshot, + createSnapshot, + dominatorTreeIsComputed, +} = require("resource://devtools/client/memory/utils.js"); +const { + actions, + snapshotState: states, + viewState, + censusState, + treeMapState, + dominatorTreeState, + individualsState, +} = require("resource://devtools/client/memory/constants.js"); +const view = require("resource://devtools/client/memory/actions/view.js"); +const refresh = require("resource://devtools/client/memory/actions/refresh.js"); +const diffing = require("resource://devtools/client/memory/actions/diffing.js"); +const TaskCache = require("resource://devtools/client/memory/actions/task-cache.js"); + +/** + * A series of actions are fired from this task to save, read and generate the + * initial census from a snapshot. + * + * @param {MemoryFront} + * @param {HeapAnalysesClient} + * @param {Object} + */ +exports.takeSnapshotAndCensus = function (front, heapWorker) { + return async function ({ dispatch, getState }) { + const id = await dispatch(takeSnapshot(front)); + if (id === null) { + return; + } + + await dispatch(readSnapshot(heapWorker, id)); + if (getSnapshot(getState(), id).state !== states.READ) { + return; + } + + await dispatch(computeSnapshotData(heapWorker, id)); + }; +}; + +/** + * Create the census for the snapshot with the provided snapshot id. If the + * current view is the DOMINATOR_TREE view, create the dominator tree for this + * snapshot as well. + * + * @param {HeapAnalysesClient} heapWorker + * @param {snapshotId} id + */ +const computeSnapshotData = (exports.computeSnapshotData = function ( + heapWorker, + id +) { + return async function ({ dispatch, getState }) { + if (getSnapshot(getState(), id).state !== states.READ) { + return; + } + + // Decide which type of census to take. + const censusTaker = getCurrentCensusTaker(getState().view.state); + await dispatch(censusTaker(heapWorker, id)); + + if ( + getState().view.state === viewState.DOMINATOR_TREE && + !getSnapshot(getState(), id).dominatorTree + ) { + await dispatch(computeAndFetchDominatorTree(heapWorker, id)); + } + }; +}); + +/** + * Selects a snapshot and if the snapshot's census is using a different + * display, take a new census. + * + * @param {HeapAnalysesClient} heapWorker + * @param {snapshotId} id + */ +exports.selectSnapshotAndRefresh = function (heapWorker, id) { + return async function ({ dispatch, getState }) { + if (getState().diffing || getState().individuals) { + dispatch(view.changeView(viewState.CENSUS)); + } + + dispatch(selectSnapshot(id)); + await dispatch(refresh.refresh(heapWorker)); + }; +}; + +/** + * Take a snapshot and return its id on success, or null on failure. + * + * @param {MemoryFront} front + * @returns {Number|null} + */ +const takeSnapshot = (exports.takeSnapshot = function (front) { + return async function ({ dispatch, getState }) { + if (getState().diffing || getState().individuals) { + dispatch(view.changeView(viewState.CENSUS)); + } + + const snapshot = createSnapshot(getState()); + const id = snapshot.id; + dispatch({ type: actions.TAKE_SNAPSHOT_START, snapshot }); + dispatch(selectSnapshot(id)); + + let path; + try { + path = await front.saveHeapSnapshot(); + } catch (error) { + reportException("takeSnapshot", error); + dispatch({ type: actions.SNAPSHOT_ERROR, id, error }); + return null; + } + + dispatch({ type: actions.TAKE_SNAPSHOT_END, id, path }); + return snapshot.id; + }; +}); + +/** + * Reads a snapshot into memory; necessary to do before taking + * a census on the snapshot. May only be called once per snapshot. + * + * @param {HeapAnalysesClient} heapWorker + * @param {snapshotId} id + */ +const readSnapshot = (exports.readSnapshot = TaskCache.declareCacheableTask({ + getCacheKey(_, id) { + return id; + }, + + async task(heapWorker, id, removeFromCache, dispatch, getState) { + const snapshot = getSnapshot(getState(), id); + assert( + [states.SAVED, states.IMPORTING].includes(snapshot.state), + `Should only read a snapshot once. Found snapshot in state ${snapshot.state}` + ); + + let creationTime; + + dispatch({ type: actions.READ_SNAPSHOT_START, id }); + try { + await heapWorker.readHeapSnapshot(snapshot.path); + creationTime = await heapWorker.getCreationTime(snapshot.path); + } catch (error) { + removeFromCache(); + reportException("readSnapshot", error); + dispatch({ type: actions.SNAPSHOT_ERROR, id, error }); + return; + } + + removeFromCache(); + dispatch({ type: actions.READ_SNAPSHOT_END, id, creationTime }); + }, +})); + +let takeCensusTaskCounter = 0; + +/** + * Census and tree maps both require snapshots. This function shares the logic + * of creating snapshots, but is configurable with specific actions for the + * individual census types. + * + * @param {getDisplay} Get the display object from the state. + * @param {getCensus} Get the census from the snapshot. + * @param {beginAction} Action to send at the beginning of a heap snapshot. + * @param {endAction} Action to send at the end of a heap snapshot. + * @param {errorAction} Action to send if a snapshot has an error. + */ +function makeTakeCensusTask({ + getDisplay, + getFilter, + getCensus, + beginAction, + endAction, + errorAction, + canTakeCensus, +}) { + /** + * @param {HeapAnalysesClient} heapWorker + * @param {snapshotId} id + * + * @see {Snapshot} model defined in devtools/client/memory/models.js + * @see `devtools/shared/heapsnapshot/HeapAnalysesClient.js` + * @see `js/src/doc/Debugger/Debugger.Memory.md` for breakdown details + */ + const thisTakeCensusTaskId = ++takeCensusTaskCounter; + return TaskCache.declareCacheableTask({ + getCacheKey(_, id) { + return `take-census-task-${thisTakeCensusTaskId}-${id}`; + }, + + async task(heapWorker, id, removeFromCache, dispatch, getState) { + const snapshot = getSnapshot(getState(), id); + if (!snapshot) { + removeFromCache(); + return; + } + + // Assert that snapshot is in a valid state + assert( + canTakeCensus(snapshot), + "Attempting to take a census when the snapshot is not in a ready state. " + + `snapshot.state = ${snapshot.state}, ` + + `census.state = ${(getCensus(snapshot) || { state: null }).state}` + ); + + let report, parentMap; + let display = getDisplay(getState()); + let filter = getFilter(getState()); + + // If display, filter and inversion haven't changed, don't do anything. + if (censusIsUpToDate(filter, display, getCensus(snapshot))) { + removeFromCache(); + return; + } + + // Keep taking a census if the display changes while our request is in + // flight. Recheck that the display used for the census is the same as the + // state's display. + do { + display = getDisplay(getState()); + filter = getState().filter; + + dispatch({ + type: beginAction, + id, + filter, + display, + }); + + const opts = display.inverted + ? { asInvertedTreeNode: true } + : { asTreeNode: true }; + + opts.filter = filter || null; + + try { + ({ report, parentMap } = await heapWorker.takeCensus( + snapshot.path, + { breakdown: display.breakdown }, + opts + )); + } catch (error) { + removeFromCache(); + reportException("takeCensus", error); + dispatch({ type: errorAction, id, error }); + return; + } + } while ( + filter !== getState().filter || + display !== getDisplay(getState()) + ); + + removeFromCache(); + dispatch({ + type: endAction, + id, + display, + filter, + report, + parentMap, + }); + }, + }); +} + +/** + * Take a census. + */ +const takeCensus = (exports.takeCensus = makeTakeCensusTask({ + getDisplay: state => state.censusDisplay, + getFilter: state => state.filter, + getCensus: snapshot => snapshot.census, + beginAction: actions.TAKE_CENSUS_START, + endAction: actions.TAKE_CENSUS_END, + errorAction: actions.TAKE_CENSUS_ERROR, + canTakeCensus: snapshot => + snapshot.state === states.READ && + (!snapshot.census || snapshot.census.state === censusState.SAVED), +})); + +/** + * Take a census for the treemap. + */ +const takeTreeMap = (exports.takeTreeMap = makeTakeCensusTask({ + getDisplay: state => state.treeMapDisplay, + getFilter: () => null, + getCensus: snapshot => snapshot.treeMap, + beginAction: actions.TAKE_TREE_MAP_START, + endAction: actions.TAKE_TREE_MAP_END, + errorAction: actions.TAKE_TREE_MAP_ERROR, + canTakeCensus: snapshot => + snapshot.state === states.READ && + (!snapshot.treeMap || snapshot.treeMap.state === treeMapState.SAVED), +})); + +/** + * Define what should be the default mode for taking a census based on the + * default view of the tool. + */ +const defaultCensusTaker = takeTreeMap; + +/** + * Pick the default census taker when taking a snapshot. This should be + * determined by the current view. If the view doesn't include a census, then + * use the default one defined above. Some census information is always needed + * to display some basic information about a snapshot. + * + * @param {string} value from viewState + */ +const getCurrentCensusTaker = (exports.getCurrentCensusTaker = function ( + currentView +) { + switch (currentView) { + case viewState.TREE_MAP: + return takeTreeMap; + case viewState.CENSUS: + return takeCensus; + default: + return defaultCensusTaker; + } +}); + +/** + * Focus the given node in the individuals view. + * + * @param {DominatorTreeNode} node. + */ +exports.focusIndividual = function (node) { + return { + type: actions.FOCUS_INDIVIDUAL, + node, + }; +}; + +/** + * Fetch the individual `DominatorTreeNodes` for the census group specified by + * `censusBreakdown` and `reportLeafIndex`. + * + * @param {HeapAnalysesClient} heapWorker + * @param {SnapshotId} id + * @param {Object} censusBreakdown + * @param {Set<Number> | Number} reportLeafIndex + */ +const fetchIndividuals = (exports.fetchIndividuals = function ( + heapWorker, + id, + censusBreakdown, + reportLeafIndex +) { + return async function ({ dispatch, getState }) { + if (getState().view.state !== viewState.INDIVIDUALS) { + dispatch(view.changeView(viewState.INDIVIDUALS)); + } + + const snapshot = getSnapshot(getState(), id); + assert( + snapshot && snapshot.state === states.READ, + "The snapshot should already be read into memory" + ); + + if (!dominatorTreeIsComputed(snapshot)) { + await dispatch(computeAndFetchDominatorTree(heapWorker, id)); + } + + const snapshot_ = getSnapshot(getState(), id); + assert( + snapshot_.dominatorTree?.root, + "Should have a dominator tree with a root." + ); + + const dominatorTreeId = snapshot_.dominatorTree.dominatorTreeId; + + const indices = isSet(reportLeafIndex) + ? reportLeafIndex + : new Set([reportLeafIndex]); + + let labelDisplay; + let nodes; + do { + labelDisplay = getState().labelDisplay; + assert( + labelDisplay?.breakdown?.by, + `Should have a breakdown to label nodes with, got: ${JSON.stringify( + labelDisplay + )}` + ); + + if (getState().view.state !== viewState.INDIVIDUALS) { + // We switched views while in the process of fetching individuals -- any + // further work is useless. + return; + } + + dispatch({ type: actions.FETCH_INDIVIDUALS_START }); + + try { + ({ nodes } = await heapWorker.getCensusIndividuals({ + dominatorTreeId, + indices, + censusBreakdown, + labelBreakdown: labelDisplay.breakdown, + maxRetainingPaths: Preferences.get( + "devtools.memory.max-retaining-paths" + ), + maxIndividuals: Preferences.get("devtools.memory.max-individuals"), + })); + } catch (error) { + reportException("actions/snapshot/fetchIndividuals", error); + dispatch({ type: actions.INDIVIDUALS_ERROR, error }); + return; + } + } while (labelDisplay !== getState().labelDisplay); + + dispatch({ + type: actions.FETCH_INDIVIDUALS_END, + id, + censusBreakdown, + indices, + labelDisplay, + nodes, + dominatorTree: snapshot_.dominatorTree, + }); + }; +}); + +/** + * Refresh the current individuals view. + * + * @param {HeapAnalysesClient} heapWorker + */ +exports.refreshIndividuals = function (heapWorker) { + return async function ({ dispatch, getState }) { + assert( + getState().view.state === viewState.INDIVIDUALS, + "Should be in INDIVIDUALS view." + ); + + const { individuals } = getState(); + + switch (individuals.state) { + case individualsState.COMPUTING_DOMINATOR_TREE: + case individualsState.FETCHING: + // Nothing to do here. + return; + + case individualsState.FETCHED: + if (getState().individuals.labelDisplay === getState().labelDisplay) { + return; + } + break; + + case individualsState.ERROR: + // Doesn't hurt to retry: maybe we won't get an error this time around? + break; + + default: + assert(false, `Unexpected individuals state: ${individuals.state}`); + return; + } + + await dispatch( + fetchIndividuals( + heapWorker, + individuals.id, + individuals.censusBreakdown, + individuals.indices + ) + ); + }; +}; + +/** + * Refresh the selected snapshot's census data, if need be (for example, + * display configuration changed). + * + * @param {HeapAnalysesClient} heapWorker + */ +exports.refreshSelectedCensus = function (heapWorker) { + return async function ({ dispatch, getState }) { + const snapshot = getState().snapshots.find(s => s.selected); + if (!snapshot || snapshot.state !== states.READ) { + return; + } + + // Intermediate snapshot states will get handled by the task action that is + // orchestrating them. For example, if the snapshot census's state is + // SAVING, then the takeCensus action will keep taking a census until + // the inverted property matches the inverted state. If the snapshot is + // still in the process of being saved or read, the takeSnapshotAndCensus + // task action will follow through and ensure that a census is taken. + if ( + (snapshot.census && snapshot.census.state === censusState.SAVED) || + !snapshot.census + ) { + await dispatch(takeCensus(heapWorker, snapshot.id)); + } + }; +}; + +/** + * Refresh the selected snapshot's tree map data, if need be (for example, + * display configuration changed). + * + * @param {HeapAnalysesClient} heapWorker + */ +exports.refreshSelectedTreeMap = function (heapWorker) { + return async function ({ dispatch, getState }) { + const snapshot = getState().snapshots.find(s => s.selected); + if (!snapshot || snapshot.state !== states.READ) { + return; + } + + // Intermediate snapshot states will get handled by the task action that is + // orchestrating them. For example, if the snapshot census's state is + // SAVING, then the takeCensus action will keep taking a census until + // the inverted property matches the inverted state. If the snapshot is + // still in the process of being saved or read, the takeSnapshotAndCensus + // task action will follow through and ensure that a census is taken. + if ( + (snapshot.treeMap && snapshot.treeMap.state === treeMapState.SAVED) || + !snapshot.treeMap + ) { + await dispatch(takeTreeMap(heapWorker, snapshot.id)); + } + }; +}; + +/** + * Request that the `HeapAnalysesWorker` compute the dominator tree for the + * snapshot with the given `id`. + * + * @param {HeapAnalysesClient} heapWorker + * @param {SnapshotId} id + * + * @returns {Promise<DominatorTreeId>} + */ +const computeDominatorTree = (exports.computeDominatorTree = + TaskCache.declareCacheableTask({ + getCacheKey(_, id) { + return id; + }, + + async task(heapWorker, id, removeFromCache, dispatch, getState) { + const snapshot = getSnapshot(getState(), id); + assert( + !snapshot.dominatorTree?.dominatorTreeId, + "Should not re-compute dominator trees" + ); + + dispatch({ type: actions.COMPUTE_DOMINATOR_TREE_START, id }); + + let dominatorTreeId; + try { + dominatorTreeId = await heapWorker.computeDominatorTree(snapshot.path); + } catch (error) { + removeFromCache(); + reportException("actions/snapshot/computeDominatorTree", error); + dispatch({ type: actions.DOMINATOR_TREE_ERROR, id, error }); + return null; + } + + removeFromCache(); + dispatch({ + type: actions.COMPUTE_DOMINATOR_TREE_END, + id, + dominatorTreeId, + }); + return dominatorTreeId; + }, + })); + +/** + * Get the partial subtree, starting from the root, of the + * snapshot-with-the-given-id's dominator tree. + * + * @param {HeapAnalysesClient} heapWorker + * @param {SnapshotId} id + * + * @returns {Promise<DominatorTreeNode>} + */ +const fetchDominatorTree = (exports.fetchDominatorTree = + TaskCache.declareCacheableTask({ + getCacheKey(_, id) { + return id; + }, + + async task(heapWorker, id, removeFromCache, dispatch, getState) { + const snapshot = getSnapshot(getState(), id); + assert( + dominatorTreeIsComputed(snapshot), + "Should have dominator tree model and it should be computed" + ); + + let display; + let root; + do { + display = getState().labelDisplay; + assert( + display?.breakdown, + `Should have a breakdown to describe nodes with, got: ${JSON.stringify( + display + )}` + ); + + dispatch({ type: actions.FETCH_DOMINATOR_TREE_START, id, display }); + + try { + root = await heapWorker.getDominatorTree({ + dominatorTreeId: snapshot.dominatorTree.dominatorTreeId, + breakdown: display.breakdown, + maxRetainingPaths: Preferences.get( + "devtools.memory.max-retaining-paths" + ), + }); + } catch (error) { + removeFromCache(); + reportException("actions/snapshot/fetchDominatorTree", error); + dispatch({ type: actions.DOMINATOR_TREE_ERROR, id, error }); + return null; + } + } while (display !== getState().labelDisplay); + + removeFromCache(); + dispatch({ type: actions.FETCH_DOMINATOR_TREE_END, id, root }); + return root; + }, + })); + +/** + * Fetch the immediately dominated children represented by the placeholder + * `lazyChildren` from snapshot-with-the-given-id's dominator tree. + * + * @param {HeapAnalysesClient} heapWorker + * @param {SnapshotId} id + * @param {DominatorTreeLazyChildren} lazyChildren + */ +exports.fetchImmediatelyDominated = TaskCache.declareCacheableTask({ + getCacheKey(_, id, lazyChildren) { + return `${id}-${lazyChildren.key()}`; + }, + + async task( + heapWorker, + id, + lazyChildren, + removeFromCache, + dispatch, + getState + ) { + const snapshot = getSnapshot(getState(), id); + assert(snapshot.dominatorTree, "Should have dominator tree model"); + assert( + snapshot.dominatorTree.state === dominatorTreeState.LOADED || + snapshot.dominatorTree.state === + dominatorTreeState.INCREMENTAL_FETCHING, + "Cannot fetch immediately dominated nodes in a dominator tree unless " + + " the dominator tree has already been computed" + ); + + let display; + let response; + do { + display = getState().labelDisplay; + assert(display, "Should have a display to describe nodes with."); + + dispatch({ type: actions.FETCH_IMMEDIATELY_DOMINATED_START, id }); + + try { + response = await heapWorker.getImmediatelyDominated({ + dominatorTreeId: snapshot.dominatorTree.dominatorTreeId, + breakdown: display.breakdown, + nodeId: lazyChildren.parentNodeId(), + startIndex: lazyChildren.siblingIndex(), + maxRetainingPaths: Preferences.get( + "devtools.memory.max-retaining-paths" + ), + }); + } catch (error) { + removeFromCache(); + reportException("actions/snapshot/fetchImmediatelyDominated", error); + dispatch({ type: actions.DOMINATOR_TREE_ERROR, id, error }); + return; + } + } while (display !== getState().labelDisplay); + + removeFromCache(); + dispatch({ + type: actions.FETCH_IMMEDIATELY_DOMINATED_END, + id, + path: response.path, + nodes: response.nodes, + moreChildrenAvailable: response.moreChildrenAvailable, + }); + }, +}); + +/** + * Compute and then fetch the dominator tree of the snapshot with the given + * `id`. + * + * @param {HeapAnalysesClient} heapWorker + * @param {SnapshotId} id + * + * @returns {Promise<DominatorTreeNode>} + */ +const computeAndFetchDominatorTree = (exports.computeAndFetchDominatorTree = + TaskCache.declareCacheableTask({ + getCacheKey(_, id) { + return id; + }, + + async task(heapWorker, id, removeFromCache, dispatch, getState) { + const dominatorTreeId = await dispatch( + computeDominatorTree(heapWorker, id) + ); + if (dominatorTreeId === null) { + removeFromCache(); + return null; + } + + const root = await dispatch(fetchDominatorTree(heapWorker, id)); + removeFromCache(); + + if (!root) { + return null; + } + + return root; + }, + })); + +/** + * Update the currently selected snapshot's dominator tree. + * + * @param {HeapAnalysesClient} heapWorker + */ +exports.refreshSelectedDominatorTree = function (heapWorker) { + return async function ({ dispatch, getState }) { + const snapshot = getState().snapshots.find(s => s.selected); + if (!snapshot) { + return; + } + + if ( + snapshot.dominatorTree && + !( + snapshot.dominatorTree.state === dominatorTreeState.COMPUTED || + snapshot.dominatorTree.state === dominatorTreeState.LOADED || + snapshot.dominatorTree.state === dominatorTreeState.INCREMENTAL_FETCHING + ) + ) { + return; + } + + // We need to check for the snapshot state because if there was an error, + // we can't continue and if we are still saving or reading the snapshot, + // then takeSnapshotAndCensus will finish the job for us + if (snapshot.state === states.READ) { + if (snapshot.dominatorTree) { + await dispatch(fetchDominatorTree(heapWorker, snapshot.id)); + } else { + await dispatch(computeAndFetchDominatorTree(heapWorker, snapshot.id)); + } + } + }; +}; + +/** + * Select the snapshot with the given id. + * + * @param {snapshotId} id + * @see {Snapshot} model defined in devtools/client/memory/models.js + */ +const selectSnapshot = (exports.selectSnapshot = function (id) { + return { + type: actions.SELECT_SNAPSHOT, + id, + }; +}); + +/** + * Delete all snapshots that are in the READ or ERROR state + * + * @param {HeapAnalysesClient} heapWorker + */ +exports.clearSnapshots = function (heapWorker) { + return async function ({ dispatch, getState }) { + const snapshots = getState().snapshots.filter(s => { + const snapshotReady = s.state === states.READ || s.state === states.ERROR; + const censusReady = + (s.treeMap && s.treeMap.state === treeMapState.SAVED) || + (s.census && s.census.state === censusState.SAVED); + + return snapshotReady && censusReady; + }); + + const ids = snapshots.map(s => s.id); + + dispatch({ type: actions.DELETE_SNAPSHOTS_START, ids }); + + if (getState().diffing) { + dispatch(diffing.toggleDiffing()); + } + if (getState().individuals) { + dispatch(view.popView()); + } + + await Promise.all( + snapshots.map(snapshot => { + return heapWorker.deleteHeapSnapshot(snapshot.path).catch(error => { + reportException("clearSnapshots", error); + dispatch({ type: actions.SNAPSHOT_ERROR, id: snapshot.id, error }); + }); + }) + ); + + dispatch({ type: actions.DELETE_SNAPSHOTS_END, ids }); + }; +}; + +/** + * Delete a snapshot + * + * @param {HeapAnalysesClient} heapWorker + * @param {snapshotModel} snapshot + */ +exports.deleteSnapshot = function (heapWorker, snapshot) { + return async function ({ dispatch, getState }) { + dispatch({ type: actions.DELETE_SNAPSHOTS_START, ids: [snapshot.id] }); + + try { + await heapWorker.deleteHeapSnapshot(snapshot.path); + } catch (error) { + reportException("deleteSnapshot", error); + dispatch({ type: actions.SNAPSHOT_ERROR, id: snapshot.id, error }); + } + + dispatch({ type: actions.DELETE_SNAPSHOTS_END, ids: [snapshot.id] }); + }; +}; + +/** + * Expand the given node in the snapshot's census report. + * + * @param {CensusTreeNode} node + */ +exports.expandCensusNode = function (id, node) { + return { + type: actions.EXPAND_CENSUS_NODE, + id, + node, + }; +}; + +/** + * Collapse the given node in the snapshot's census report. + * + * @param {CensusTreeNode} node + */ +exports.collapseCensusNode = function (id, node) { + return { + type: actions.COLLAPSE_CENSUS_NODE, + id, + node, + }; +}; + +/** + * Focus the given node in the snapshot's census's report. + * + * @param {SnapshotId} id + * @param {DominatorTreeNode} node + */ +exports.focusCensusNode = function (id, node) { + return { + type: actions.FOCUS_CENSUS_NODE, + id, + node, + }; +}; + +/** + * Expand the given node in the snapshot's dominator tree. + * + * @param {DominatorTreeTreeNode} node + */ +exports.expandDominatorTreeNode = function (id, node) { + return { + type: actions.EXPAND_DOMINATOR_TREE_NODE, + id, + node, + }; +}; + +/** + * Collapse the given node in the snapshot's dominator tree. + * + * @param {DominatorTreeTreeNode} node + */ +exports.collapseDominatorTreeNode = function (id, node) { + return { + type: actions.COLLAPSE_DOMINATOR_TREE_NODE, + id, + node, + }; +}; + +/** + * Focus the given node in the snapshot's dominator tree. + * + * @param {SnapshotId} id + * @param {DominatorTreeNode} node + */ +exports.focusDominatorTreeNode = function (id, node) { + return { + type: actions.FOCUS_DOMINATOR_TREE_NODE, + id, + node, + }; +}; diff --git a/devtools/client/memory/actions/task-cache.js b/devtools/client/memory/actions/task-cache.js new file mode 100644 index 0000000000..c472cb69da --- /dev/null +++ b/devtools/client/memory/actions/task-cache.js @@ -0,0 +1,105 @@ +/* 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 { assert } = require("resource://devtools/shared/DevToolsUtils.js"); + +/** + * The `TaskCache` allows for re-using active tasks when spawning a second task + * would simply duplicate work and is unnecessary. It maps from a task's unique + * key to the promise of its result. + */ +const TaskCache = (module.exports = class TaskCache { + constructor() { + this._cache = new Map(); + } + + /** + * Get the promise keyed by the given unique `key`, if one exists. + * + * @param {Any} key + * @returns {Promise<Any> | undefined} + */ + get(key) { + return this._cache.get(key); + } + + /** + * Put the task result promise in the cache and associate it with the given + * `key` which must not already have an entry in the cache. + * + * @param {Any} key + * @param {Promise<Any>} promise + */ + put(key, promise) { + assert(!this._cache.has(key), "We should not override extant entries"); + + this._cache.set(key, promise); + } + + /** + * Remove the cache entry with the given key. + * + * @param {Any} key + */ + remove(key) { + assert( + this._cache.has(key), + `Should have an extant entry for key = ${key}` + ); + + this._cache.delete(key); + } +}); + +/** + * Create a new action-orchestrating task that is automatically cached. The + * tasks themselves are responsible from removing themselves from the cache. + * + * @param {Function(...args) -> Any} getCacheKey + * @param {Generator(...args) -> Any} task + * + * @returns Cacheable, Action-Creating Task + */ +TaskCache.declareCacheableTask = function ({ getCacheKey, task }) { + const cache = new TaskCache(); + + return function (...args) { + return async function ({ dispatch, getState }) { + const key = getCacheKey(...args); + + const extantResult = cache.get(key); + if (extantResult) { + return extantResult; + } + + // Ensure that we have our new entry in the cache *before* dispatching the + // task! + let resolve; + cache.put( + key, + new Promise(r => { + resolve = r; + }) + ); + + resolve( + dispatch(async function () { + try { + args.push(() => cache.remove(key), dispatch, getState); + return await task(...args); + } catch (error) { + // Don't perma-cache errors. + if (cache.get(key)) { + cache.remove(key); + } + throw error; + } + }) + ); + + return cache.get(key); + }; + }; +}; diff --git a/devtools/client/memory/actions/tree-map-display.js b/devtools/client/memory/actions/tree-map-display.js new file mode 100644 index 0000000000..e1c4a21132 --- /dev/null +++ b/devtools/client/memory/actions/tree-map-display.js @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { actions } = require("resource://devtools/client/memory/constants.js"); +const { + refresh, +} = require("resource://devtools/client/memory/actions/refresh.js"); +/** + * Sets the tree map display as the current display and refreshes the tree map + * census. + */ +exports.setTreeMapAndRefresh = function (heapWorker, display) { + return async function ({ dispatch, getState }) { + dispatch(setTreeMap(display)); + await dispatch(refresh(heapWorker)); + }; +}; + +/** + * Clears out all cached census data in the snapshots and sets new display data + * for tree maps. + * + * @param {treeMapModel} display + */ +const setTreeMap = (exports.setTreeMap = function (display) { + assert( + typeof display === "object" && + display && + display.breakdown && + display.breakdown.by, + "Breakdowns must be an object with a `by` property, attempted to set: " + + JSON.stringify(display) + ); + + return { + type: actions.SET_TREE_MAP_DISPLAY, + display, + }; +}); diff --git a/devtools/client/memory/actions/view.js b/devtools/client/memory/actions/view.js new file mode 100644 index 0000000000..af1bc7b21a --- /dev/null +++ b/devtools/client/memory/actions/view.js @@ -0,0 +1,69 @@ +/* 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 { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { actions } = require("resource://devtools/client/memory/constants.js"); +const { + findSelectedSnapshot, +} = require("resource://devtools/client/memory/utils.js"); +const refresh = require("resource://devtools/client/memory/actions/refresh.js"); + +/** + * Change the currently selected view. + * + * @param {viewState} view + */ +const changeView = (exports.changeView = function (view) { + return function ({ dispatch, getState }) { + dispatch({ + type: actions.CHANGE_VIEW, + newViewState: view, + oldDiffing: getState().diffing, + oldSelected: findSelectedSnapshot(getState()), + }); + }; +}); + +/** + * Given that we are in the INDIVIDUALS view state, go back to the state we were + * in before. + */ +const popView = (exports.popView = function () { + return function ({ dispatch, getState }) { + const { previous } = getState().view; + assert(previous); + dispatch({ + type: actions.POP_VIEW, + previousView: previous, + }); + }; +}); + +/** + * Change the currently selected view and ensure all our data is up to date from + * the heap worker. + * + * @param {viewState} view + * @param {HeapAnalysesClient} heapWorker + */ +exports.changeViewAndRefresh = function (view, heapWorker) { + return async function ({ dispatch, getState }) { + dispatch(changeView(view)); + await dispatch(refresh.refresh(heapWorker)); + }; +}; + +/** + * Given that we are in the INDIVIDUALS view state, go back to the state we were + * previously in and refresh our data. + * + * @param {HeapAnalysesClient} heapWorker + */ +exports.popViewAndRefresh = function (heapWorker) { + return async function ({ dispatch, getState }) { + dispatch(popView()); + await dispatch(refresh.refresh(heapWorker)); + }; +}; diff --git a/devtools/client/memory/app.js b/devtools/client/memory/app.js new file mode 100644 index 0000000000..1549cf23eb --- /dev/null +++ b/devtools/client/memory/app.js @@ -0,0 +1,441 @@ +/* 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 { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const { + censusDisplays, + labelDisplays, + treeMapDisplays, + diffingState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + toggleRecordingAllocationStacks, +} = require("resource://devtools/client/memory/actions/allocations.js"); +const { + setCensusDisplayAndRefresh, +} = require("resource://devtools/client/memory/actions/census-display.js"); +const { + setLabelDisplayAndRefresh, +} = require("resource://devtools/client/memory/actions/label-display.js"); +const { + setTreeMapDisplayAndRefresh, +} = require("resource://devtools/client/memory/actions/tree-map-display.js"); + +const { + getCustomCensusDisplays, + getCustomLabelDisplays, + getCustomTreeMapDisplays, +} = require("resource://devtools/client/memory/utils.js"); +const { + selectSnapshotForDiffingAndRefresh, + toggleDiffing, + expandDiffingCensusNode, + collapseDiffingCensusNode, + focusDiffingCensusNode, +} = require("resource://devtools/client/memory/actions/diffing.js"); +const { + setFilterStringAndRefresh, +} = require("resource://devtools/client/memory/actions/filter.js"); +const { + pickFileAndExportSnapshot, + pickFileAndImportSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/io.js"); +const { + selectSnapshotAndRefresh, + takeSnapshotAndCensus, + clearSnapshots, + deleteSnapshot, + fetchImmediatelyDominated, + expandCensusNode, + collapseCensusNode, + focusCensusNode, + expandDominatorTreeNode, + collapseDominatorTreeNode, + focusDominatorTreeNode, + fetchIndividuals, + focusIndividual, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeViewAndRefresh, + popViewAndRefresh, +} = require("resource://devtools/client/memory/actions/view.js"); +const { + resizeShortestPaths, +} = require("resource://devtools/client/memory/actions/sizes.js"); +const Toolbar = createFactory( + require("resource://devtools/client/memory/components/Toolbar.js") +); +const List = createFactory( + require("resource://devtools/client/memory/components/List.js") +); +const SnapshotListItem = createFactory( + require("resource://devtools/client/memory/components/SnapshotListItem.js") +); +const Heap = createFactory( + require("resource://devtools/client/memory/components/Heap.js") +); +const { + app: appModel, +} = require("resource://devtools/client/memory/models.js"); + +class MemoryApp extends Component { + static get propTypes() { + return { + allocations: appModel.allocations, + censusDisplay: appModel.censusDisplay, + commands: appModel.commands, + diffing: appModel.diffing, + dispatch: PropTypes.func, + filter: appModel.filter, + front: appModel.front, + heapWorker: appModel.heapWorker, + individuals: appModel.individuals, + labelDisplay: appModel.labelDisplay, + sizes: PropTypes.object, + snapshots: appModel.snapshots, + toolbox: PropTypes.object, + view: appModel.view, + }; + } + + static get childContextTypes() { + return { + front: PropTypes.any, + heapWorker: PropTypes.any, + toolbox: PropTypes.any, + }; + } + + static get defaultProps() { + return {}; + } + + constructor(props) { + super(props); + this.onKeyDown = this.onKeyDown.bind(this); + this._getCensusDisplays = this._getCensusDisplays.bind(this); + this._getLabelDisplays = this._getLabelDisplays.bind(this); + this._getTreeMapDisplays = this._getTreeMapDisplays.bind(this); + } + + getChildContext() { + return { + front: this.props.front, + heapWorker: this.props.heapWorker, + toolbox: this.props.toolbox, + }; + } + + componentDidMount() { + // Attach the keydown listener directly to the window. When an element that + // has the focus (such as a tree node) is removed from the DOM, the focus + // falls back to the body. + window.addEventListener("keydown", this.onKeyDown); + } + + componentWillUnmount() { + window.removeEventListener("keydown", this.onKeyDown); + } + + onKeyDown(e) { + const { snapshots, dispatch, heapWorker } = this.props; + const selectedSnapshot = snapshots.find(s => s.selected); + const selectedIndex = snapshots.indexOf(selectedSnapshot); + + const isOSX = Services.appinfo.OS == "Darwin"; + const isAccelKey = (isOSX && e.metaKey) || (!isOSX && e.ctrlKey); + + // On ACCEL+UP, select previous snapshot. + if (isAccelKey && e.key === "ArrowUp") { + const previousIndex = Math.max(0, selectedIndex - 1); + const previousSnapshotId = snapshots[previousIndex].id; + dispatch(selectSnapshotAndRefresh(heapWorker, previousSnapshotId)); + } + + // On ACCEL+DOWN, select next snapshot. + if (isAccelKey && e.key === "ArrowDown") { + const nextIndex = Math.min(snapshots.length - 1, selectedIndex + 1); + const nextSnapshotId = snapshots[nextIndex].id; + dispatch(selectSnapshotAndRefresh(heapWorker, nextSnapshotId)); + } + } + + _getCensusDisplays() { + const customDisplays = getCustomCensusDisplays(); + const custom = Object.keys(customDisplays).reduce((arr, key) => { + arr.push(customDisplays[key]); + return arr; + }, []); + + return [ + censusDisplays.coarseType, + censusDisplays.allocationStack, + censusDisplays.invertedAllocationStack, + ].concat(custom); + } + + _getLabelDisplays() { + const customDisplays = getCustomLabelDisplays(); + const custom = Object.keys(customDisplays).reduce((arr, key) => { + arr.push(customDisplays[key]); + return arr; + }, []); + + return [labelDisplays.coarseType, labelDisplays.allocationStack].concat( + custom + ); + } + + _getTreeMapDisplays() { + const customDisplays = getCustomTreeMapDisplays(); + const custom = Object.keys(customDisplays).reduce((arr, key) => { + arr.push(customDisplays[key]); + return arr; + }, []); + + return [treeMapDisplays.coarseType].concat(custom); + } + + render() { + const { + commands, + dispatch, + snapshots, + front, + heapWorker, + allocations, + toolbox, + filter, + diffing, + view, + sizes, + censusDisplay, + labelDisplay, + individuals, + } = this.props; + + const selectedSnapshot = snapshots.find(s => s.selected); + + const onClickSnapshotListItem = + diffing && diffing.state === diffingState.SELECTING + ? snapshot => + dispatch(selectSnapshotForDiffingAndRefresh(heapWorker, snapshot)) + : snapshot => + dispatch(selectSnapshotAndRefresh(heapWorker, snapshot.id)); + + return dom.div( + { + id: "memory-tool", + }, + + Toolbar({ + snapshots, + censusDisplays: this._getCensusDisplays(), + censusDisplay, + onCensusDisplayChange: newDisplay => + dispatch(setCensusDisplayAndRefresh(heapWorker, newDisplay)), + onImportClick: () => + dispatch(pickFileAndImportSnapshotAndCensus(heapWorker)), + onClearSnapshotsClick: () => dispatch(clearSnapshots(heapWorker)), + onTakeSnapshotClick: () => + dispatch(takeSnapshotAndCensus(front, heapWorker)), + onToggleRecordAllocationStacks: () => + dispatch(toggleRecordingAllocationStacks(commands)), + allocations, + filterString: filter, + setFilterString: filterString => + dispatch(setFilterStringAndRefresh(filterString, heapWorker)), + diffing, + onToggleDiffing: () => dispatch(toggleDiffing()), + view, + labelDisplays: this._getLabelDisplays(), + labelDisplay, + onLabelDisplayChange: newDisplay => + dispatch(setLabelDisplayAndRefresh(heapWorker, newDisplay)), + treeMapDisplays: this._getTreeMapDisplays(), + onTreeMapDisplayChange: newDisplay => + dispatch(setTreeMapDisplayAndRefresh(heapWorker, newDisplay)), + onViewChange: v => dispatch(changeViewAndRefresh(v, heapWorker)), + }), + + dom.div( + { + id: "memory-tool-container", + }, + + List({ + itemComponent: SnapshotListItem, + items: snapshots, + onSave: snapshot => dispatch(pickFileAndExportSnapshot(snapshot)), + onDelete: snapshot => dispatch(deleteSnapshot(heapWorker, snapshot)), + onClick: onClickSnapshotListItem, + diffing, + }), + + Heap({ + snapshot: selectedSnapshot, + diffing, + onViewSourceInDebugger: ({ url, line, column }) => { + toolbox.viewSourceInDebugger(url, line, column); + }, + onSnapshotClick: () => + dispatch(takeSnapshotAndCensus(front, heapWorker)), + onLoadMoreSiblings: lazyChildren => + dispatch( + fetchImmediatelyDominated( + heapWorker, + selectedSnapshot.id, + lazyChildren + ) + ), + onPopView: () => dispatch(popViewAndRefresh(heapWorker)), + individuals, + onViewIndividuals: node => { + const snapshotId = diffing + ? diffing.secondSnapshotId + : selectedSnapshot.id; + dispatch( + fetchIndividuals( + heapWorker, + snapshotId, + censusDisplay.breakdown, + node.reportLeafIndex + ) + ); + }, + onFocusIndividual: node => { + assert( + view.state === viewState.INDIVIDUALS, + "Should be in the individuals view" + ); + dispatch(focusIndividual(node)); + }, + onCensusExpand: (census, node) => { + if (diffing) { + assert( + diffing.census === census, + "Should only expand active census" + ); + dispatch(expandDiffingCensusNode(node)); + } else { + assert( + selectedSnapshot && selectedSnapshot.census === census, + "If not diffing, " + + "should be expanding on selected snapshot's census" + ); + dispatch(expandCensusNode(selectedSnapshot.id, node)); + } + }, + onCensusCollapse: (census, node) => { + if (diffing) { + assert( + diffing.census === census, + "Should only collapse active census" + ); + dispatch(collapseDiffingCensusNode(node)); + } else { + assert( + selectedSnapshot && selectedSnapshot.census === census, + "If not diffing, " + + "should be collapsing on selected snapshot's census" + ); + dispatch(collapseCensusNode(selectedSnapshot.id, node)); + } + }, + onCensusFocus: (census, node) => { + if (diffing) { + assert( + diffing.census === census, + "Should only focus nodes in active census" + ); + dispatch(focusDiffingCensusNode(node)); + } else { + assert( + selectedSnapshot && selectedSnapshot.census === census, + "If not diffing, " + + "should be focusing on nodes in selected snapshot's census" + ); + dispatch(focusCensusNode(selectedSnapshot.id, node)); + } + }, + onDominatorTreeExpand: node => { + assert( + view.state === viewState.DOMINATOR_TREE, + "If expanding dominator tree nodes, " + + "should be in dominator tree view" + ); + assert( + selectedSnapshot, + "...and we should have a selected snapshot" + ); + assert( + selectedSnapshot.dominatorTree, + "...and that snapshot should have a dominator tree" + ); + dispatch(expandDominatorTreeNode(selectedSnapshot.id, node)); + }, + onDominatorTreeCollapse: node => { + assert( + view.state === viewState.DOMINATOR_TREE, + "If collapsing dominator tree nodes, " + + "should be in dominator tree view" + ); + assert( + selectedSnapshot, + "...and we should have a selected snapshot" + ); + assert( + selectedSnapshot.dominatorTree, + "...and that snapshot should have a dominator tree" + ); + dispatch(collapseDominatorTreeNode(selectedSnapshot.id, node)); + }, + onDominatorTreeFocus: node => { + assert( + view.state === viewState.DOMINATOR_TREE, + "If focusing dominator tree nodes, " + + "should be in dominator tree view" + ); + assert( + selectedSnapshot, + "...and we should have a selected snapshot" + ); + assert( + selectedSnapshot.dominatorTree, + "...and that snapshot should have a dominator tree" + ); + dispatch(focusDominatorTreeNode(selectedSnapshot.id, node)); + }, + onShortestPathsResize: newSize => { + dispatch(resizeShortestPaths(newSize)); + }, + sizes, + view, + }) + ) + ); + } +} + +/** + * Passed into react-redux's `connect` method that is called on store change + * and passed to components. + */ +function mapStateToProps(state) { + return state; +} + +module.exports = connect(mapStateToProps)(MemoryApp); diff --git a/devtools/client/memory/components/Census.js b/devtools/client/memory/components/Census.js new file mode 100644 index 0000000000..d33050b019 --- /dev/null +++ b/devtools/client/memory/components/Census.js @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const Tree = createFactory( + require("resource://devtools/client/shared/components/VirtualizedTree.js") +); +const CensusTreeItem = createFactory( + require("resource://devtools/client/memory/components/CensusTreeItem.js") +); +const { + TREE_ROW_HEIGHT, +} = require("resource://devtools/client/memory/constants.js"); +const { + censusModel, + diffingModel, +} = require("resource://devtools/client/memory/models.js"); + +class Census extends Component { + static get propTypes() { + return { + census: censusModel, + onExpand: PropTypes.func.isRequired, + onCollapse: PropTypes.func.isRequired, + onFocus: PropTypes.func.isRequired, + onViewSourceInDebugger: PropTypes.func.isRequired, + onViewIndividuals: PropTypes.func.isRequired, + diffing: diffingModel, + }; + } + + render() { + const { + census, + onExpand, + onCollapse, + onFocus, + diffing, + onViewSourceInDebugger, + onViewIndividuals, + } = this.props; + + const report = census.report; + const parentMap = census.parentMap; + const { totalBytes, totalCount } = report; + + const getPercentBytes = + totalBytes === 0 ? _ => 0 : bytes => (bytes / totalBytes) * 100; + + const getPercentCount = + totalCount === 0 ? _ => 0 : count => (count / totalCount) * 100; + + return Tree({ + autoExpandDepth: 0, + preventNavigationOnArrowRight: false, + focused: census.focused, + getParent: node => { + const parent = parentMap[node.id]; + return parent === report ? null : parent; + }, + getChildren: node => node.children || [], + isExpanded: node => census.expanded.has(node.id), + onExpand, + onCollapse, + onFocus, + renderItem: (item, depth, focused, arrow, expanded) => + new CensusTreeItem({ + onViewSourceInDebugger, + item, + depth, + focused, + arrow, + expanded, + getPercentBytes, + getPercentCount, + diffing, + inverted: census.display.inverted, + onViewIndividuals, + }), + getRoots: () => report.children || [], + getKey: node => node.id, + itemHeight: TREE_ROW_HEIGHT, + }); + } +} + +module.exports = Census; diff --git a/devtools/client/memory/components/CensusHeader.js b/devtools/client/memory/components/CensusHeader.js new file mode 100644 index 0000000000..bdd9d15087 --- /dev/null +++ b/devtools/client/memory/components/CensusHeader.js @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { L10N } = require("resource://devtools/client/memory/utils.js"); +const models = require("resource://devtools/client/memory/models.js"); + +class CensusHeader extends Component { + static get propTypes() { + return { + diffing: models.diffingModel, + }; + } + + render() { + let individualsCell; + if (!this.props.diffing) { + individualsCell = dom.span({ + className: "heap-tree-item-field heap-tree-item-individuals", + }); + } + + return dom.div( + { + className: "header", + }, + + dom.span( + { + className: "heap-tree-item-bytes", + title: L10N.getStr("heapview.field.bytes.tooltip"), + }, + L10N.getStr("heapview.field.bytes") + ), + + dom.span( + { + className: "heap-tree-item-count", + title: L10N.getStr("heapview.field.count.tooltip"), + }, + L10N.getStr("heapview.field.count") + ), + + dom.span( + { + className: "heap-tree-item-total-bytes", + title: L10N.getStr("heapview.field.totalbytes.tooltip"), + }, + L10N.getStr("heapview.field.totalbytes") + ), + + dom.span( + { + className: "heap-tree-item-total-count", + title: L10N.getStr("heapview.field.totalcount.tooltip"), + }, + L10N.getStr("heapview.field.totalcount") + ), + + individualsCell, + + dom.span( + { + className: "heap-tree-item-name", + title: L10N.getStr("heapview.field.name.tooltip"), + }, + L10N.getStr("heapview.field.name") + ) + ); + } +} + +module.exports = CensusHeader; diff --git a/devtools/client/memory/components/CensusTreeItem.js b/devtools/client/memory/components/CensusTreeItem.js new file mode 100644 index 0000000000..39fdbeb052 --- /dev/null +++ b/devtools/client/memory/components/CensusTreeItem.js @@ -0,0 +1,185 @@ +/* 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 { isSavedFrame } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + L10N, + formatNumber, + formatPercent, +} = require("resource://devtools/client/memory/utils.js"); +const Frame = createFactory( + require("resource://devtools/client/shared/components/Frame.js") +); +const { + TREE_ROW_HEIGHT, +} = require("resource://devtools/client/memory/constants.js"); +const models = require("resource://devtools/client/memory/models.js"); + +class CensusTreeItem extends Component { + static get propTypes() { + return { + arrow: PropTypes.any, + depth: PropTypes.number.isRequired, + diffing: models.app.diffing, + expanded: PropTypes.bool.isRequired, + focused: PropTypes.bool.isRequired, + getPercentBytes: PropTypes.func.isRequired, + getPercentCount: PropTypes.func.isRequired, + inverted: PropTypes.bool, + item: PropTypes.object.isRequired, + onViewIndividuals: PropTypes.func.isRequired, + onViewSourceInDebugger: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + this.toLabel = this.toLabel.bind(this); + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + this.props.item != nextProps.item || + this.props.depth != nextProps.depth || + this.props.expanded != nextProps.expanded || + this.props.focused != nextProps.focused || + this.props.diffing != nextProps.diffing + ); + } + + toLabel(name, onViewSourceInDebugger) { + if (isSavedFrame(name)) { + return Frame({ + frame: name, + onClick: onViewSourceInDebugger, + showFunctionName: true, + showHost: true, + }); + } + + if (name === null) { + return L10N.getStr("tree-item.root"); + } + + if (name === "noStack") { + return L10N.getStr("tree-item.nostack"); + } + + if (name === "noFilename") { + return L10N.getStr("tree-item.nofilename"); + } + + return String(name); + } + + render() { + const { + item, + depth, + arrow, + focused, + getPercentBytes, + getPercentCount, + diffing, + onViewSourceInDebugger, + onViewIndividuals, + inverted, + } = this.props; + + const bytes = formatNumber(item.bytes, !!diffing); + const percentBytes = formatPercent(getPercentBytes(item.bytes), !!diffing); + + const count = formatNumber(item.count, !!diffing); + const percentCount = formatPercent(getPercentCount(item.count), !!diffing); + + const totalBytes = formatNumber(item.totalBytes, !!diffing); + const percentTotalBytes = formatPercent( + getPercentBytes(item.totalBytes), + !!diffing + ); + + const totalCount = formatNumber(item.totalCount, !!diffing); + const percentTotalCount = formatPercent( + getPercentCount(item.totalCount), + !!diffing + ); + + let pointer; + if (inverted && depth > 0) { + pointer = dom.span({ className: "children-pointer" }, "↖"); + } else if (!inverted && item.children?.length) { + pointer = dom.span({ className: "children-pointer" }, "↘"); + } + + let individualsCell; + if (!diffing) { + let individualsButton; + if (item.reportLeafIndex !== undefined) { + individualsButton = dom.button( + { + key: `individuals-button-${item.id}`, + title: L10N.getStr("tree-item.view-individuals.tooltip"), + className: "devtools-button individuals-button", + onClick: e => { + // Don't let the event bubble up to cause this item to focus after + // we have switched views, which would lead to assertion failures. + e.preventDefault(); + e.stopPropagation(); + + onViewIndividuals(item); + }, + }, + "⁂" + ); + } + individualsCell = dom.span( + { className: "heap-tree-item-field heap-tree-item-individuals" }, + individualsButton + ); + } + + return dom.div( + { className: `heap-tree-item ${focused ? "focused" : ""}` }, + dom.span( + { className: "heap-tree-item-field heap-tree-item-bytes" }, + dom.span({ className: "heap-tree-number" }, bytes), + dom.span({ className: "heap-tree-percent" }, percentBytes) + ), + dom.span( + { className: "heap-tree-item-field heap-tree-item-count" }, + dom.span({ className: "heap-tree-number" }, count), + dom.span({ className: "heap-tree-percent" }, percentCount) + ), + dom.span( + { className: "heap-tree-item-field heap-tree-item-total-bytes" }, + dom.span({ className: "heap-tree-number" }, totalBytes), + dom.span({ className: "heap-tree-percent" }, percentTotalBytes) + ), + dom.span( + { className: "heap-tree-item-field heap-tree-item-total-count" }, + dom.span({ className: "heap-tree-number" }, totalCount), + dom.span({ className: "heap-tree-percent" }, percentTotalCount) + ), + individualsCell, + dom.span( + { + className: "heap-tree-item-field heap-tree-item-name", + style: { marginInlineStart: depth * TREE_ROW_HEIGHT }, + }, + arrow, + pointer, + this.toLabel(item.name, onViewSourceInDebugger) + ) + ); + } +} + +module.exports = CensusTreeItem; diff --git a/devtools/client/memory/components/DominatorTree.js b/devtools/client/memory/components/DominatorTree.js new file mode 100644 index 0000000000..9c767cece7 --- /dev/null +++ b/devtools/client/memory/components/DominatorTree.js @@ -0,0 +1,250 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + createParentMap, +} = require("resource://devtools/shared/heapsnapshot/CensusUtils.js"); +const Tree = createFactory( + require("resource://devtools/client/shared/components/VirtualizedTree.js") +); +const DominatorTreeItem = createFactory( + require("resource://devtools/client/memory/components/DominatorTreeItem.js") +); +const { L10N } = require("resource://devtools/client/memory/utils.js"); +const { + TREE_ROW_HEIGHT, + dominatorTreeState, +} = require("resource://devtools/client/memory/constants.js"); +const { + dominatorTreeModel, +} = require("resource://devtools/client/memory/models.js"); +const DominatorTreeLazyChildren = require("resource://devtools/client/memory/dominator-tree-lazy-children.js"); + +const DOMINATOR_TREE_AUTO_EXPAND_DEPTH = 3; + +/** + * A throbber that represents a subtree in the dominator tree that is actively + * being incrementally loaded and fetched from the `HeapAnalysesWorker`. + */ +class DominatorTreeSubtreeFetchingClass extends Component { + static get propTypes() { + return { + depth: PropTypes.number.isRequired, + focused: PropTypes.bool.isRequired, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + this.props.depth !== nextProps.depth || + this.props.focused !== nextProps.focused + ); + } + + render() { + const { depth, focused } = this.props; + + return dom.div( + { + className: `heap-tree-item subtree-fetching ${ + focused ? "focused" : "" + }`, + }, + dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }), + dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }), + dom.span({ + className: "heap-tree-item-field heap-tree-item-name devtools-throbber", + style: { marginInlineStart: depth * TREE_ROW_HEIGHT }, + }) + ); + } +} + +/** + * A link to fetch and load more siblings in the dominator tree, when there are + * already many loaded above. + */ +class DominatorTreeSiblingLinkClass extends Component { + static get propTypes() { + return { + depth: PropTypes.number.isRequired, + focused: PropTypes.bool.isRequired, + item: PropTypes.instanceOf(DominatorTreeLazyChildren).isRequired, + onLoadMoreSiblings: PropTypes.func.isRequired, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + this.props.depth !== nextProps.depth || + this.props.focused !== nextProps.focused + ); + } + + render() { + const { depth, focused, item, onLoadMoreSiblings } = this.props; + + return dom.div( + { + className: `heap-tree-item more-children ${focused ? "focused" : ""}`, + }, + dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }), + dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }), + dom.span( + { + className: "heap-tree-item-field heap-tree-item-name", + style: { marginInlineStart: depth * TREE_ROW_HEIGHT }, + }, + dom.a( + { + className: "load-more-link", + onClick: () => onLoadMoreSiblings(item), + }, + L10N.getStr("tree-item.load-more") + ) + ) + ); + } +} + +class DominatorTree extends Component { + static get propTypes() { + return { + dominatorTree: dominatorTreeModel.isRequired, + onLoadMoreSiblings: PropTypes.func.isRequired, + onViewSourceInDebugger: PropTypes.func.isRequired, + onExpand: PropTypes.func.isRequired, + onCollapse: PropTypes.func.isRequired, + onFocus: PropTypes.func.isRequired, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + // Safe to use referential equality here because all of our mutations on + // dominator tree models use immutableUpdate in a persistent manner. The + // exception to the rule are mutations of the expanded set, however we take + // care that the dominatorTree model itself is still re-allocated when + // mutations to the expanded set occur. Because of the re-allocations, we + // can continue using referential equality here. + return this.props.dominatorTree !== nextProps.dominatorTree; + } + + render() { + const { dominatorTree, onViewSourceInDebugger, onLoadMoreSiblings } = + this.props; + + const parentMap = createParentMap(dominatorTree.root, node => node.nodeId); + + return Tree({ + key: "dominator-tree-tree", + autoExpandDepth: DOMINATOR_TREE_AUTO_EXPAND_DEPTH, + preventNavigationOnArrowRight: false, + focused: dominatorTree.focused, + getParent: node => + node instanceof DominatorTreeLazyChildren + ? parentMap[node.parentNodeId()] + : parentMap[node.nodeId], + getChildren: node => { + const children = node.children ? node.children.slice() : []; + if (node.moreChildrenAvailable) { + children.push( + new DominatorTreeLazyChildren(node.nodeId, children.length) + ); + } + return children; + }, + isExpanded: node => { + return node instanceof DominatorTreeLazyChildren + ? false + : dominatorTree.expanded.has(node.nodeId); + }, + onExpand: item => { + if (item instanceof DominatorTreeLazyChildren) { + return; + } + + if ( + item.moreChildrenAvailable && + (!item.children || !item.children.length) + ) { + const startIndex = item.children ? item.children.length : 0; + onLoadMoreSiblings( + new DominatorTreeLazyChildren(item.nodeId, startIndex) + ); + } + + this.props.onExpand(item); + }, + onCollapse: item => { + if (item instanceof DominatorTreeLazyChildren) { + return; + } + + this.props.onCollapse(item); + }, + onFocus: item => { + if (item instanceof DominatorTreeLazyChildren) { + return; + } + + this.props.onFocus(item); + }, + renderItem: (item, depth, focused, arrow, expanded) => { + if (item instanceof DominatorTreeLazyChildren) { + if (item.isFirstChild()) { + assert( + dominatorTree.state === dominatorTreeState.INCREMENTAL_FETCHING, + "If we are displaying a throbber for loading a subtree, " + + "then we should be INCREMENTAL_FETCHING those children right now" + ); + return DominatorTreeSubtreeFetching({ + key: item.key(), + depth, + focused, + }); + } + + return DominatorTreeSiblingLink({ + key: item.key(), + item, + depth, + focused, + onLoadMoreSiblings, + }); + } + + return DominatorTreeItem({ + item, + depth, + focused, + arrow, + expanded, + getPercentSize: size => + (size / dominatorTree.root.retainedSize) * 100, + onViewSourceInDebugger, + }); + }, + getRoots: () => [dominatorTree.root], + getKey: node => + node instanceof DominatorTreeLazyChildren ? node.key() : node.nodeId, + itemHeight: TREE_ROW_HEIGHT, + }); + } +} + +const DominatorTreeSubtreeFetching = createFactory( + DominatorTreeSubtreeFetchingClass +); +const DominatorTreeSiblingLink = createFactory(DominatorTreeSiblingLinkClass); + +module.exports = DominatorTree; diff --git a/devtools/client/memory/components/DominatorTreeHeader.js b/devtools/client/memory/components/DominatorTreeHeader.js new file mode 100644 index 0000000000..292b74ea60 --- /dev/null +++ b/devtools/client/memory/components/DominatorTreeHeader.js @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { L10N } = require("resource://devtools/client/memory/utils.js"); + +class DominatorTreeHeader extends Component { + static get propTypes() { + return {}; + } + + render() { + return dom.div( + { + className: "header", + }, + + dom.span( + { + className: "heap-tree-item-bytes", + title: L10N.getStr("heapview.field.retainedSize.tooltip"), + }, + L10N.getStr("heapview.field.retainedSize") + ), + + dom.span( + { + className: "heap-tree-item-bytes", + title: L10N.getStr("heapview.field.shallowSize.tooltip"), + }, + L10N.getStr("heapview.field.shallowSize") + ), + + dom.span( + { + className: "heap-tree-item-name", + title: L10N.getStr("dominatortree.field.label.tooltip"), + }, + L10N.getStr("dominatortree.field.label") + ) + ); + } +} + +module.exports = DominatorTreeHeader; diff --git a/devtools/client/memory/components/DominatorTreeItem.js b/devtools/client/memory/components/DominatorTreeItem.js new file mode 100644 index 0000000000..59cb542a3a --- /dev/null +++ b/devtools/client/memory/components/DominatorTreeItem.js @@ -0,0 +1,174 @@ +/* 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 { + assert, + isSavedFrame, +} = require("resource://devtools/shared/DevToolsUtils.js"); +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + L10N, + formatNumber, + formatPercent, +} = require("resource://devtools/client/memory/utils.js"); +const Frame = createFactory( + require("resource://devtools/client/shared/components/Frame.js") +); +const { + TREE_ROW_HEIGHT, +} = require("resource://devtools/client/memory/constants.js"); + +class SeparatorClass extends Component { + render() { + return dom.span({ className: "separator" }, "›"); + } +} + +const Separator = createFactory(SeparatorClass); + +class DominatorTreeItem extends Component { + static get propTypes() { + return { + item: PropTypes.object.isRequired, + depth: PropTypes.number.isRequired, + arrow: PropTypes.object, + expanded: PropTypes.bool.isRequired, + focused: PropTypes.bool.isRequired, + getPercentSize: PropTypes.func.isRequired, + onViewSourceInDebugger: PropTypes.func.isRequired, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + this.props.item != nextProps.item || + this.props.depth != nextProps.depth || + this.props.expanded != nextProps.expanded || + this.props.focused != nextProps.focused + ); + } + + render() { + const { + item, + depth, + arrow, + focused, + getPercentSize, + onViewSourceInDebugger, + } = this.props; + + const retainedSize = formatNumber(item.retainedSize); + const percentRetainedSize = formatPercent( + getPercentSize(item.retainedSize) + ); + + const shallowSize = formatNumber(item.shallowSize); + const percentShallowSize = formatPercent(getPercentSize(item.shallowSize)); + + // Build up our label UI as an array of each label piece, which is either a + // string or a frame, and separators in between them. + + assert(!!item.label.length, "Our label should not be empty"); + const label = Array(item.label.length * 2 - 1); + label.fill(undefined); + + for (let i = 0, length = item.label.length; i < length; i++) { + const piece = item.label[i]; + const key = `${item.nodeId}-label-${i}`; + + // `i` is the index of the label piece we are rendering, `label[i*2]` is + // where the rendered label piece belngs, and `label[i*2+1]` (if it isn't + // out of bounds) is where the separator belongs. + + if (isSavedFrame(piece)) { + label[i * 2] = Frame({ + key, + onClick: onViewSourceInDebugger, + frame: piece, + showFunctionName: true, + }); + } else if (piece === "noStack") { + label[i * 2] = dom.span( + { key, className: "not-available" }, + L10N.getStr("tree-item.nostack") + ); + } else if (piece === "noFilename") { + label[i * 2] = dom.span( + { key, className: "not-available" }, + L10N.getStr("tree-item.nofilename") + ); + } else if (piece === "JS::ubi::RootList") { + // Don't use the usual labeling machinery for root lists: replace it + // with the "GC Roots" string. + label.splice(0, label.length); + label.push(L10N.getStr("tree-item.rootlist")); + break; + } else { + label[i * 2] = piece; + } + + // If this is not the last piece of the label, add a separator. + if (i < length - 1) { + label[i * 2 + 1] = Separator({ key: `${item.nodeId}-separator-${i}` }); + } + } + + return dom.div( + { + className: `heap-tree-item ${focused ? "focused" : ""} node-${ + item.nodeId + }`, + }, + + dom.span( + { + className: "heap-tree-item-field heap-tree-item-bytes", + }, + dom.span( + { + className: "heap-tree-number", + }, + retainedSize + ), + dom.span({ className: "heap-tree-percent" }, percentRetainedSize) + ), + + dom.span( + { + className: "heap-tree-item-field heap-tree-item-bytes", + }, + dom.span( + { + className: "heap-tree-number", + }, + shallowSize + ), + dom.span({ className: "heap-tree-percent" }, percentShallowSize) + ), + + dom.span( + { + className: "heap-tree-item-field heap-tree-item-name", + style: { marginInlineStart: depth * TREE_ROW_HEIGHT }, + }, + arrow, + label, + dom.span( + { className: "heap-tree-item-address" }, + `@ 0x${item.nodeId.toString(16)}` + ) + ) + ); + } +} + +module.exports = DominatorTreeItem; diff --git a/devtools/client/memory/components/Heap.js b/devtools/client/memory/components/Heap.js new file mode 100644 index 0000000000..2c78a63749 --- /dev/null +++ b/devtools/client/memory/components/Heap.js @@ -0,0 +1,547 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + assert, + safeErrorString, +} = require("resource://devtools/shared/DevToolsUtils.js"); +const Census = createFactory( + require("resource://devtools/client/memory/components/Census.js") +); +const CensusHeader = createFactory( + require("resource://devtools/client/memory/components/CensusHeader.js") +); +const DominatorTree = createFactory( + require("resource://devtools/client/memory/components/DominatorTree.js") +); +const DominatorTreeHeader = createFactory( + require("resource://devtools/client/memory/components/DominatorTreeHeader.js") +); +const TreeMap = createFactory( + require("resource://devtools/client/memory/components/TreeMap.js") +); +const HSplitBox = createFactory( + require("resource://devtools/client/shared/components/HSplitBox.js") +); +const Individuals = createFactory( + require("resource://devtools/client/memory/components/Individuals.js") +); +const IndividualsHeader = createFactory( + require("resource://devtools/client/memory/components/IndividualsHeader.js") +); +const ShortestPaths = createFactory( + require("resource://devtools/client/memory/components/ShortestPaths.js") +); +const { + getStatusTextFull, + L10N, +} = require("resource://devtools/client/memory/utils.js"); +const { + snapshotState: states, + diffingState, + viewState, + censusState, + treeMapState, + dominatorTreeState, + individualsState, +} = require("resource://devtools/client/memory/constants.js"); +const models = require("resource://devtools/client/memory/models.js"); +const { snapshot: snapshotModel, diffingModel } = models; + +/** + * Get the app state's current state atom. + * + * @see the relevant state string constants in `../constants.js`. + * + * @param {models.view} view + * @param {snapshotModel} snapshot + * @param {diffingModel} diffing + * @param {individualsModel} individuals + * + * @return {snapshotState|diffingState|dominatorTreeState} + */ +function getState(view, snapshot, diffing, individuals) { + switch (view.state) { + case viewState.CENSUS: + return snapshot.census ? snapshot.census.state : snapshot.state; + + case viewState.DIFFING: + return diffing.state; + + case viewState.TREE_MAP: + return snapshot.treeMap ? snapshot.treeMap.state : snapshot.state; + + case viewState.DOMINATOR_TREE: + return snapshot.dominatorTree + ? snapshot.dominatorTree.state + : snapshot.state; + + case viewState.INDIVIDUALS: + return individuals.state; + } + + assert(false, `Unexpected view state: ${view.state}`); + return null; +} + +/** + * Return true if we should display a status message when we are in the given + * state. Return false otherwise. + * + * @param {snapshotState|diffingState|dominatorTreeState} state + * @param {models.view} view + * @param {snapshotModel} snapshot + * + * @returns {Boolean} + */ +function shouldDisplayStatus(state, view, snapshot) { + switch (state) { + case states.IMPORTING: + case states.SAVING: + case states.SAVED: + case states.READING: + case censusState.SAVING: + case treeMapState.SAVING: + case diffingState.SELECTING: + case diffingState.TAKING_DIFF: + case dominatorTreeState.COMPUTING: + case dominatorTreeState.COMPUTED: + case dominatorTreeState.FETCHING: + case individualsState.COMPUTING_DOMINATOR_TREE: + case individualsState.FETCHING: + return true; + } + return view.state === viewState.DOMINATOR_TREE && !snapshot.dominatorTree; +} + +/** + * Get the status text to display for the given state. + * + * @param {snapshotState|diffingState|dominatorTreeState} state + * @param {diffingModel} diffing + * + * @returns {String} + */ +function getStateStatusText(state, diffing) { + if (state === diffingState.SELECTING) { + return L10N.getStr( + diffing.firstSnapshotId === null + ? "diffing.prompt.selectBaseline" + : "diffing.prompt.selectComparison" + ); + } + + return getStatusTextFull(state); +} + +/** + * Given that we should display a status message, return true if we should also + * display a throbber along with the status message. Return false otherwise. + * + * @param {diffingModel} diffing + * + * @returns {Boolean} + */ +function shouldDisplayThrobber(diffing) { + return !diffing || diffing.state !== diffingState.SELECTING; +} + +/** + * Get the current state's error, or return null if there is none. + * + * @param {snapshotModel} snapshot + * @param {diffingModel} diffing + * @param {individualsModel} individuals + * + * @returns {Error|null} + */ +function getError(snapshot, diffing, individuals) { + if (diffing) { + if (diffing.state === diffingState.ERROR) { + return diffing.error; + } + if (diffing.census === censusState.ERROR) { + return diffing.census.error; + } + } + + if (snapshot) { + if (snapshot.state === states.ERROR) { + return snapshot.error; + } + + if (snapshot.census === censusState.ERROR) { + return snapshot.census.error; + } + + if (snapshot.treeMap === treeMapState.ERROR) { + return snapshot.treeMap.error; + } + + if ( + snapshot.dominatorTree && + snapshot.dominatorTree.state === dominatorTreeState.ERROR + ) { + return snapshot.dominatorTree.error; + } + } + + if (individuals && individuals.state === individualsState.ERROR) { + return individuals.error; + } + + return null; +} + +/** + * Main view for the memory tool. + * + * The Heap component contains several panels for different states; an initial + * state of only a button to take a snapshot, loading states, the census view + * tree, the dominator tree, etc. + */ +class Heap extends Component { + static get propTypes() { + return { + onSnapshotClick: PropTypes.func.isRequired, + onLoadMoreSiblings: PropTypes.func.isRequired, + onCensusExpand: PropTypes.func.isRequired, + onCensusCollapse: PropTypes.func.isRequired, + onDominatorTreeExpand: PropTypes.func.isRequired, + onDominatorTreeCollapse: PropTypes.func.isRequired, + onCensusFocus: PropTypes.func.isRequired, + onDominatorTreeFocus: PropTypes.func.isRequired, + onShortestPathsResize: PropTypes.func.isRequired, + snapshot: snapshotModel, + onViewSourceInDebugger: PropTypes.func.isRequired, + onPopView: PropTypes.func.isRequired, + individuals: models.individuals, + onViewIndividuals: PropTypes.func.isRequired, + onFocusIndividual: PropTypes.func.isRequired, + diffing: diffingModel, + view: models.view.isRequired, + sizes: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + this._renderHeapView = this._renderHeapView.bind(this); + this._renderInitial = this._renderInitial.bind(this); + this._renderStatus = this._renderStatus.bind(this); + this._renderError = this._renderError.bind(this); + this._renderCensus = this._renderCensus.bind(this); + this._renderTreeMap = this._renderTreeMap.bind(this); + this._renderIndividuals = this._renderIndividuals.bind(this); + this._renderDominatorTree = this._renderDominatorTree.bind(this); + } + + /** + * Render the heap view's container panel with the given contents inside of + * it. + * + * @param {snapshotState|diffingState|dominatorTreeState} state + * @param {...Any} contents + */ + _renderHeapView(state, ...contents) { + return dom.div( + { + id: "heap-view", + "data-state": state, + }, + dom.div( + { + className: "heap-view-panel", + "data-state": state, + }, + ...contents + ) + ); + } + + _renderInitial(onSnapshotClick) { + return this._renderHeapView( + "initial", + dom.button( + { + className: "devtools-button take-snapshot", + onClick: onSnapshotClick, + "data-standalone": true, + }, + L10N.getStr("take-snapshot") + ) + ); + } + + _renderStatus(state, statusText, diffing) { + let throbber = ""; + if (shouldDisplayThrobber(diffing)) { + throbber = "devtools-throbber"; + } + + return this._renderHeapView( + state, + dom.span( + { + className: `snapshot-status ${throbber}`, + }, + statusText + ) + ); + } + + _renderError(state, statusText, error) { + return this._renderHeapView( + state, + dom.span({ className: "snapshot-status error" }, statusText), + dom.pre({}, safeErrorString(error)) + ); + } + + _renderCensus( + state, + census, + diffing, + onViewSourceInDebugger, + onViewIndividuals + ) { + assert( + census.report, + "Should not render census that does not have a report" + ); + + if (!census.report.children) { + const censusFilterMsg = census.filter + ? L10N.getStr("heapview.none-match") + : L10N.getStr("heapview.empty"); + const msg = diffing + ? L10N.getStr("heapview.no-difference") + : censusFilterMsg; + return this._renderHeapView(state, dom.div({ className: "empty" }, msg)); + } + + const contents = []; + + if ( + census.display.breakdown.by === "allocationStack" && + census.report.children && + census.report.children.length === 1 && + census.report.children[0].name === "noStack" + ) { + contents.push( + dom.div( + { className: "error no-allocation-stacks" }, + L10N.getStr("heapview.noAllocationStacks") + ) + ); + } + + contents.push(CensusHeader({ diffing })); + contents.push( + Census({ + onViewSourceInDebugger, + onViewIndividuals, + diffing, + census, + onExpand: node => this.props.onCensusExpand(census, node), + onCollapse: node => this.props.onCensusCollapse(census, node), + onFocus: node => this.props.onCensusFocus(census, node), + }) + ); + + return this._renderHeapView(state, ...contents); + } + + _renderTreeMap(state, treeMap) { + return this._renderHeapView(state, TreeMap({ treeMap })); + } + + _renderIndividuals( + state, + individuals, + dominatorTree, + onViewSourceInDebugger + ) { + assert( + individuals.state === individualsState.FETCHED, + "Should have fetched individuals" + ); + assert(dominatorTree?.root, "Should have a dominator tree and its root"); + + const tree = dom.div( + { + className: "vbox", + style: { + overflowY: "auto", + }, + }, + IndividualsHeader(), + Individuals({ + individuals, + dominatorTree, + onViewSourceInDebugger, + onFocus: this.props.onFocusIndividual, + }) + ); + + const shortestPaths = ShortestPaths({ + graph: individuals.focused ? individuals.focused.shortestPaths : null, + }); + + return this._renderHeapView( + state, + dom.div( + { className: "hbox devtools-toolbar" }, + dom.label( + { id: "pop-view-button-label" }, + dom.button( + { + id: "pop-view-button", + className: "devtools-button", + onClick: this.props.onPopView, + }, + L10N.getStr("toolbar.pop-view") + ), + L10N.getStr("toolbar.pop-view.label") + ), + dom.span( + { className: "toolbar-text" }, + L10N.getStr("toolbar.viewing-individuals") + ) + ), + HSplitBox({ + start: tree, + end: shortestPaths, + startWidth: this.props.sizes.shortestPathsSize, + onResize: this.props.onShortestPathsResize, + }) + ); + } + + _renderDominatorTree( + state, + onViewSourceInDebugger, + dominatorTree, + onLoadMoreSiblings + ) { + const tree = dom.div( + { + className: "vbox", + style: { + overflowY: "auto", + }, + }, + DominatorTreeHeader(), + DominatorTree({ + onViewSourceInDebugger, + dominatorTree, + onLoadMoreSiblings, + onExpand: this.props.onDominatorTreeExpand, + onCollapse: this.props.onDominatorTreeCollapse, + onFocus: this.props.onDominatorTreeFocus, + }) + ); + + const shortestPaths = ShortestPaths({ + graph: dominatorTree.focused ? dominatorTree.focused.shortestPaths : null, + }); + + return this._renderHeapView( + state, + HSplitBox({ + start: tree, + end: shortestPaths, + startWidth: this.props.sizes.shortestPathsSize, + onResize: this.props.onShortestPathsResize, + }) + ); + } + + render() { + const { + snapshot, + diffing, + onSnapshotClick, + onLoadMoreSiblings, + onViewSourceInDebugger, + onViewIndividuals, + individuals, + view, + } = this.props; + + if (!diffing && !snapshot && !individuals) { + return this._renderInitial(onSnapshotClick); + } + + const state = getState(view, snapshot, diffing, individuals); + const statusText = getStateStatusText(state, diffing); + + if (shouldDisplayStatus(state, view, snapshot)) { + return this._renderStatus(state, statusText, diffing); + } + + const error = getError(snapshot, diffing, individuals); + if (error) { + return this._renderError(state, statusText, error); + } + + if (view.state === viewState.CENSUS || view.state === viewState.DIFFING) { + const census = + view.state === viewState.CENSUS ? snapshot.census : diffing.census; + if (!census) { + return this._renderStatus(state, statusText, diffing); + } + return this._renderCensus( + state, + census, + diffing, + onViewSourceInDebugger, + onViewIndividuals + ); + } + + if (view.state === viewState.TREE_MAP) { + return this._renderTreeMap(state, snapshot.treeMap); + } + + if (view.state === viewState.INDIVIDUALS) { + assert( + individuals.state === individualsState.FETCHED, + "Should have fetched the individuals -- " + + "other states are rendered as statuses" + ); + return this._renderIndividuals( + state, + individuals, + individuals.dominatorTree, + onViewSourceInDebugger + ); + } + + assert( + view.state === viewState.DOMINATOR_TREE, + "If we aren't in progress, looking at a census, or diffing, then we " + + "must be looking at a dominator tree" + ); + assert(!diffing, "Should not have diffing"); + assert(snapshot.dominatorTree, "Should have a dominator tree"); + + return this._renderDominatorTree( + state, + onViewSourceInDebugger, + snapshot.dominatorTree, + onLoadMoreSiblings + ); + } +} + +module.exports = Heap; diff --git a/devtools/client/memory/components/Individuals.js b/devtools/client/memory/components/Individuals.js new file mode 100644 index 0000000000..dd0e9acc81 --- /dev/null +++ b/devtools/client/memory/components/Individuals.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const Tree = createFactory( + require("resource://devtools/client/shared/components/VirtualizedTree.js") +); +const DominatorTreeItem = createFactory( + require("resource://devtools/client/memory/components/DominatorTreeItem.js") +); +const { + TREE_ROW_HEIGHT, +} = require("resource://devtools/client/memory/constants.js"); +const models = require("resource://devtools/client/memory/models.js"); + +/** + * The list of individuals in a census group. + */ +class Individuals extends Component { + static get propTypes() { + return { + onViewSourceInDebugger: PropTypes.func.isRequired, + onFocus: PropTypes.func.isRequired, + individuals: models.individuals, + dominatorTree: models.dominatorTreeModel, + }; + } + + render() { + const { individuals, dominatorTree, onViewSourceInDebugger, onFocus } = + this.props; + + return Tree({ + key: "individuals-tree", + autoExpandDepth: 0, + preventNavigationOnArrowRight: false, + focused: individuals.focused, + getParent: node => null, + getChildren: node => [], + isExpanded: node => false, + onExpand: () => {}, + onCollapse: () => {}, + onFocus, + renderItem: (item, depth, focused, _, expanded) => { + return DominatorTreeItem({ + item, + depth, + focused, + arrow: undefined, + expanded, + getPercentSize: size => + (size / dominatorTree.root.retainedSize) * 100, + onViewSourceInDebugger, + }); + }, + getRoots: () => individuals.nodes, + getKey: node => node.nodeId, + itemHeight: TREE_ROW_HEIGHT, + }); + } +} + +module.exports = Individuals; diff --git a/devtools/client/memory/components/IndividualsHeader.js b/devtools/client/memory/components/IndividualsHeader.js new file mode 100644 index 0000000000..abd5a81a36 --- /dev/null +++ b/devtools/client/memory/components/IndividualsHeader.js @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { L10N } = require("resource://devtools/client/memory/utils.js"); + +class IndividualsHeader extends Component { + static get propTypes() { + return {}; + } + + render() { + return dom.div( + { + className: "header", + }, + + dom.span( + { + className: "heap-tree-item-bytes", + title: L10N.getStr("heapview.field.retainedSize.tooltip"), + }, + L10N.getStr("heapview.field.retainedSize") + ), + + dom.span( + { + className: "heap-tree-item-bytes", + title: L10N.getStr("heapview.field.shallowSize.tooltip"), + }, + L10N.getStr("heapview.field.shallowSize") + ), + + dom.span( + { + className: "heap-tree-item-name", + title: L10N.getStr("individuals.field.node.tooltip"), + }, + L10N.getStr("individuals.field.node") + ) + ); + } +} + +module.exports = IndividualsHeader; diff --git a/devtools/client/memory/components/List.js b/devtools/client/memory/components/List.js new file mode 100644 index 0000000000..290b178b7e --- /dev/null +++ b/devtools/client/memory/components/List.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +/** + * Generic list component that takes another react component to represent + * the children nodes as `itemComponent`, and a list of items to render + * as that component with a click handler. + */ +class List extends Component { + static get propTypes() { + return { + itemComponent: PropTypes.any.isRequired, + onClick: PropTypes.func, + items: PropTypes.array.isRequired, + }; + } + + render() { + const { items, onClick, itemComponent: Item } = this.props; + + return dom.ul( + { className: "list" }, + ...items.map((item, index) => { + return Item( + Object.assign({}, this.props, { + key: index, + item, + index, + onClick: () => onClick(item), + }) + ); + }) + ); + } +} + +module.exports = List; diff --git a/devtools/client/memory/components/ShortestPaths.js b/devtools/client/memory/components/ShortestPaths.js new file mode 100644 index 0000000000..0dd6e22c33 --- /dev/null +++ b/devtools/client/memory/components/ShortestPaths.js @@ -0,0 +1,196 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { isSavedFrame } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + getSourceNames, +} = require("resource://devtools/client/shared/source-utils.js"); +const { L10N } = require("resource://devtools/client/memory/utils.js"); + +const GRAPH_DEFAULTS = { + translate: [20, 20], + scale: 1, +}; + +const NO_STACK = "noStack"; +const NO_FILENAME = "noFilename"; +const ROOT_LIST = "JS::ubi::RootList"; + +function stringifyLabel(label, id) { + const sanitized = []; + + for (let i = 0, length = label.length; i < length; i++) { + const piece = label[i]; + + if (isSavedFrame(piece)) { + const { short } = getSourceNames(piece.source); + sanitized[i] = + `${piece.functionDisplayName} @ ` + + `${short}:${piece.line}:${piece.column}`; + } else if (piece === NO_STACK) { + sanitized[i] = L10N.getStr("tree-item.nostack"); + } else if (piece === NO_FILENAME) { + sanitized[i] = L10N.getStr("tree-item.nofilename"); + } else if (piece === ROOT_LIST) { + // Don't use the usual labeling machinery for root lists: replace it + // with the "GC Roots" string. + sanitized.splice(0, label.length); + sanitized.push(L10N.getStr("tree-item.rootlist")); + break; + } else { + sanitized[i] = "" + piece; + } + } + + return `${sanitized.join(" › ")} @ 0x${id.toString(16)}`; +} + +class ShortestPaths extends Component { + static get propTypes() { + return { + graph: PropTypes.shape({ + nodes: PropTypes.arrayOf(PropTypes.object), + edges: PropTypes.arrayOf(PropTypes.object), + }), + }; + } + + constructor(props) { + super(props); + this.state = { zoom: null }; + this._renderGraph = this._renderGraph.bind(this); + } + + componentDidMount() { + if (this.props.graph) { + this._renderGraph(this.refs.container, this.props.graph); + } + } + + shouldComponentUpdate(nextProps) { + return this.props.graph != nextProps.graph; + } + + componentDidUpdate() { + if (this.props.graph) { + this._renderGraph(this.refs.container, this.props.graph); + } + } + + componentWillUnmount() { + if (this.state.zoom) { + this.state.zoom.on("zoom", null); + } + } + + _renderGraph(container, { nodes, edges }) { + if (!container.firstChild) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("id", "graph-svg"); + svg.setAttribute("xlink", "http://www.w3.org/1999/xlink"); + svg.style.width = "100%"; + svg.style.height = "100%"; + + const target = document.createElementNS( + "http://www.w3.org/2000/svg", + "g" + ); + target.setAttribute("id", "graph-target"); + target.style.width = "100%"; + target.style.height = "100%"; + + svg.appendChild(target); + container.appendChild(svg); + } + + const graph = new dagreD3.Digraph(); + + for (let i = 0; i < nodes.length; i++) { + graph.addNode(nodes[i].id, { + id: nodes[i].id, + label: stringifyLabel(nodes[i].label, nodes[i].id), + }); + } + + for (let i = 0; i < edges.length; i++) { + graph.addEdge(null, edges[i].from, edges[i].to, { + label: edges[i].name, + }); + } + + const renderer = new dagreD3.Renderer(); + renderer.drawNodes(); + renderer.drawEdgePaths(); + + const svg = d3.select("#graph-svg"); + const target = d3.select("#graph-target"); + + let zoom = this.state.zoom; + if (!zoom) { + zoom = d3.behavior.zoom().on("zoom", function () { + target.attr( + "transform", + `translate(${d3.event.translate}) scale(${d3.event.scale})` + ); + }); + svg.call(zoom); + this.setState({ zoom }); + } + + const { translate, scale } = GRAPH_DEFAULTS; + zoom.scale(scale); + zoom.translate(translate); + target.attr("transform", `translate(${translate}) scale(${scale})`); + + const layout = dagreD3.layout(); + renderer.layout(layout).run(graph, target); + } + + render() { + let contents; + if (this.props.graph) { + // Let the componentDidMount or componentDidUpdate method draw the graph + // with DagreD3. We just provide the container for the graph here. + contents = dom.div({ + ref: "container", + style: { + flex: 1, + height: "100%", + width: "100%", + }, + }); + } else { + contents = dom.div( + { + id: "shortest-paths-select-node-msg", + }, + L10N.getStr("shortest-paths.select-node") + ); + } + + return dom.div( + { + id: "shortest-paths", + className: "vbox", + }, + dom.label( + { + id: "shortest-paths-header", + className: "header", + }, + L10N.getStr("shortest-paths.header") + ), + contents + ); + } +} + +module.exports = ShortestPaths; diff --git a/devtools/client/memory/components/SnapshotListItem.js b/devtools/client/memory/components/SnapshotListItem.js new file mode 100644 index 0000000000..5a2c24b4e4 --- /dev/null +++ b/devtools/client/memory/components/SnapshotListItem.js @@ -0,0 +1,142 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + L10N, + getSnapshotTitle, + getSnapshotTotals, + getStatusText, + snapshotIsDiffable, + getSavedCensus, +} = require("resource://devtools/client/memory/utils.js"); +const { + diffingState, +} = require("resource://devtools/client/memory/constants.js"); +const { + snapshot: snapshotModel, + app: appModel, +} = require("resource://devtools/client/memory/models.js"); + +class SnapshotListItem extends Component { + static get propTypes() { + return { + onClick: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + item: snapshotModel.isRequired, + index: PropTypes.number.isRequired, + diffing: appModel.diffing, + }; + } + + render() { + const { item: snapshot, onClick, onSave, onDelete, diffing } = this.props; + let className = `snapshot-list-item ${ + snapshot.selected ? " selected" : "" + }`; + let statusText = getStatusText(snapshot.state); + let wantThrobber = !!statusText; + const title = getSnapshotTitle(snapshot); + + const selectedForDiffing = + diffing && + (diffing.firstSnapshotId === snapshot.id || + diffing.secondSnapshotId === snapshot.id); + + let checkbox; + if (diffing && snapshotIsDiffable(snapshot)) { + if (diffing.state === diffingState.SELECTING) { + wantThrobber = false; + } + + const checkboxAttrs = { + type: "checkbox", + checked: false, + }; + + if (selectedForDiffing) { + checkboxAttrs.checked = true; + checkboxAttrs.disabled = true; + className += " selected"; + statusText = L10N.getStr( + diffing.firstSnapshotId === snapshot.id + ? "diffing.baseline" + : "diffing.comparison" + ); + } + + if (selectedForDiffing || diffing.state == diffingState.SELECTING) { + checkbox = dom.input(checkboxAttrs); + } + } + + let details; + if (!selectedForDiffing) { + // See if a tree map or census is in the read state. + const census = getSavedCensus(snapshot); + + // If there is census data, fill in the total bytes. + if (census) { + const { bytes } = getSnapshotTotals(census); + const formatBytes = L10N.getFormatStr( + "aggregate.mb", + L10N.numberWithDecimals(bytes / 1000000, 2) + ); + + details = dom.span( + { className: "snapshot-totals" }, + dom.span({ className: "total-bytes" }, formatBytes) + ); + } + } + if (!details) { + details = dom.span({ className: "snapshot-state" }, statusText); + } + + const saveLink = !snapshot.path + ? void 0 + : dom.a( + { + onClick: () => onSave(snapshot), + className: "save", + }, + L10N.getStr("snapshot.io.save") + ); + + const deleteButton = !snapshot.path + ? void 0 + : dom.button({ + onClick: event => { + event.stopPropagation(); + onDelete(snapshot); + }, + className: "delete", + title: L10N.getStr("snapshot.io.delete"), + }); + + return dom.li( + { className, onClick }, + dom.span( + { + className: `snapshot-title ${ + wantThrobber ? " devtools-throbber" : "" + }`, + }, + checkbox, + title, + deleteButton + ), + dom.span({ className: "snapshot-info" }, details, saveLink) + ); + } +} + +module.exports = SnapshotListItem; diff --git a/devtools/client/memory/components/Toolbar.js b/devtools/client/memory/components/Toolbar.js new file mode 100644 index 0000000000..712a04abfe --- /dev/null +++ b/devtools/client/memory/components/Toolbar.js @@ -0,0 +1,309 @@ +/* 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 { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { L10N } = require("resource://devtools/client/memory/utils.js"); +const models = require("resource://devtools/client/memory/models.js"); +const { viewState } = require("resource://devtools/client/memory/constants.js"); + +class Toolbar extends Component { + static get propTypes() { + return { + censusDisplays: PropTypes.arrayOf(models.censusDisplay).isRequired, + censusDisplay: models.censusDisplay.isRequired, + onTakeSnapshotClick: PropTypes.func.isRequired, + onImportClick: PropTypes.func.isRequired, + onClearSnapshotsClick: PropTypes.func.isRequired, + onCensusDisplayChange: PropTypes.func.isRequired, + onToggleRecordAllocationStacks: PropTypes.func.isRequired, + allocations: models.allocations, + filterString: PropTypes.string, + setFilterString: PropTypes.func.isRequired, + diffing: models.diffingModel, + onToggleDiffing: PropTypes.func.isRequired, + view: models.view.isRequired, + onViewChange: PropTypes.func.isRequired, + labelDisplays: PropTypes.arrayOf(models.labelDisplay).isRequired, + labelDisplay: models.labelDisplay.isRequired, + onLabelDisplayChange: PropTypes.func.isRequired, + treeMapDisplays: PropTypes.arrayOf(models.treeMapDisplay).isRequired, + onTreeMapDisplayChange: PropTypes.func.isRequired, + snapshots: PropTypes.arrayOf(models.snapshot).isRequired, + }; + } + + render() { + const { + onTakeSnapshotClick, + onImportClick, + onClearSnapshotsClick, + onCensusDisplayChange, + censusDisplays, + censusDisplay, + labelDisplays, + labelDisplay, + onLabelDisplayChange, + treeMapDisplays, + onTreeMapDisplayChange, + onToggleRecordAllocationStacks, + allocations, + filterString, + setFilterString, + snapshots, + diffing, + onToggleDiffing, + view, + onViewChange, + } = this.props; + + let viewToolbarOptions; + if (view.state == viewState.CENSUS || view.state === viewState.DIFFING) { + viewToolbarOptions = dom.div( + { + className: "toolbar-group", + }, + + dom.label( + { + className: "display-by", + title: L10N.getStr("toolbar.displayBy.tooltip"), + }, + L10N.getStr("toolbar.displayBy"), + dom.select( + { + id: "select-display", + className: "devtools-toolbar-select select-display", + onChange: e => { + const newDisplay = censusDisplays.find( + b => b.displayName === e.target.value + ); + onCensusDisplayChange(newDisplay); + }, + value: censusDisplay.displayName, + }, + censusDisplays.map(({ tooltip, displayName }) => + dom.option( + { + key: `display-${displayName}`, + value: displayName, + title: tooltip, + }, + displayName + ) + ) + ) + ), + + dom.span({ className: "devtools-separator" }), + + dom.input({ + id: "filter", + type: "search", + className: "devtools-filterinput", + placeholder: L10N.getStr("filter.placeholder"), + title: L10N.getStr("filter.tooltip"), + onChange: event => setFilterString(event.target.value), + value: filterString || undefined, + }) + ); + } else if (view.state == viewState.TREE_MAP) { + assert( + treeMapDisplays.length >= 1, + "Should always have at least one tree map display" + ); + + // Only show the dropdown if there are multiple display options + viewToolbarOptions = + treeMapDisplays.length > 1 + ? dom.div( + { + className: "toolbar-group", + }, + + dom.label( + { + className: "display-by", + title: L10N.getStr("toolbar.displayBy.tooltip"), + }, + L10N.getStr("toolbar.displayBy"), + dom.select( + { + id: "select-tree-map-display", + onChange: e => { + const newDisplay = treeMapDisplays.find( + b => b.displayName === e.target.value + ); + onTreeMapDisplayChange(newDisplay); + }, + }, + treeMapDisplays.map(({ tooltip, displayName }) => + dom.option( + { + key: `tree-map-display-${displayName}`, + value: displayName, + title: tooltip, + }, + displayName + ) + ) + ) + ) + ) + : null; + } else { + assert( + view.state === viewState.DOMINATOR_TREE || + view.state === viewState.INDIVIDUALS + ); + + viewToolbarOptions = dom.div( + { + className: "toolbar-group", + }, + + dom.label( + { + className: "label-by", + title: L10N.getStr("toolbar.labelBy.tooltip"), + }, + L10N.getStr("toolbar.labelBy"), + dom.select( + { + id: "select-label-display", + className: "devtools-toolbar-select select-label-display", + onChange: e => { + const newDisplay = labelDisplays.find( + b => b.displayName === e.target.value + ); + onLabelDisplayChange(newDisplay); + }, + value: labelDisplay.displayName, + }, + labelDisplays.map(({ tooltip, displayName }) => + dom.option( + { + key: `label-display-${displayName}`, + value: displayName, + title: tooltip, + }, + displayName + ) + ) + ) + ) + ); + } + + let viewSelect; + if ( + view.state !== viewState.DIFFING && + view.state !== viewState.INDIVIDUALS + ) { + viewSelect = dom.label( + { + title: L10N.getStr("toolbar.view.tooltip"), + }, + L10N.getStr("toolbar.view"), + dom.select( + { + id: "select-view", + className: "devtools-toolbar-select select-view", + onChange: e => onViewChange(e.target.value), + value: view.state, + }, + dom.option( + { + value: viewState.TREE_MAP, + title: L10N.getStr("toolbar.view.treemap.tooltip"), + }, + L10N.getStr("toolbar.view.treemap") + ), + dom.option( + { + value: viewState.CENSUS, + title: L10N.getStr("toolbar.view.census.tooltip"), + }, + L10N.getStr("toolbar.view.census") + ), + dom.option( + { + value: viewState.DOMINATOR_TREE, + title: L10N.getStr("toolbar.view.dominators.tooltip"), + }, + L10N.getStr("toolbar.view.dominators") + ) + ) + ); + } + + return dom.div( + { + className: "devtools-toolbar", + }, + + dom.div( + { + className: "toolbar-group", + }, + + dom.button({ + id: "clear-snapshots", + className: "clear-snapshots devtools-button", + disabled: !snapshots.length, + onClick: onClearSnapshotsClick, + title: L10N.getStr("clear-snapshots.tooltip"), + }), + + dom.button({ + id: "take-snapshot", + className: "take-snapshot devtools-button", + onClick: onTakeSnapshotClick, + title: L10N.getStr("take-snapshot"), + }), + + dom.button({ + id: "diff-snapshots", + className: + "devtools-button devtools-monospace" + (diffing ? " checked" : ""), + disabled: snapshots.length < 2, + onClick: onToggleDiffing, + title: L10N.getStr("diff-snapshots.tooltip"), + }), + + dom.button({ + id: "import-snapshot", + className: "import-snapshot devtools-button", + onClick: onImportClick, + title: L10N.getStr("import-snapshot"), + }) + ), + + dom.label( + { + id: "record-allocation-stacks-label", + title: L10N.getStr("checkbox.recordAllocationStacks.tooltip"), + }, + dom.input({ + id: "record-allocation-stacks-checkbox", + type: "checkbox", + checked: allocations.recording, + disabled: allocations.togglingInProgress, + onChange: onToggleRecordAllocationStacks, + }), + L10N.getStr("checkbox.recordAllocationStacks") + ), + + viewSelect, + viewToolbarOptions + ); + } +} + +module.exports = Toolbar; diff --git a/devtools/client/memory/components/TreeMap.js b/devtools/client/memory/components/TreeMap.js new file mode 100644 index 0000000000..b9a3c39495 --- /dev/null +++ b/devtools/client/memory/components/TreeMap.js @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { treeMapModel } = require("resource://devtools/client/memory/models.js"); +const startVisualization = require("resource://devtools/client/memory/components/tree-map/start.js"); + +class TreeMap extends Component { + static get propTypes() { + return { + treeMap: treeMapModel, + }; + } + + constructor(props) { + super(props); + this.state = {}; + this._stopVisualization = this._stopVisualization.bind(this); + this._startVisualization = this._startVisualization.bind(this); + } + + componentDidMount() { + const { treeMap } = this.props; + if (treeMap?.report) { + this._startVisualization(); + } + } + + shouldComponentUpdate(nextProps) { + const oldTreeMap = this.props.treeMap; + const newTreeMap = nextProps.treeMap; + return oldTreeMap !== newTreeMap; + } + + componentDidUpdate(prevProps) { + this._stopVisualization(); + + if (this.props.treeMap && this.props.treeMap.report) { + this._startVisualization(); + } + } + + componentWillUnmount() { + if (this.state.stopVisualization) { + this.state.stopVisualization(); + } + } + + _stopVisualization() { + if (this.state.stopVisualization) { + this.state.stopVisualization(); + this.setState({ stopVisualization: null }); + } + } + + _startVisualization() { + const { container } = this.refs; + const { report } = this.props.treeMap; + const stopVisualization = startVisualization(container, report); + this.setState({ stopVisualization }); + } + + render() { + return dom.div({ + ref: "container", + className: "tree-map-container", + }); + } +} + +module.exports = TreeMap; diff --git a/devtools/client/memory/components/moz.build b/devtools/client/memory/components/moz.build new file mode 100644 index 0000000000..82739bf97f --- /dev/null +++ b/devtools/client/memory/components/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 += [ + "tree-map", +] + +DevToolsModules( + "Census.js", + "CensusHeader.js", + "CensusTreeItem.js", + "DominatorTree.js", + "DominatorTreeHeader.js", + "DominatorTreeItem.js", + "Heap.js", + "Individuals.js", + "IndividualsHeader.js", + "List.js", + "ShortestPaths.js", + "SnapshotListItem.js", + "Toolbar.js", + "TreeMap.js", +) diff --git a/devtools/client/memory/components/tree-map/canvas-utils.js b/devtools/client/memory/components/tree-map/canvas-utils.js new file mode 100644 index 0000000000..e1bf252057 --- /dev/null +++ b/devtools/client/memory/components/tree-map/canvas-utils.js @@ -0,0 +1,132 @@ +/* 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/. */ + +/* eslint-env browser */ + +"use strict"; + +/** + * Create 2 canvases and contexts for drawing onto, 1 main canvas, and 1 zoom + * canvas. The main canvas dimensions match the parent div, but the CSS can be + * transformed to be zoomed and dragged around (potentially creating a blurry + * canvas once zoomed in). The zoom canvas is a zoomed in section that matches + * the parent div's dimensions and is kept in place through CSS. A zoomed in + * view of the visualization is drawn onto this canvas, providing a crisp zoomed + * in view of the tree map. + */ +const { debounce } = require("resource://devtools/shared/debounce.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const FULLSCREEN_STYLE = { + width: "100%", + height: "100%", + position: "absolute", +}; + +/** + * Create the canvases, resize handlers, and return references to them all + * + * @param {HTMLDivElement} parentEl + * @param {Number} debounceRate + * @return {Object} + */ +function Canvases(parentEl, debounceRate) { + EventEmitter.decorate(this); + this.container = createContainingDiv(parentEl); + + // This canvas contains all of the treemap + this.main = createCanvas(this.container, "main"); + // This canvas contains only the zoomed in portion, overlaying the main canvas + this.zoom = createCanvas(this.container, "zoom"); + + this.removeHandlers = handleResizes(this, debounceRate); +} + +Canvases.prototype = { + /** + * Remove the handlers and elements + * + * @return {type} description + */ + destroy() { + this.removeHandlers(); + this.container.removeChild(this.main.canvas); + this.container.removeChild(this.zoom.canvas); + }, +}; + +module.exports = Canvases; + +/** + * Create the containing div + * + * @param {HTMLDivElement} parentEl + * @return {HTMLDivElement} + */ +function createContainingDiv(parentEl) { + const div = parentEl.ownerDocument.createElementNS(HTML_NS, "div"); + Object.assign(div.style, FULLSCREEN_STYLE); + parentEl.appendChild(div); + return div; +} + +/** + * Create a canvas and context + * + * @param {HTMLDivElement} container + * @param {String} className + * @return {Object} { canvas, ctx } + */ +function createCanvas(container, className) { + const window = container.ownerDocument.defaultView; + const canvas = container.ownerDocument.createElementNS(HTML_NS, "canvas"); + container.appendChild(canvas); + canvas.width = container.offsetWidth * window.devicePixelRatio; + canvas.height = container.offsetHeight * window.devicePixelRatio; + canvas.className = className; + + Object.assign(canvas.style, FULLSCREEN_STYLE, { + pointerEvents: "none", + }); + + const ctx = canvas.getContext("2d"); + + return { canvas, ctx }; +} + +/** + * Resize the canvases' resolutions, and fires out the onResize callback + * + * @param {HTMLDivElement} container + * @param {Object} canvases + * @param {Number} debounceRate + */ +function handleResizes(canvases, debounceRate) { + const { container, main, zoom } = canvases; + const window = container.ownerDocument.defaultView; + + function resize() { + const width = container.offsetWidth * window.devicePixelRatio; + const height = container.offsetHeight * window.devicePixelRatio; + + main.canvas.width = width; + main.canvas.height = height; + zoom.canvas.width = width; + zoom.canvas.height = height; + + canvases.emit("resize"); + } + + // Tests may not need debouncing + const debouncedResize = + debounceRate > 0 ? debounce(resize, debounceRate) : resize; + + window.addEventListener("resize", debouncedResize); + resize(); + + return function removeResizeHandlers() { + window.removeEventListener("resize", debouncedResize); + }; +} diff --git a/devtools/client/memory/components/tree-map/color-coarse-type.js b/devtools/client/memory/components/tree-map/color-coarse-type.js new file mode 100644 index 0000000000..b511f9f50e --- /dev/null +++ b/devtools/client/memory/components/tree-map/color-coarse-type.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Color the boxes in the treemap + */ + +const TYPES = ["objects", "other", "strings", "scripts", "domNode"]; + +// The factors determine how much the hue shifts +const TYPE_FACTOR = TYPES.length * 3; +const DEPTH_FACTOR = -10; +const H = 0.5; +const S = 0.6; +const L = 0.9; + +/** + * Recursively find the index of the coarse type of a node + * + * @param {Object} node + * d3 treemap + * @return {Integer} + * index + */ +function findCoarseTypeIndex(node) { + const index = TYPES.indexOf(node.name); + + if (node.parent) { + return index === -1 ? findCoarseTypeIndex(node.parent) : index; + } + + return TYPES.indexOf("other"); +} + +/** + * Decide a color value for depth to be used in the HSL computation + * + * @param {Object} node + * @return {Number} + */ +function depthColorFactor(node) { + return Math.min(1, node.depth / DEPTH_FACTOR); +} + +/** + * Decide a color value for type to be used in the HSL computation + * + * @param {Object} node + * @return {Number} + */ +function typeColorFactor(node) { + return findCoarseTypeIndex(node) / TYPE_FACTOR; +} + +/** + * Color a node + * + * @param {Object} node + * @return {Array} HSL values ranged 0-1 + */ +module.exports = function colorCoarseType(node) { + const h = Math.min(1, H + typeColorFactor(node)); + const s = Math.min(1, S); + const l = Math.min(1, L + depthColorFactor(node)); + + return [h, s, l]; +}; diff --git a/devtools/client/memory/components/tree-map/drag-zoom.js b/devtools/client/memory/components/tree-map/drag-zoom.js new file mode 100644 index 0000000000..034017e086 --- /dev/null +++ b/devtools/client/memory/components/tree-map/drag-zoom.js @@ -0,0 +1,337 @@ +/* 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 { debounce } = require("resource://devtools/shared/debounce.js"); +const { lerp } = require("resource://devtools/client/memory/utils.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +const LERP_SPEED = 0.5; +const ZOOM_SPEED = 0.01; +const TRANSLATE_EPSILON = 1; +const ZOOM_EPSILON = 0.001; +const LINE_SCROLL_MODE = 1; +const SCROLL_LINE_SIZE = 15; + +/** + * DragZoom is a constructor that contains the state of the current dragging and + * zooming behavior. It sets the scrolling and zooming behaviors. + * + * @param {HTMLElement} container description + * The container for the canvases + */ +function DragZoom(container, debounceRate, requestAnimationFrame) { + EventEmitter.decorate(this); + + this.isDragging = false; + + // The current mouse position + this.mouseX = container.offsetWidth / 2; + this.mouseY = container.offsetHeight / 2; + + // The total size of the visualization after being zoomed, in pixels + this.zoomedWidth = container.offsetWidth; + this.zoomedHeight = container.offsetHeight; + + // How much the visualization has been zoomed in + this.zoom = 0; + + // The offset of visualization from the container. This is applied after + // the zoom, and the visualization by default is centered + this.translateX = 0; + this.translateY = 0; + + // The size of the offset between the top/left of the container, and the + // top/left of the containing element. This value takes into account + // the device pixel ratio for canvas draws. + this.offsetX = 0; + this.offsetY = 0; + + // The smoothed values that are animated and eventually match the target + // values. The values are updated by the update loop + this.smoothZoom = 0; + this.smoothTranslateX = 0; + this.smoothTranslateY = 0; + + // Add the constant values for testing purposes + this.ZOOM_SPEED = ZOOM_SPEED; + this.ZOOM_EPSILON = ZOOM_EPSILON; + + const update = createUpdateLoop(container, this, requestAnimationFrame); + + this.destroy = setHandlers(this, container, update, debounceRate); +} + +module.exports = DragZoom; + +/** + * Returns an update loop. This loop smoothly updates the visualization when + * actions are performed. Once the animations have reached their target values + * the animation loop is stopped. + * + * Any value in the `dragZoom` object that starts with "smooth" is the + * smoothed version of a value that is interpolating toward the target value. + * For instance `dragZoom.smoothZoom` approaches `dragZoom.zoom` on each + * iteration of the update loop until it's sufficiently close as defined by + * the epsilon values. + * + * Only these smoothed values and the container CSS are updated by the loop. + * + * @param {HTMLDivElement} container + * @param {Object} dragZoom + * The values that represent the current dragZoom state + * @param {Function} requestAnimationFrame + */ +function createUpdateLoop(container, dragZoom, requestAnimationFrame) { + let isLooping = false; + + function update() { + const isScrollChanging = + Math.abs(dragZoom.smoothZoom - dragZoom.zoom) > ZOOM_EPSILON; + const isTranslateChanging = + Math.abs(dragZoom.smoothTranslateX - dragZoom.translateX) > + TRANSLATE_EPSILON || + Math.abs(dragZoom.smoothTranslateY - dragZoom.translateY) > + TRANSLATE_EPSILON; + + isLooping = isScrollChanging || isTranslateChanging; + + if (isScrollChanging) { + dragZoom.smoothZoom = lerp( + dragZoom.smoothZoom, + dragZoom.zoom, + LERP_SPEED + ); + } else { + dragZoom.smoothZoom = dragZoom.zoom; + } + + if (isTranslateChanging) { + dragZoom.smoothTranslateX = lerp( + dragZoom.smoothTranslateX, + dragZoom.translateX, + LERP_SPEED + ); + dragZoom.smoothTranslateY = lerp( + dragZoom.smoothTranslateY, + dragZoom.translateY, + LERP_SPEED + ); + } else { + dragZoom.smoothTranslateX = dragZoom.translateX; + dragZoom.smoothTranslateY = dragZoom.translateY; + } + + const zoom = 1 + dragZoom.smoothZoom; + const x = dragZoom.smoothTranslateX; + const y = dragZoom.smoothTranslateY; + container.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`; + + if (isLooping) { + requestAnimationFrame(update); + } + } + + // Go ahead and start the update loop + update(); + + return function restartLoopingIfStopped() { + if (!isLooping) { + update(); + } + }; +} + +/** + * Set the various event listeners and return a function to remove them + * + * @param {Object} dragZoom + * @param {HTMLElement} container + * @param {Function} update + * @return {Function} The function to remove the handlers + */ +function setHandlers(dragZoom, container, update, debounceRate) { + const emitChanged = debounce(() => dragZoom.emit("change"), debounceRate); + + const removeDragHandlers = setDragHandlers( + container, + dragZoom, + emitChanged, + update + ); + const removeScrollHandlers = setScrollHandlers( + container, + dragZoom, + emitChanged, + update + ); + + return function removeHandlers() { + removeDragHandlers(); + removeScrollHandlers(); + }; +} + +/** + * Sets handlers for when the user drags on the canvas. It will update dragZoom + * object with new translate and offset values. + * + * @param {HTMLElement} container + * @param {Object} dragZoom + * @param {Function} changed + * @param {Function} update + */ +function setDragHandlers(container, dragZoom, emitChanged, update) { + const parentEl = container.parentElement; + + function startDrag() { + dragZoom.isDragging = true; + container.style.cursor = "grabbing"; + } + + function stopDrag() { + dragZoom.isDragging = false; + container.style.cursor = "grab"; + } + + function drag(event) { + const prevMouseX = dragZoom.mouseX; + const prevMouseY = dragZoom.mouseY; + + dragZoom.mouseX = event.clientX - parentEl.offsetLeft; + dragZoom.mouseY = event.clientY - parentEl.offsetTop; + + if (!dragZoom.isDragging) { + return; + } + + dragZoom.translateX += dragZoom.mouseX - prevMouseX; + dragZoom.translateY += dragZoom.mouseY - prevMouseY; + + keepInView(container, dragZoom); + + emitChanged(); + update(); + } + + parentEl.addEventListener("mousedown", startDrag); + parentEl.addEventListener("mouseup", stopDrag); + parentEl.addEventListener("mouseout", stopDrag); + parentEl.addEventListener("mousemove", drag); + + return function removeListeners() { + parentEl.removeEventListener("mousedown", startDrag); + parentEl.removeEventListener("mouseup", stopDrag); + parentEl.removeEventListener("mouseout", stopDrag); + parentEl.removeEventListener("mousemove", drag); + }; +} + +/** + * Sets the handlers for when the user scrolls. It updates the dragZoom object + * and keeps the canvases all within the view. After changing values update + * loop is called, and the changed event is emitted. + * + * @param {HTMLDivElement} container + * @param {Object} dragZoom + * @param {Function} changed + * @param {Function} update + */ +function setScrollHandlers(container, dragZoom, emitChanged, update) { + const window = container.ownerDocument.defaultView; + + function handleWheel(event) { + event.preventDefault(); + + if (dragZoom.isDragging) { + return; + } + + // Update the zoom level + const scrollDelta = getScrollDelta(event, window); + const prevZoom = dragZoom.zoom; + dragZoom.zoom = Math.max(0, dragZoom.zoom - scrollDelta * ZOOM_SPEED); + + // Calculate the updated width and height + const prevZoomedWidth = container.offsetWidth * (1 + prevZoom); + const prevZoomedHeight = container.offsetHeight * (1 + prevZoom); + dragZoom.zoomedWidth = container.offsetWidth * (1 + dragZoom.zoom); + dragZoom.zoomedHeight = container.offsetHeight * (1 + dragZoom.zoom); + const deltaWidth = dragZoom.zoomedWidth - prevZoomedWidth; + const deltaHeight = dragZoom.zoomedHeight - prevZoomedHeight; + + const mouseOffsetX = dragZoom.mouseX - container.offsetWidth / 2; + const mouseOffsetY = dragZoom.mouseY - container.offsetHeight / 2; + + // The ratio of where the center of the mouse is in regards to the total + // zoomed width/height + const ratioZoomX = + (prevZoomedWidth / 2 + mouseOffsetX - dragZoom.translateX) / + prevZoomedWidth; + const ratioZoomY = + (prevZoomedHeight / 2 + mouseOffsetY - dragZoom.translateY) / + prevZoomedHeight; + + // Distribute the change in width and height based on the above ratio + dragZoom.translateX -= lerp(-deltaWidth / 2, deltaWidth / 2, ratioZoomX); + dragZoom.translateY -= lerp(-deltaHeight / 2, deltaHeight / 2, ratioZoomY); + + // Keep the canvas in range of the container + keepInView(container, dragZoom); + emitChanged(); + update(); + } + + container.addEventListener("wheel", handleWheel); + + return function removeListener() { + container.removeEventListener("wheel", handleWheel); + }; +} + +/** + * Account for the various mouse wheel event types, per pixel or per line + * + * @param {WheelEvent} event + * @param {Window} window + * @return {Number} The scroll size in pixels + */ +function getScrollDelta(event, window) { + if (event.deltaMode === LINE_SCROLL_MODE) { + // Update by a fixed arbitrary value to normalize scroll types + return event.deltaY * SCROLL_LINE_SIZE; + } + return event.deltaY; +} + +/** + * Keep the dragging and zooming within the view by updating the values in the + * `dragZoom` object. + * + * @param {HTMLDivElement} container + * @param {Object} dragZoom + */ +function keepInView(container, dragZoom) { + const { devicePixelRatio } = container.ownerDocument.defaultView; + const overdrawX = (dragZoom.zoomedWidth - container.offsetWidth) / 2; + const overdrawY = (dragZoom.zoomedHeight - container.offsetHeight) / 2; + + dragZoom.translateX = Math.max( + -overdrawX, + Math.min(overdrawX, dragZoom.translateX) + ); + dragZoom.translateY = Math.max( + -overdrawY, + Math.min(overdrawY, dragZoom.translateY) + ); + + dragZoom.offsetX = + devicePixelRatio * + ((dragZoom.zoomedWidth - container.offsetWidth) / 2 - dragZoom.translateX); + dragZoom.offsetY = + devicePixelRatio * + ((dragZoom.zoomedHeight - container.offsetHeight) / 2 - + dragZoom.translateY); +} diff --git a/devtools/client/memory/components/tree-map/draw.js b/devtools/client/memory/components/tree-map/draw.js new file mode 100644 index 0000000000..12a5901332 --- /dev/null +++ b/devtools/client/memory/components/tree-map/draw.js @@ -0,0 +1,317 @@ +/* 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"; +/** + * Draw the treemap into the provided canvases using the 2d context. The treemap + * layout is computed with d3. There are 2 canvases provided, each matching + * the resolution of the window. The main canvas is a fully drawn version of + * the treemap that is positioned and zoomed using css. It gets blurry the more + * you zoom in as it doesn't get redrawn when zooming. The zoom canvas is + * repositioned absolutely after every change in the dragZoom object, and then + * redrawn to provide a full-resolution (non-blurry) view of zoomed in segment + * of the treemap. + */ + +const colorCoarseType = require("resource://devtools/client/memory/components/tree-map/color-coarse-type.js"); +const { + hslToStyle, + formatAbbreviatedBytes, + L10N, +} = require("resource://devtools/client/memory/utils.js"); + +// A constant fully zoomed out dragZoom object for the main canvas +const NO_SCROLL = { + translateX: 0, + translateY: 0, + zoom: 0, + offsetX: 0, + offsetY: 0, +}; + +// Drawing constants +const ELLIPSIS = "..."; +const TEXT_MARGIN = 2; +const TEXT_COLOR = "#000000"; +const TEXT_LIGHT_COLOR = "rgba(0,0,0,0.5)"; +const LINE_WIDTH = 1; +const FONT_SIZE = 10; +const FONT_LINE_HEIGHT = 2; +const PADDING = [5 + FONT_SIZE, 5, 5, 5]; +const COUNT_LABEL = L10N.getStr("tree-map.node-count"); + +/** + * Setup and start drawing the treemap visualization + * + * @param {Object} report + * @param {Object} canvases + * A CanvasUtils object that contains references to the main and zoom + * canvases and contexts + * @param {Object} dragZoom + * A DragZoom object representing the current state of the dragging + * and zooming behavior + */ +exports.setupDraw = function (report, canvases, dragZoom) { + const getTreemap = configureD3Treemap.bind(null, canvases.main.canvas); + + let treemap, nodes; + + function drawFullTreemap() { + treemap = getTreemap(); + nodes = treemap(report); + drawTreemap(canvases.main, nodes, NO_SCROLL); + drawTreemap(canvases.zoom, nodes, dragZoom); + } + + function drawZoomedTreemap() { + drawTreemap(canvases.zoom, nodes, dragZoom); + positionZoomedCanvas(canvases.zoom.canvas, dragZoom); + } + + drawFullTreemap(); + canvases.on("resize", drawFullTreemap); + dragZoom.on("change", drawZoomedTreemap); +}; + +/** + * Returns a configured d3 treemap function + * + * @param {HTMLCanvasElement} canvas + * @return {Function} + */ +const configureD3Treemap = (exports.configureD3Treemap = function (canvas) { + const window = canvas.ownerDocument.defaultView; + const ratio = window.devicePixelRatio; + const treemap = window.d3.layout + .treemap() + .size([ + // The d3 layout includes the padding around everything, add some + // extra padding to the size to compensate for thi + canvas.width + (PADDING[1] + PADDING[3]) * ratio, + canvas.height + (PADDING[0] + PADDING[2]) * ratio, + ]) + .sticky(true) + .padding([ + PADDING[0] * ratio, + PADDING[1] * ratio, + PADDING[2] * ratio, + PADDING[3] * ratio, + ]) + .value(d => d.bytes); + + /** + * Create treemap nodes from a census report that are sorted by depth + * + * @param {Object} report + * @return {Array} An array of d3 treemap nodes + * // https://github.com/mbostock/d3/wiki/Treemap-Layout + * parent - the parent node, or null for the root. + * children - the array of child nodes, or null for leaf nodes. + * value - the node value, as returned by the value accessor. + * depth - the depth of the node, starting at 0 for the root. + * area - the computed pixel area of this node. + * x - the minimum x-coordinate of the node position. + * y - the minimum y-coordinate of the node position. + * z - the orientation of this cell’s subdivision, if any. + * dx - the x-extent of the node position. + * dy - the y-extent of the node position. + */ + return function depthSortedNodes(report) { + const nodes = treemap(report); + nodes.sort((a, b) => a.depth - b.depth); + return nodes; + }; +}); + +/** + * Draw the text, cut it in half every time it doesn't fit until it fits or + * it's smaller than the "..." text. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Number} x + * the position of the text + * @param {Number} y + * the position of the text + * @param {Number} innerWidth + * the inner width of the containing treemap cell + * @param {Text} name + */ +const drawTruncatedName = (exports.drawTruncatedName = function ( + ctx, + x, + y, + innerWidth, + name +) { + const truncated = name.substr(0, Math.floor(name.length / 2)); + const formatted = truncated + ELLIPSIS; + + if (ctx.measureText(formatted).width > innerWidth) { + drawTruncatedName(ctx, x, y, innerWidth, truncated); + } else { + ctx.fillText(formatted, x, y); + } +}); + +/** + * Fit and draw the text in a node with the following strategies to shrink + * down the text size: + * + * Function 608KB 9083 count + * Function + * Func... + * Fu... + * ... + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} node + * @param {Number} borderWidth + * @param {Object} dragZoom + * @param {Array} padding + */ +const drawText = (exports.drawText = function ( + ctx, + node, + borderWidth, + ratio, + dragZoom, + padding +) { + let { dx, dy, name, totalBytes, totalCount } = node; + const scale = dragZoom.zoom + 1; + dx *= scale; + dy *= scale; + + // Start checking to see how much text we can fit in, optimizing for the + // common case of lots of small leaf nodes + if (FONT_SIZE * FONT_LINE_HEIGHT < dy) { + const margin = borderWidth(node) * 1.5 + ratio * TEXT_MARGIN; + const x = margin + (node.x - padding[0]) * scale - dragZoom.offsetX; + const y = margin + (node.y - padding[1]) * scale - dragZoom.offsetY; + const innerWidth = dx - margin * 2; + const nameSize = ctx.measureText(name).width; + + if (ctx.measureText(ELLIPSIS).width > innerWidth) { + return; + } + + ctx.fillStyle = TEXT_COLOR; + + if (nameSize > innerWidth) { + // The name is too long - halve the name as an expediant way to shorten it + drawTruncatedName(ctx, x, y, innerWidth, name); + } else { + const bytesFormatted = formatAbbreviatedBytes(totalBytes); + const countFormatted = `${totalCount} ${COUNT_LABEL}`; + const byteSize = ctx.measureText(bytesFormatted).width; + const countSize = ctx.measureText(countFormatted).width; + const spaceSize = ctx.measureText(" ").width; + + if (nameSize + byteSize + countSize + spaceSize * 3 > innerWidth) { + // The full name will fit + ctx.fillText(`${name}`, x, y); + } else { + // The full name plus the byte information will fit + ctx.fillText(name, x, y); + ctx.fillStyle = TEXT_LIGHT_COLOR; + ctx.fillText( + `${bytesFormatted} ${countFormatted}`, + x + nameSize + spaceSize, + y + ); + } + } + } +}); + +/** + * Draw a box given a node + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} node + * @param {Number} borderWidth + * @param {Number} ratio + * @param {Object} dragZoom + * @param {Array} padding + */ +const drawBox = (exports.drawBox = function ( + ctx, + node, + borderWidth, + dragZoom, + padding +) { + const border = borderWidth(node); + const fillHSL = colorCoarseType(node); + const strokeHSL = [fillHSL[0], fillHSL[1], fillHSL[2] * 0.5]; + const scale = 1 + dragZoom.zoom; + + // Offset the draw so that box strokes don't overlap + const x = scale * (node.x - padding[0]) - dragZoom.offsetX + border / 2; + const y = scale * (node.y - padding[1]) - dragZoom.offsetY + border / 2; + const dx = scale * node.dx - border; + const dy = scale * node.dy - border; + + ctx.fillStyle = hslToStyle(...fillHSL); + ctx.fillRect(x, y, dx, dy); + + ctx.strokeStyle = hslToStyle(...strokeHSL); + ctx.lineWidth = border; + ctx.strokeRect(x, y, dx, dy); +}); + +/** + * Draw the overall treemap + * + * @param {HTMLCanvasElement} canvas + * @param {CanvasRenderingContext2D} ctx + * @param {Array} nodes + * @param {Objbect} dragZoom + */ +const drawTreemap = (exports.drawTreemap = function ( + { canvas, ctx }, + nodes, + dragZoom +) { + const window = canvas.ownerDocument.defaultView; + const ratio = window.devicePixelRatio; + const canvasArea = canvas.width * canvas.height; + // Subtract the outer padding from the tree map layout. + const padding = [PADDING[3] * ratio, PADDING[0] * ratio]; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.font = `${FONT_SIZE * ratio}px sans-serif`; + ctx.textBaseline = "top"; + + function borderWidth(node) { + const areaRatio = Math.sqrt(node.area / canvasArea); + return ratio * Math.max(1, LINE_WIDTH * areaRatio); + } + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node.parent === undefined) { + continue; + } + + drawBox(ctx, node, borderWidth, dragZoom, padding); + drawText(ctx, node, borderWidth, ratio, dragZoom, padding); + } +}); + +/** + * Set the position of the zoomed in canvas. It always take up 100% of the view + * window, but is transformed relative to the zoomed in containing element, + * essentially reversing the transform of the containing element. + * + * @param {HTMLCanvasElement} canvas + * @param {Object} dragZoom + */ +const positionZoomedCanvas = function (canvas, dragZoom) { + const scale = 1 / (1 + dragZoom.zoom); + const x = -dragZoom.translateX; + const y = -dragZoom.translateY; + canvas.style.transform = `scale(${scale}) translate(${x}px, ${y}px)`; +}; + +exports.positionZoomedCanvas = positionZoomedCanvas; diff --git a/devtools/client/memory/components/tree-map/moz.build b/devtools/client/memory/components/tree-map/moz.build new file mode 100644 index 0000000000..a9e5900339 --- /dev/null +++ b/devtools/client/memory/components/tree-map/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( + "canvas-utils.js", + "color-coarse-type.js", + "drag-zoom.js", + "draw.js", + "start.js", +) diff --git a/devtools/client/memory/components/tree-map/start.js b/devtools/client/memory/components/tree-map/start.js new file mode 100644 index 0000000000..80ae483903 --- /dev/null +++ b/devtools/client/memory/components/tree-map/start.js @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + setupDraw, +} = require("resource://devtools/client/memory/components/tree-map/draw.js"); +const DragZoom = require("resource://devtools/client/memory/components/tree-map/drag-zoom.js"); +const CanvasUtils = require("resource://devtools/client/memory/components/tree-map/canvas-utils.js"); + +/** + * Start the tree map visualization + * + * @param {HTMLDivElement} container + * @param {Object} report + * the report from a census + * @param {Number} debounceRate + */ +module.exports = function startVisualization( + parentEl, + report, + debounceRate = 60 +) { + const window = parentEl.ownerDocument.defaultView; + const canvases = new CanvasUtils(parentEl, debounceRate); + const dragZoom = new DragZoom( + canvases.container, + debounceRate, + window.requestAnimationFrame + ); + + setupDraw(report, canvases, dragZoom); + + return function stopVisualization() { + canvases.destroy(); + dragZoom.destroy(); + }; +}; diff --git a/devtools/client/memory/constants.js b/devtools/client/memory/constants.js new file mode 100644 index 0000000000..c729866ffe --- /dev/null +++ b/devtools/client/memory/constants.js @@ -0,0 +1,360 @@ +/* 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"; + +// Options passed to MemoryFront's startRecordingAllocations never change. +exports.ALLOCATION_RECORDING_OPTIONS = { + probability: 1, + maxLogLength: 1, +}; + +// If TREE_ROW_HEIGHT changes, be sure to change `var(--heap-tree-row-height)` +// in `devtools/client/themes/memory.css` +exports.TREE_ROW_HEIGHT = 18; + +/** * Actions ******************************************************************/ + +const actions = (exports.actions = {}); + +// Fired by UI to request a snapshot from the actor. +actions.TAKE_SNAPSHOT_START = "take-snapshot-start"; +actions.TAKE_SNAPSHOT_END = "take-snapshot-end"; + +// When a heap snapshot is read into memory -- only fired +// once per snapshot. +actions.READ_SNAPSHOT_START = "read-snapshot-start"; +actions.READ_SNAPSHOT_END = "read-snapshot-end"; + +// When a census is being performed on a heap snapshot +actions.TAKE_CENSUS_START = "take-census-start"; +actions.TAKE_CENSUS_END = "take-census-end"; +actions.TAKE_CENSUS_ERROR = "take-census-error"; + +// When a tree map is being calculated on a heap snapshot +actions.TAKE_TREE_MAP_START = "take-tree-map-start"; +actions.TAKE_TREE_MAP_END = "take-tree-map-end"; +actions.TAKE_TREE_MAP_ERROR = "take-tree-map-error"; + +// When requesting that the server start/stop recording allocation stacks. +actions.TOGGLE_RECORD_ALLOCATION_STACKS_START = + "toggle-record-allocation-stacks-start"; +actions.TOGGLE_RECORD_ALLOCATION_STACKS_END = + "toggle-record-allocation-stacks-end"; + +// When a heap snapshot is being saved to a user-specified +// location on disk. +actions.EXPORT_SNAPSHOT_START = "export-snapshot-start"; +actions.EXPORT_SNAPSHOT_END = "export-snapshot-end"; +actions.EXPORT_SNAPSHOT_ERROR = "export-snapshot-error"; + +// When a heap snapshot is being read from a user selected file, +// and represents the entire state until the census is available. +actions.IMPORT_SNAPSHOT_START = "import-snapshot-start"; +actions.IMPORT_SNAPSHOT_END = "import-snapshot-end"; +actions.IMPORT_SNAPSHOT_ERROR = "import-snapshot-error"; + +// Fired by UI to select a snapshot to view. +actions.SELECT_SNAPSHOT = "select-snapshot"; + +// Fired to delete a provided list of snapshots +actions.DELETE_SNAPSHOTS_START = "delete-snapshots-start"; +actions.DELETE_SNAPSHOTS_END = "delete-snapshots-end"; + +// Fired to toggle tree inversion on or off. +actions.TOGGLE_INVERTED = "toggle-inverted"; + +// Fired when a snapshot is selected for diffing. +actions.SELECT_SNAPSHOT_FOR_DIFFING = "select-snapshot-for-diffing"; + +// Fired when taking a census diff. +actions.TAKE_CENSUS_DIFF_START = "take-census-diff-start"; +actions.TAKE_CENSUS_DIFF_END = "take-census-diff-end"; +actions.DIFFING_ERROR = "diffing-error"; + +// Fired to set a new census display. +actions.SET_CENSUS_DISPLAY = "set-census-display"; + +// Fired to change the display that controls the dominator tree labels. +actions.SET_LABEL_DISPLAY = "set-label-display"; + +// Fired to set a tree map display +actions.SET_TREE_MAP_DISPLAY = "set-tree-map-display"; + +// Fired when changing between census or dominators view. +actions.CHANGE_VIEW = "change-view"; +actions.POP_VIEW = "pop-view"; + +// Fired when there is an error processing a snapshot or taking a census. +actions.SNAPSHOT_ERROR = "snapshot-error"; + +// Fired when there is a new filter string set. +actions.SET_FILTER_STRING = "set-filter-string"; + +// Fired to expand or collapse nodes in census reports. +actions.EXPAND_CENSUS_NODE = "expand-census-node"; +actions.EXPAND_DIFFING_CENSUS_NODE = "expand-diffing-census-node"; +actions.COLLAPSE_CENSUS_NODE = "collapse-census-node"; +actions.COLLAPSE_DIFFING_CENSUS_NODE = "collapse-diffing-census-node"; + +// Fired when nodes in various trees are focused. +actions.FOCUS_CENSUS_NODE = "focus-census-node"; +actions.FOCUS_DIFFING_CENSUS_NODE = "focus-diffing-census-node"; +actions.FOCUS_DOMINATOR_TREE_NODE = "focus-dominator-tree-node"; + +actions.FOCUS_INDIVIDUAL = "focus-individual"; +actions.FETCH_INDIVIDUALS_START = "fetch-individuals-start"; +actions.FETCH_INDIVIDUALS_END = "fetch-individuals-end"; +actions.INDIVIDUALS_ERROR = "individuals-error"; + +actions.COMPUTE_DOMINATOR_TREE_START = "compute-dominator-tree-start"; +actions.COMPUTE_DOMINATOR_TREE_END = "compute-dominator-tree-end"; +actions.FETCH_DOMINATOR_TREE_START = "fetch-dominator-tree-start"; +actions.FETCH_DOMINATOR_TREE_END = "fetch-dominator-tree-end"; +actions.DOMINATOR_TREE_ERROR = "dominator-tree-error"; +actions.FETCH_IMMEDIATELY_DOMINATED_START = "fetch-immediately-dominated-start"; +actions.FETCH_IMMEDIATELY_DOMINATED_END = "fetch-immediately-dominated-end"; +actions.EXPAND_DOMINATOR_TREE_NODE = "expand-dominator-tree-node"; +actions.COLLAPSE_DOMINATOR_TREE_NODE = "collapse-dominator-tree-node"; + +actions.RESIZE_SHORTEST_PATHS = "resize-shortest-paths"; + +// Fired when the memory front is changed. +actions.UPDATE_MEMORY_FRONT = "update-memory-front"; + +/** * Census Displays ***************************************************************/ + +const COUNT = Object.freeze({ by: "count", count: true, bytes: true }); +const INTERNAL_TYPE = Object.freeze({ by: "internalType", then: COUNT }); +const DESCRIPTIVE_TYPE = Object.freeze({ by: "descriptiveType", then: COUNT }); +const ALLOCATION_STACK = Object.freeze({ + by: "allocationStack", + then: COUNT, + noStack: COUNT, +}); +const OBJECT_CLASS = Object.freeze({ + by: "objectClass", + then: COUNT, + other: COUNT, +}); +const COARSE_TYPE = Object.freeze({ + by: "coarseType", + objects: OBJECT_CLASS, + strings: COUNT, + scripts: { + by: "filename", + then: INTERNAL_TYPE, + noFilename: INTERNAL_TYPE, + }, + other: INTERNAL_TYPE, + domNode: DESCRIPTIVE_TYPE, +}); + +exports.censusDisplays = Object.freeze({ + coarseType: Object.freeze({ + displayName: "Type", + get tooltip() { + // Importing down here is necessary because of the circular dependency + // this introduces with `./utils.js`. + const { L10N } = require("resource://devtools/client/memory/utils.js"); + return L10N.getStr("censusDisplays.coarseType.tooltip"); + }, + inverted: true, + breakdown: COARSE_TYPE, + }), + + allocationStack: Object.freeze({ + displayName: "Call Stack", + get tooltip() { + const { L10N } = require("resource://devtools/client/memory/utils.js"); + return L10N.getStr("censusDisplays.allocationStack.tooltip"); + }, + inverted: false, + breakdown: ALLOCATION_STACK, + }), + + invertedAllocationStack: Object.freeze({ + displayName: "Inverted Call Stack", + get tooltip() { + const { L10N } = require("resource://devtools/client/memory/utils.js"); + return L10N.getStr("censusDisplays.invertedAllocationStack.tooltip"); + }, + inverted: true, + breakdown: ALLOCATION_STACK, + }), +}); + +const DOMINATOR_TREE_LABEL_COARSE_TYPE = Object.freeze({ + by: "coarseType", + objects: OBJECT_CLASS, + scripts: Object.freeze({ + by: "internalType", + then: Object.freeze({ + by: "filename", + then: COUNT, + noFilename: COUNT, + }), + }), + strings: INTERNAL_TYPE, + other: INTERNAL_TYPE, + domNode: DESCRIPTIVE_TYPE, +}); + +exports.labelDisplays = Object.freeze({ + coarseType: Object.freeze({ + displayName: "Type", + get tooltip() { + const { L10N } = require("resource://devtools/client/memory/utils.js"); + return L10N.getStr("dominatorTreeDisplays.coarseType.tooltip"); + }, + breakdown: DOMINATOR_TREE_LABEL_COARSE_TYPE, + }), + + allocationStack: Object.freeze({ + displayName: "Call Stack", + get tooltip() { + const { L10N } = require("resource://devtools/client/memory/utils.js"); + return L10N.getStr("dominatorTreeDisplays.allocationStack.tooltip"); + }, + breakdown: Object.freeze({ + by: "allocationStack", + then: DOMINATOR_TREE_LABEL_COARSE_TYPE, + noStack: DOMINATOR_TREE_LABEL_COARSE_TYPE, + }), + }), +}); + +exports.treeMapDisplays = Object.freeze({ + coarseType: Object.freeze({ + displayName: "Type", + get tooltip() { + const { L10N } = require("resource://devtools/client/memory/utils.js"); + return L10N.getStr("treeMapDisplays.coarseType.tooltip"); + }, + breakdown: COARSE_TYPE, + inverted: false, + }), +}); + +/** * View States **************************************************************/ + +/** + * The various main views that the tool can be in. + */ +const viewState = (exports.viewState = Object.create(null)); +viewState.CENSUS = "view-state-census"; +viewState.DIFFING = "view-state-diffing"; +viewState.DOMINATOR_TREE = "view-state-dominator-tree"; +viewState.TREE_MAP = "view-state-tree-map"; +viewState.INDIVIDUALS = "view-state-individuals"; + +/** * Snapshot States **********************************************************/ + +const snapshotState = (exports.snapshotState = Object.create(null)); + +/** + * Various states a snapshot can be in. + * An FSM describing snapshot states: + * + * SAVING -> SAVED -> READING -> READ + * ↗ + * IMPORTING + * + * Any of these states may go to the ERROR state, from which they can never + * leave (mwah ha ha ha!) + */ +snapshotState.ERROR = "snapshot-state-error"; +snapshotState.IMPORTING = "snapshot-state-importing"; +snapshotState.SAVING = "snapshot-state-saving"; +snapshotState.SAVED = "snapshot-state-saved"; +snapshotState.READING = "snapshot-state-reading"; +snapshotState.READ = "snapshot-state-read"; + +/* + * Various states the census model can be in. + * + * SAVING <-> SAVED + * | + * V + * ERROR + */ + +const censusState = (exports.censusState = Object.create(null)); + +censusState.SAVING = "census-state-saving"; +censusState.SAVED = "census-state-saved"; +censusState.ERROR = "census-state-error"; + +/* + * Various states the tree map model can be in. + * + * SAVING <-> SAVED + * | + * V + * ERROR + */ + +const treeMapState = (exports.treeMapState = Object.create(null)); + +treeMapState.SAVING = "tree-map-state-saving"; +treeMapState.SAVED = "tree-map-state-saved"; +treeMapState.ERROR = "tree-map-state-error"; + +/** * Diffing States ***********************************************************/ + +/* + * Various states the diffing model can be in. + * + * SELECTING --> TAKING_DIFF <---> TOOK_DIFF + * | + * V + * ERROR + */ +const diffingState = (exports.diffingState = Object.create(null)); + +// Selecting the two snapshots to diff. +diffingState.SELECTING = "diffing-state-selecting"; + +// Currently computing the diff between the two selected snapshots. +diffingState.TAKING_DIFF = "diffing-state-taking-diff"; + +// Have the diff between the two selected snapshots. +diffingState.TOOK_DIFF = "diffing-state-took-diff"; + +// An error occurred while computing the diff. +diffingState.ERROR = "diffing-state-error"; + +/** * Dominator Tree States ****************************************************/ + +/* + * Various states the dominator tree model can be in. + * + * COMPUTING -> COMPUTED -> FETCHING -> LOADED <--> INCREMENTAL_FETCHING + * + * Any state may lead to the ERROR state, from which it can never leave. + */ +const dominatorTreeState = (exports.dominatorTreeState = Object.create(null)); +dominatorTreeState.COMPUTING = "dominator-tree-state-computing"; +dominatorTreeState.COMPUTED = "dominator-tree-state-computed"; +dominatorTreeState.FETCHING = "dominator-tree-state-fetching"; +dominatorTreeState.LOADED = "dominator-tree-state-loaded"; +dominatorTreeState.INCREMENTAL_FETCHING = + "dominator-tree-state-incremental-fetching"; +dominatorTreeState.ERROR = "dominator-tree-state-error"; + +/** * States for Individuals Model *********************************************/ + +/* + * Various states the individuals model can be in. + * + * COMPUTING_DOMINATOR_TREE -> FETCHING -> FETCHED + * + * Any state may lead to the ERROR state, from which it can never leave. + */ +const individualsState = (exports.individualsState = Object.create(null)); +individualsState.COMPUTING_DOMINATOR_TREE = + "individuals-state-computing-dominator-tree"; +individualsState.FETCHING = "individuals-state-fetching"; +individualsState.FETCHED = "individuals-state-fetched"; +individualsState.ERROR = "individuals-state-error"; diff --git a/devtools/client/memory/dominator-tree-lazy-children.js b/devtools/client/memory/dominator-tree-lazy-children.js new file mode 100644 index 0000000000..f61efbc391 --- /dev/null +++ b/devtools/client/memory/dominator-tree-lazy-children.js @@ -0,0 +1,60 @@ +/* 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 `DominatorTreeLazyChildren` is a placeholder that represents a future + * subtree in an existing `DominatorTreeNode` tree that is currently being + * incrementally fetched from the `HeapAnalysesWorker`. + * + * @param {NodeId} parentNodeId + * @param {Number} siblingIndex + */ +function DominatorTreeLazyChildren(parentNodeId, siblingIndex) { + this._parentNodeId = parentNodeId; + this._siblingIndex = siblingIndex; +} + +/** + * Generate a unique key for this `DominatorTreeLazyChildren` instance. This can + * be used as the key in a hash table or as the `key` property for a React + * component, for example. + * + * @returns {String} + */ +DominatorTreeLazyChildren.prototype.key = function () { + return `dominator-tree-lazy-children-${this._parentNodeId}-${this._siblingIndex}`; +}; + +/** + * Return true if this is a placeholder for the first child of its + * parent. Return false if it is a placeholder for loading more of its parent's + * children. + * + * @returns {Boolean} + */ +DominatorTreeLazyChildren.prototype.isFirstChild = function () { + return this._siblingIndex === 0; +}; + +/** + * Get this subtree's parent node's identifier. + * + * @returns {NodeId} + */ +DominatorTreeLazyChildren.prototype.parentNodeId = function () { + return this._parentNodeId; +}; + +/** + * Get this subtree's index in its parent's children array. + * + * @returns {Number} + */ +DominatorTreeLazyChildren.prototype.siblingIndex = function () { + return this._siblingIndex; +}; + +module.exports = DominatorTreeLazyChildren; diff --git a/devtools/client/memory/index.xhtml b/devtools/client/memory/index.xhtml new file mode 100644 index 0000000000..5d7836a13c --- /dev/null +++ b/devtools/client/memory/index.xhtml @@ -0,0 +1,49 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> + +<!-- 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/. --> +<html xmlns="http://www.w3.org/1999/xhtml" dir=""> + <head> + <link + rel="stylesheet" + href="chrome://devtools/skin/widgets.css" + type="text/css" + /> + <link + rel="stylesheet" + href="chrome://devtools/skin/memory.css" + type="text/css" + /> + <link + rel="stylesheet" + href="chrome://devtools/skin/components-frame.css" + type="text/css" + /> + <link + rel="stylesheet" + href="chrome://devtools/skin/components-h-split-box.css" + type="text/css" + /> + </head> + <body class="theme-body"> + <div id="app"></div> + + <script + src="chrome://devtools/content/shared/theme-switching.js" + defer="true" + ></script> + + <script + src="chrome://global/content/third_party/d3/d3.js" + defer="true" + ></script> + + <script + src="chrome://devtools/content/shared/vendor/dagre-d3.js" + defer="true" + ></script> + </body> +</html> diff --git a/devtools/client/memory/initializer.js b/devtools/client/memory/initializer.js new file mode 100644 index 0000000000..541bd224eb --- /dev/null +++ b/devtools/client/memory/initializer.js @@ -0,0 +1,85 @@ +/* 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/. */ + +/* exported initialize, destroy, Promise */ + +"use strict"; + +const { + createFactory, + createElement, +} = require("resource://devtools/client/shared/vendor/react.js"); +const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js"); +const { + Provider, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const App = createFactory(require("resource://devtools/client/memory/app.js")); +const Store = require("resource://devtools/client/memory/store.js"); +const { assert } = require("resource://devtools/shared/DevToolsUtils.js"); + +const { + updateMemoryFront, +} = require("resource://devtools/client/memory/actions/front.js"); + +// Shared variables used by several methods of this module. +let root, store, unsubscribe; + +const initialize = async function (commands) { + // Exposed by panel.js + const { gToolbox, gHeapAnalysesClient } = window; + + root = document.querySelector("#app"); + store = Store(); + const app = createElement(App, { + toolbox: gToolbox, + commands, + heapWorker: gHeapAnalysesClient, + }); + const provider = createElement(Provider, { store }, app); + ReactDOM.render(provider, root); + unsubscribe = store.subscribe(onStateChange); + + // Exposed for tests. + window.gStore = store; +}; + +const updateFront = front => { + store.dispatch(updateMemoryFront(front)); +}; + +const destroy = function () { + const ok = ReactDOM.unmountComponentAtNode(root); + assert( + ok, + "Should successfully unmount the memory tool's top level React component" + ); + + unsubscribe(); +}; + +// Current state +let isHighlighted; + +/** + * Fired on any state change, currently only handles toggling + * the highlighting of the tool when recording allocations. + */ +function onStateChange() { + const { gToolbox } = window; + + const isRecording = store.getState().allocations.recording; + if (isRecording === isHighlighted) { + return; + } + + if (isRecording) { + gToolbox.highlightTool("memory"); + } else { + gToolbox.unhighlightTool("memory"); + } + + isHighlighted = isRecording; +} + +module.exports = { initialize, updateFront, destroy }; diff --git a/devtools/client/memory/models.js b/devtools/client/memory/models.js new file mode 100644 index 0000000000..83ed3641b0 --- /dev/null +++ b/devtools/client/memory/models.js @@ -0,0 +1,546 @@ +/* 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/. */ + +/* global treeMapState, censusState */ +/* eslint no-shadow: ["error", { "allow": ["app"] }] */ + +"use strict"; + +const { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { MemoryFront } = require("resource://devtools/client/fronts/memory.js"); +const HeapAnalysesClient = require("resource://devtools/shared/heapsnapshot/HeapAnalysesClient.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + snapshotState: states, + diffingState, + dominatorTreeState, + viewState, + individualsState, +} = require("resource://devtools/client/memory/constants.js"); + +/** + * ONLY USE THIS FOR MODEL VALIDATORS IN CONJUCTION WITH assert()! + * + * React checks that the returned values from validator functions are instances + * of Error, but because React is loaded in its own global, that check is always + * false and always results in a warning. + * + * To work around this and still get model validation, just call assert() inside + * a function passed to catchAndIgnore. The assert() function will still report + * assertion failures, but this funciton will swallow the errors so that React + * doesn't go crazy and drown out the real error in irrelevant and incorrect + * warnings. + * + * Example usage: + * + * const MyModel = PropTypes.shape({ + * someProperty: catchAndIgnore(function (model) { + * assert(someInvariant(model.someProperty), "Should blah blah"); + * }) + * }); + */ +function catchAndIgnore(fn) { + return function (...args) { + try { + fn(...args); + } catch (err) { + // continue regardless of error + } + + return null; + }; +} + +/** + * The data describing the census report's shape, and its associated metadata. + * + * @see `js/src/doc/Debugger/Debugger.Memory.md` + */ +const censusDisplayModel = (exports.censusDisplay = PropTypes.shape({ + displayName: PropTypes.string.isRequired, + tooltip: PropTypes.string.isRequired, + inverted: PropTypes.bool.isRequired, + breakdown: PropTypes.shape({ + by: PropTypes.string.isRequired, + }), +})); + +/** + * How we want to label nodes in the dominator tree, and associated + * metadata. The notable difference from `censusDisplayModel` is the lack of + * an `inverted` property. + * + * @see `js/src/doc/Debugger/Debugger.Memory.md` + */ +const labelDisplayModel = (exports.labelDisplay = PropTypes.shape({ + displayName: PropTypes.string.isRequired, + tooltip: PropTypes.string.isRequired, + breakdown: PropTypes.shape({ + by: PropTypes.string.isRequired, + }), +})); + +/** + * The data describing the tree map's shape, and its associated metadata. + * + * @see `js/src/doc/Debugger/Debugger.Memory.md` + */ +const treeMapDisplayModel = (exports.treeMapDisplay = PropTypes.shape({ + displayName: PropTypes.string.isRequired, + tooltip: PropTypes.string.isRequired, + inverted: PropTypes.bool.isRequired, + breakdown: PropTypes.shape({ + by: PropTypes.string.isRequired, + }), +})); + +/** + * Tree map model. + */ +const treeMapModel = (exports.treeMapModel = PropTypes.shape({ + // The current census report data. + report: PropTypes.object, + // The display data used to generate the current census. + display: treeMapDisplayModel, + // The current treeMapState this is in + state: catchAndIgnore(function (treeMap) { + switch (treeMap.state) { + case treeMapState.SAVING: + assert(!treeMap.report, "Should not have a report"); + assert(!treeMap.error, "Should not have an error"); + break; + + case treeMapState.SAVED: + assert(treeMap.report, "Should have a report"); + assert(!treeMap.error, "Should not have an error"); + break; + + case treeMapState.ERROR: + assert(treeMap.error, "Should have an error"); + break; + + default: + assert(false, `Unexpected treeMap state: ${treeMap.state}`); + } + }), +})); + +const censusModel = (exports.censusModel = PropTypes.shape({ + // The current census report data. + report: PropTypes.object, + // The parent map for the report. + parentMap: PropTypes.object, + // The display data used to generate the current census. + display: censusDisplayModel, + // If present, the currently cached report's filter string used for pruning + // the tree items. + filter: PropTypes.string, + // The Immutable.Set<CensusTreeNode.id> of expanded node ids in the report + // tree. + expanded: catchAndIgnore(function (census) { + if (census.report) { + assert( + census.expanded, + "If we have a report, we should also have the set of expanded nodes" + ); + } + }), + // If a node is currently focused in the report tree, then this is it. + focused: PropTypes.object, + // The censusModelState that this census is currently in. + state: catchAndIgnore(function (census) { + switch (census.state) { + case censusState.SAVING: + assert(!census.report, "Should not have a report"); + assert(!census.parentMap, "Should not have a parent map"); + assert(census.expanded, "Should not have an expanded set"); + assert(!census.error, "Should not have an error"); + break; + + case censusState.SAVED: + assert(census.report, "Should have a report"); + assert(census.parentMap, "Should have a parent map"); + assert(census.expanded, "Should have an expanded set"); + assert(!census.error, "Should not have an error"); + break; + + case censusState.ERROR: + assert(!census.report, "Should not have a report"); + assert(census.error, "Should have an error"); + break; + + default: + assert(false, `Unexpected census state: ${census.state}`); + } + }), +})); + +/** + * Dominator tree model. + */ +const dominatorTreeModel = (exports.dominatorTreeModel = PropTypes.shape({ + // The id of this dominator tree. + dominatorTreeId: PropTypes.number, + + // The root DominatorTreeNode of this dominator tree. + root: PropTypes.object, + + // The Set<NodeId> of expanded nodes in this dominator tree. + expanded: PropTypes.object, + + // If a node is currently focused in the dominator tree, then this is it. + focused: PropTypes.object, + + // If an error was thrown while getting this dominator tree, the `Error` + // instance (or an error string message) is attached here. + error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + + // The display used to generate descriptive labels of nodes in this dominator + // tree. + display: labelDisplayModel, + + // The number of active requests to incrementally fetch subtrees. This should + // only be non-zero when the state is INCREMENTAL_FETCHING. + activeFetchRequestCount: PropTypes.number, + + // The dominatorTreeState that this domintor tree is currently in. + state: catchAndIgnore(function (dominatorTree) { + switch (dominatorTree.state) { + case dominatorTreeState.COMPUTING: + assert( + dominatorTree.dominatorTreeId == null, + "Should not have a dominator tree id yet" + ); + assert(!dominatorTree.root, "Should not have the root of the tree yet"); + assert(!dominatorTree.error, "Should not have an error"); + break; + + case dominatorTreeState.COMPUTED: + case dominatorTreeState.FETCHING: + assert( + dominatorTree.dominatorTreeId != null, + "Should have a dominator tree id" + ); + assert(!dominatorTree.root, "Should not have the root of the tree yet"); + assert(!dominatorTree.error, "Should not have an error"); + break; + + case dominatorTreeState.INCREMENTAL_FETCHING: + assert( + typeof dominatorTree.activeFetchRequestCount === "number", + "The active fetch request count is a number when we are in the " + + "INCREMENTAL_FETCHING state" + ); + assert( + dominatorTree.activeFetchRequestCount > 0, + "We are keeping track of how many active requests are in flight." + ); + // Fall through... + case dominatorTreeState.LOADED: + assert( + dominatorTree.dominatorTreeId != null, + "Should have a dominator tree id" + ); + assert(dominatorTree.root, "Should have the root of the tree"); + assert(dominatorTree.expanded, "Should have an expanded set"); + assert(!dominatorTree.error, "Should not have an error"); + break; + + case dominatorTreeState.ERROR: + assert(dominatorTree.error, "Should have an error"); + break; + + default: + assert( + false, + `Unexpected dominator tree state: ${dominatorTree.state}` + ); + } + }), +})); + +/** + * Snapshot model. + */ +const stateKeys = Object.keys(states).map(state => states[state]); +const snapshotId = PropTypes.number; +const snapshotModel = (exports.snapshot = PropTypes.shape({ + // Unique ID for a snapshot + id: snapshotId.isRequired, + // Whether or not this snapshot is currently selected. + selected: PropTypes.bool.isRequired, + // Filesystem path to where the snapshot is stored; used to identify the + // snapshot for HeapAnalysesClient. + path: PropTypes.string, + // Current census data for this snapshot. + census: censusModel, + // Current dominator tree data for this snapshot. + dominatorTree: dominatorTreeModel, + // Current tree map data for this snapshot. + treeMap: treeMapModel, + // If an error was thrown while processing this snapshot, the `Error` instance + // is attached here. + error: PropTypes.object, + // Boolean indicating whether or not this snapshot was imported. + imported: PropTypes.bool.isRequired, + // The creation time of the snapshot; required after the snapshot has been + // read. + creationTime: PropTypes.number, + // The current state the snapshot is in. + // @see ./constants.js + state: catchAndIgnore(function (snapshot, propName) { + const current = snapshot.state; + const shouldHavePath = [states.IMPORTING, states.SAVED, states.READ]; + const shouldHaveCreationTime = [states.READ]; + + if (!stateKeys.includes(current)) { + throw new Error(`Snapshot state must be one of ${stateKeys}.`); + } + if (shouldHavePath.includes(current) && !snapshot.path) { + throw new Error( + `Snapshots in state ${current} must have a snapshot path.` + ); + } + if (shouldHaveCreationTime.includes(current) && !snapshot.creationTime) { + throw new Error( + `Snapshots in state ${current} must have a creation time.` + ); + } + }), +})); + +const allocationsModel = (exports.allocations = PropTypes.shape({ + // True iff we are recording allocation stacks right now. + recording: PropTypes.bool.isRequired, + // True iff we are in the process of toggling the recording of allocation + // stacks on or off right now. + togglingInProgress: PropTypes.bool.isRequired, +})); + +const diffingModel = (exports.diffingModel = PropTypes.shape({ + // The id of the first snapshot to diff. + firstSnapshotId: snapshotId, + + // The id of the second snapshot to diff. + secondSnapshotId: catchAndIgnore(function (diffing, propName) { + if (diffing.secondSnapshotId && !diffing.firstSnapshotId) { + throw new Error( + "Cannot have second snapshot without already having " + "first snapshot" + ); + } + return snapshotId(diffing, propName); + }), + + // The current census data for the diffing. + census: censusModel, + + // If an error was thrown while diffing, the `Error` instance is attached + // here. + error: PropTypes.object, + + // The current state the diffing is in. + // @see ./constants.js + state: catchAndIgnore(function (diffing) { + switch (diffing.state) { + case diffingState.TOOK_DIFF: + assert(diffing.census, "If we took a diff, we should have a census"); + // Fall through... + case diffingState.TAKING_DIFF: + assert(diffing.firstSnapshotId, "Should have first snapshot"); + assert(diffing.secondSnapshotId, "Should have second snapshot"); + break; + + case diffingState.SELECTING: + break; + + case diffingState.ERROR: + assert(diffing.error, "Should have error"); + break; + + default: + assert(false, `Bad diffing state: ${diffing.state}`); + } + }), +})); + +const previousViewModel = (exports.previousView = PropTypes.shape({ + state: catchAndIgnore(function (previous) { + switch (previous.state) { + case viewState.DIFFING: + assert(previous.diffing, "Should have previous diffing state."); + assert( + !previous.selected, + "Should not have a previously selected snapshot." + ); + break; + + case viewState.CENSUS: + case viewState.DOMINATOR_TREE: + case viewState.TREE_MAP: + assert( + previous.selected, + "Should have a previously selected snapshot." + ); + break; + + case viewState.INDIVIDUALS: + default: + assert(false, `Unexpected previous view state: ${previous.state}.`); + } + }), + + // The previous diffing state, if any. + diffing: diffingModel, + + // The previously selected snapshot, if any. + selected: snapshotId, +})); + +exports.view = PropTypes.shape({ + // The current view state. + state: catchAndIgnore(function (view) { + switch (view.state) { + case viewState.DIFFING: + case viewState.CENSUS: + case viewState.DOMINATOR_TREE: + case viewState.INDIVIDUALS: + case viewState.TREE_MAP: + break; + + default: + assert(false, `Unexpected type of view: ${view.state}`); + } + }), + + // The previous view state. + previous: previousViewModel, +}); + +const individualsModel = (exports.individuals = PropTypes.shape({ + error: PropTypes.object, + + nodes: PropTypes.arrayOf(PropTypes.object), + + dominatorTree: dominatorTreeModel, + + id: snapshotId, + + censusBreakdown: PropTypes.object, + + indices: PropTypes.object, + + labelDisplay: labelDisplayModel, + + focused: PropTypes.object, + + state: catchAndIgnore(function (individuals) { + switch (individuals.state) { + case individualsState.COMPUTING_DOMINATOR_TREE: + case individualsState.FETCHING: + assert(!individuals.nodes, "Should not have individual nodes"); + assert(!individuals.dominatorTree, "Should not have dominator tree"); + assert(!individuals.id, "Should not have an id"); + assert( + !individuals.censusBreakdown, + "Should not have a censusBreakdown" + ); + assert(!individuals.indices, "Should not have indices"); + assert(!individuals.labelDisplay, "Should not have a labelDisplay"); + break; + + case individualsState.FETCHED: + assert(individuals.nodes, "Should have individual nodes"); + assert(individuals.dominatorTree, "Should have dominator tree"); + assert(individuals.id, "Should have an id"); + assert(individuals.censusBreakdown, "Should have a censusBreakdown"); + assert(individuals.indices, "Should have indices"); + assert(individuals.labelDisplay, "Should have a labelDisplay"); + break; + + case individualsState.ERROR: + assert(individuals.error, "Should have an error object"); + break; + + default: + assert(false, `Unexpected individuals state: ${individuals.state}`); + break; + } + }), +})); + +exports.app = { + // {Commands} Used to communicate with the backend + commands: PropTypes.object, + + // {MemoryFront} Used to communicate with platform + front: PropTypes.instanceOf(MemoryFront), + + // Allocations recording related data. + allocations: allocationsModel.isRequired, + + // {HeapAnalysesClient} Used to interface with snapshots + heapWorker: PropTypes.instanceOf(HeapAnalysesClient), + + // The display data describing how we want the census data to be. + censusDisplay: censusDisplayModel.isRequired, + + // The display data describing how we want the dominator tree labels to be + // computed. + labelDisplay: labelDisplayModel.isRequired, + + // The display data describing how we want the dominator tree labels to be + // computed. + treeMapDisplay: treeMapDisplayModel.isRequired, + + // List of reference to all snapshots taken + snapshots: PropTypes.arrayOf(snapshotModel).isRequired, + + // If present, a filter string for pruning the tree items. + filter: PropTypes.string, + + // If present, the current diffing state. + diffing: diffingModel, + + // If present, the current individuals state. + individuals: individualsModel, + + // The current type of view. + view(app) { + catchAndIgnore(function (app) { + switch (app.view.state) { + case viewState.DIFFING: + assert(app.diffing, "Should be diffing"); + break; + + case viewState.INDIVIDUALS: + case viewState.CENSUS: + case viewState.DOMINATOR_TREE: + case viewState.TREE_MAP: + assert(!app.diffing, "Should not be diffing"); + break; + + default: + assert(false, `Unexpected type of view: ${app.view.state}`); + } + })(app); + + catchAndIgnore(function (app) { + switch (app.view.state) { + case viewState.INDIVIDUALS: + assert(app.individuals, "Should have individuals state"); + break; + + case viewState.DIFFING: + case viewState.CENSUS: + case viewState.DOMINATOR_TREE: + case viewState.TREE_MAP: + assert(!app.individuals, "Should not have individuals state"); + break; + + default: + assert(false, `Unexpected type of view: ${app.view.state}`); + } + })(app); + }, +}; diff --git a/devtools/client/memory/moz.build b/devtools/client/memory/moz.build new file mode 100644 index 0000000000..3e3282bd2e --- /dev/null +++ b/devtools/client/memory/moz.build @@ -0,0 +1,29 @@ +# 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/. + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Memory") + +DIRS += [ + "actions", + "components", + "reducers", +] + +DevToolsModules( + "app.js", + "constants.js", + "dominator-tree-lazy-children.js", + "initializer.js", + "models.js", + "panel.js", + "reducers.js", + "store.js", + "utils.js", +) + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"] +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.ini"] +MOCHITEST_CHROME_MANIFESTS += ["test/chrome/chrome.ini"] diff --git a/devtools/client/memory/panel.js b/devtools/client/memory/panel.js new file mode 100644 index 0000000000..e6143aafa4 --- /dev/null +++ b/devtools/client/memory/panel.js @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const HeapAnalysesClient = require("resource://devtools/shared/heapsnapshot/HeapAnalysesClient.js"); + +function MemoryPanel(iframeWindow, toolbox, commands) { + this.panelWin = iframeWindow; + this._toolbox = toolbox; + this._commands = commands; + + const { BrowserLoader } = ChromeUtils.import( + "resource://devtools/shared/loader/browser-loader.js" + ); + const browserRequire = BrowserLoader({ + baseURI: "resource://devtools/client/memory/", + window: this.panelWin, + }).require; + this.initializer = browserRequire("devtools/client/memory/initializer"); + + this._onTargetAvailable = this._onTargetAvailable.bind(this); + + EventEmitter.decorate(this); +} + +MemoryPanel.prototype = { + async open() { + this.panelWin.gToolbox = this._toolbox; + this.panelWin.gHeapAnalysesClient = new HeapAnalysesClient(); + + await this.initializer.initialize(this._commands); + + await this._commands.targetCommand.watchTargets({ + types: [this._commands.targetCommand.TYPES.FRAME], + onAvailable: this._onTargetAvailable, + }); + + return this; + }, + + async _onTargetAvailable({ targetFront }) { + if (targetFront.isTopLevel) { + const front = await targetFront.getFront("memory"); + await front.attach(); + this.initializer.updateFront(front); + } + }, + + // DevToolPanel API + + destroy() { + // Make sure this panel is not already destroyed. + if (this._destroyed) { + return; + } + this._destroyed = true; + + this._commands.targetCommand.unwatchTargets({ + types: [this._commands.targetCommand.TYPES.FRAME], + onAvailable: this._onTargetAvailable, + }); + + this.initializer.destroy(); + + this.panelWin.gHeapAnalysesClient.destroy(); + this.panelWin = null; + this.emit("destroyed"); + }, +}; + +exports.MemoryPanel = MemoryPanel; diff --git a/devtools/client/memory/reducers.js b/devtools/client/memory/reducers.js new file mode 100644 index 0000000000..964f2c5cfd --- /dev/null +++ b/devtools/client/memory/reducers.js @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +exports.allocations = require("resource://devtools/client/memory/reducers/allocations.js"); +exports.censusDisplay = require("resource://devtools/client/memory/reducers/census-display.js"); +exports.diffing = require("resource://devtools/client/memory/reducers/diffing.js"); +exports.front = require("resource://devtools/client/memory/reducers/front.js"); +exports.individuals = require("resource://devtools/client/memory/reducers/individuals.js"); +exports.labelDisplay = require("resource://devtools/client/memory/reducers/label-display.js"); +exports.treeMapDisplay = require("resource://devtools/client/memory/reducers/tree-map-display.js"); +exports.errors = require("resource://devtools/client/memory/reducers/errors.js"); +exports.filter = require("resource://devtools/client/memory/reducers/filter.js"); +exports.sizes = require("resource://devtools/client/memory/reducers/sizes.js"); +exports.snapshots = require("resource://devtools/client/memory/reducers/snapshots.js"); +exports.view = require("resource://devtools/client/memory/reducers/view.js"); diff --git a/devtools/client/memory/reducers/allocations.js b/devtools/client/memory/reducers/allocations.js new file mode 100644 index 0000000000..f019705560 --- /dev/null +++ b/devtools/client/memory/reducers/allocations.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"; + +const { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { actions } = require("resource://devtools/client/memory/constants.js"); + +const handlers = Object.create(null); + +handlers[actions.TOGGLE_RECORD_ALLOCATION_STACKS_START] = function ( + state, + action +) { + assert( + !state.togglingInProgress, + "Changing recording state must not be reentrant." + ); + + return { + recording: !state.recording, + togglingInProgress: true, + }; +}; + +handlers[actions.TOGGLE_RECORD_ALLOCATION_STACKS_END] = function ( + state, + action +) { + assert( + state.togglingInProgress, + "Should not complete changing recording state if we weren't changing " + + "recording state already." + ); + + return { + recording: state.recording, + togglingInProgress: false, + }; +}; + +const DEFAULT_ALLOCATIONS_STATE = { + recording: false, + togglingInProgress: false, +}; + +module.exports = function (state = DEFAULT_ALLOCATIONS_STATE, action) { + const handle = handlers[action.type]; + if (handle) { + return handle(state, action); + } + return state; +}; diff --git a/devtools/client/memory/reducers/census-display.js b/devtools/client/memory/reducers/census-display.js new file mode 100644 index 0000000000..59a9d9ca5c --- /dev/null +++ b/devtools/client/memory/reducers/census-display.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"; + +const { + actions, + censusDisplays, +} = require("resource://devtools/client/memory/constants.js"); +const DEFAULT_CENSUS_DISPLAY = censusDisplays.coarseType; + +const handlers = Object.create(null); + +handlers[actions.SET_CENSUS_DISPLAY] = function (_, { display }) { + return display; +}; + +module.exports = function (state = DEFAULT_CENSUS_DISPLAY, action) { + const handle = handlers[action.type]; + if (handle) { + return handle(state, action); + } + return state; +}; diff --git a/devtools/client/memory/reducers/diffing.js b/devtools/client/memory/reducers/diffing.js new file mode 100644 index 0000000000..da28b57deb --- /dev/null +++ b/devtools/client/memory/reducers/diffing.js @@ -0,0 +1,175 @@ +/* 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 Immutable = require("resource://devtools/client/shared/vendor/immutable.js"); +const { + immutableUpdate, + assert, +} = require("resource://devtools/shared/DevToolsUtils.js"); +const { + actions, + diffingState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + snapshotIsDiffable, +} = require("resource://devtools/client/memory/utils.js"); + +const handlers = Object.create(null); + +handlers[actions.POP_VIEW] = function (diffing, { previousView }) { + if (previousView.state === viewState.DIFFING) { + assert(previousView.diffing, "Should have previousView.diffing"); + return previousView.diffing; + } + + return null; +}; + +handlers[actions.CHANGE_VIEW] = function (diffing, { newViewState }) { + if (newViewState === viewState.DIFFING) { + assert(!diffing, "Should not switch to diffing view when already diffing"); + return Object.freeze({ + firstSnapshotId: null, + secondSnapshotId: null, + census: null, + state: diffingState.SELECTING, + }); + } + + return null; +}; + +handlers[actions.SELECT_SNAPSHOT_FOR_DIFFING] = function ( + diffing, + { snapshot } +) { + assert( + diffing, + "Should never select a snapshot for diffing when we aren't diffing " + + "anything" + ); + assert( + diffing.state === diffingState.SELECTING, + "Can't select when not in SELECTING state" + ); + assert(snapshotIsDiffable(snapshot), "snapshot must be in a diffable state"); + + if (!diffing.firstSnapshotId) { + return immutableUpdate(diffing, { + firstSnapshotId: snapshot.id, + }); + } + + assert( + !diffing.secondSnapshotId, + "If we aren't selecting the first, then we must be selecting the " + + "second" + ); + + if (snapshot.id === diffing.firstSnapshotId) { + // Ignore requests to select the same snapshot. + return diffing; + } + + return immutableUpdate(diffing, { + secondSnapshotId: snapshot.id, + }); +}; + +handlers[actions.TAKE_CENSUS_DIFF_START] = function (diffing, action) { + assert(diffing, "Should be diffing when starting a census diff"); + assert( + action.first.id === diffing.firstSnapshotId, + "First snapshot's id should match" + ); + assert( + action.second.id === diffing.secondSnapshotId, + "Second snapshot's id should match" + ); + + return immutableUpdate(diffing, { + state: diffingState.TAKING_DIFF, + census: { + report: null, + inverted: action.inverted, + filter: action.filter, + display: action.display, + }, + }); +}; + +handlers[actions.TAKE_CENSUS_DIFF_END] = function (diffing, action) { + assert(diffing, "Should be diffing when ending a census diff"); + assert( + action.first.id === diffing.firstSnapshotId, + "First snapshot's id should match" + ); + assert( + action.second.id === diffing.secondSnapshotId, + "Second snapshot's id should match" + ); + + return immutableUpdate(diffing, { + state: diffingState.TOOK_DIFF, + census: { + report: action.report, + parentMap: action.parentMap, + expanded: Immutable.Set(), + inverted: action.inverted, + filter: action.filter, + display: action.display, + }, + }); +}; + +handlers[actions.DIFFING_ERROR] = function (diffing, action) { + return { + state: diffingState.ERROR, + error: action.error, + }; +}; + +handlers[actions.EXPAND_DIFFING_CENSUS_NODE] = function (diffing, { node }) { + assert(diffing, "Should be diffing if expanding diffing's census nodes"); + assert( + diffing.state === diffingState.TOOK_DIFF, + "Should have taken the census diff if expanding nodes" + ); + assert(diffing.census, "Should have a census"); + assert(diffing.census.report, "Should have a census report"); + assert(diffing.census.expanded, "Should have a census's expanded set"); + + const expanded = diffing.census.expanded.add(node.id); + const census = immutableUpdate(diffing.census, { expanded }); + return immutableUpdate(diffing, { census }); +}; + +handlers[actions.COLLAPSE_DIFFING_CENSUS_NODE] = function (diffing, { node }) { + assert(diffing, "Should be diffing if expanding diffing's census nodes"); + assert( + diffing.state === diffingState.TOOK_DIFF, + "Should have taken the census diff if expanding nodes" + ); + assert(diffing.census, "Should have a census"); + assert(diffing.census.report, "Should have a census report"); + assert(diffing.census.expanded, "Should have a census's expanded set"); + + const expanded = diffing.census.expanded.delete(node.id); + const census = immutableUpdate(diffing.census, { expanded }); + return immutableUpdate(diffing, { census }); +}; + +handlers[actions.FOCUS_DIFFING_CENSUS_NODE] = function (diffing, { node }) { + assert(diffing, "Should be diffing."); + assert(diffing.census, "Should have a census"); + const census = immutableUpdate(diffing.census, { focused: node }); + return immutableUpdate(diffing, { census }); +}; + +module.exports = function (diffing = null, action) { + const handler = handlers[action.type]; + return handler ? handler(diffing, action) : diffing; +}; diff --git a/devtools/client/memory/reducers/errors.js b/devtools/client/memory/reducers/errors.js new file mode 100644 index 0000000000..ad1c8bd621 --- /dev/null +++ b/devtools/client/memory/reducers/errors.js @@ -0,0 +1,21 @@ +/* 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 { + ERROR_TYPE: TASK_ERROR_TYPE, +} = require("resource://devtools/client/shared/redux/middleware/task.js"); + +/** + * Handle errors dispatched from task middleware and + * store them so we can check in tests or dump them out. + */ +module.exports = function (state = [], action) { + switch (action.type) { + case TASK_ERROR_TYPE: + return [...state, action.error]; + } + return state; +}; diff --git a/devtools/client/memory/reducers/filter.js b/devtools/client/memory/reducers/filter.js new file mode 100644 index 0000000000..78d9704cf7 --- /dev/null +++ b/devtools/client/memory/reducers/filter.js @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { actions } = require("resource://devtools/client/memory/constants.js"); + +module.exports = function (filterString = null, action) { + if (action.type === actions.SET_FILTER_STRING) { + return action.filter || null; + } + + return filterString; +}; diff --git a/devtools/client/memory/reducers/front.js b/devtools/client/memory/reducers/front.js new file mode 100644 index 0000000000..92ff6c151c --- /dev/null +++ b/devtools/client/memory/reducers/front.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"; + +const { actions } = require("resource://devtools/client/memory/constants.js"); + +module.exports = (front = null, action) => { + return action.type === actions.UPDATE_MEMORY_FRONT ? action.front : front; +}; diff --git a/devtools/client/memory/reducers/individuals.js b/devtools/client/memory/reducers/individuals.js new file mode 100644 index 0000000000..9b5a2c89a5 --- /dev/null +++ b/devtools/client/memory/reducers/individuals.js @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + assert, + immutableUpdate, +} = require("resource://devtools/shared/DevToolsUtils.js"); +const { + actions, + individualsState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); + +const handlers = Object.create(null); + +handlers[actions.POP_VIEW] = function (_state, _action) { + return null; +}; + +handlers[actions.CHANGE_VIEW] = function (individuals, { newViewState }) { + if (newViewState === viewState.INDIVIDUALS) { + assert( + !individuals, + "Should not switch to individuals view when already in individuals view" + ); + return Object.freeze({ + state: individualsState.COMPUTING_DOMINATOR_TREE, + }); + } + + return null; +}; + +handlers[actions.FOCUS_INDIVIDUAL] = function (individuals, { node }) { + assert(individuals, "Should have individuals"); + return immutableUpdate(individuals, { focused: node }); +}; + +handlers[actions.FETCH_INDIVIDUALS_START] = function (individuals, action) { + assert(individuals, "Should have individuals"); + return Object.freeze({ + state: individualsState.FETCHING, + focused: individuals.focused, + }); +}; + +handlers[actions.FETCH_INDIVIDUALS_END] = function (individuals, action) { + assert(individuals, "Should have individuals"); + assert(!individuals.nodes, "Should not have nodes"); + assert( + individuals.state === individualsState.FETCHING, + "Should only end fetching individuals after starting." + ); + + const focused = individuals.focused + ? action.nodes.find(n => n.nodeId === individuals.focused.nodeId) + : null; + + return Object.freeze({ + state: individualsState.FETCHED, + nodes: action.nodes, + id: action.id, + censusBreakdown: action.censusBreakdown, + indices: action.indices, + labelDisplay: action.labelDisplay, + focused, + dominatorTree: action.dominatorTree, + }); +}; + +handlers[actions.INDIVIDUALS_ERROR] = function (_, { error }) { + return Object.freeze({ + error, + nodes: null, + state: individualsState.ERROR, + }); +}; + +module.exports = function (individuals = null, action) { + const handler = handlers[action.type]; + return handler ? handler(individuals, action) : individuals; +}; diff --git a/devtools/client/memory/reducers/label-display.js b/devtools/client/memory/reducers/label-display.js new file mode 100644 index 0000000000..678930a91a --- /dev/null +++ b/devtools/client/memory/reducers/label-display.js @@ -0,0 +1,22 @@ +/* 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 { + actions, + labelDisplays, +} = require("resource://devtools/client/memory/constants.js"); +const DEFAULT_LABEL_DISPLAY = labelDisplays.coarseType; + +const handlers = Object.create(null); + +handlers[actions.SET_LABEL_DISPLAY] = function (_, { display }) { + return display; +}; + +module.exports = function (state = DEFAULT_LABEL_DISPLAY, action) { + const handler = handlers[action.type]; + return handler ? handler(state, action) : state; +}; diff --git a/devtools/client/memory/reducers/moz.build b/devtools/client/memory/reducers/moz.build new file mode 100644 index 0000000000..53677d1d8e --- /dev/null +++ b/devtools/client/memory/reducers/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( + "allocations.js", + "census-display.js", + "diffing.js", + "errors.js", + "filter.js", + "front.js", + "individuals.js", + "label-display.js", + "sizes.js", + "snapshots.js", + "tree-map-display.js", + "view.js", +) diff --git a/devtools/client/memory/reducers/sizes.js b/devtools/client/memory/reducers/sizes.js new file mode 100644 index 0000000000..f79e9f6b28 --- /dev/null +++ b/devtools/client/memory/reducers/sizes.js @@ -0,0 +1,20 @@ +/* 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 { actions } = require("resource://devtools/client/memory/constants.js"); +const { + immutableUpdate, +} = require("resource://devtools/shared/DevToolsUtils.js"); + +const handlers = Object.create(null); + +handlers[actions.RESIZE_SHORTEST_PATHS] = function (sizes, { size }) { + return immutableUpdate(sizes, { shortestPathsSize: size }); +}; + +module.exports = function (sizes = { shortestPathsSize: 0.5 }, action) { + const handler = handlers[action.type]; + return handler ? handler(sizes, action) : sizes; +}; diff --git a/devtools/client/memory/reducers/snapshots.js b/devtools/client/memory/reducers/snapshots.js new file mode 100644 index 0000000000..a56deddb9a --- /dev/null +++ b/devtools/client/memory/reducers/snapshots.js @@ -0,0 +1,517 @@ +/* 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 Immutable = require("resource://devtools/client/shared/vendor/immutable.js"); +const { + immutableUpdate, + assert, +} = require("resource://devtools/shared/DevToolsUtils.js"); +const { + actions, + snapshotState: states, + censusState, + treeMapState, + dominatorTreeState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const DominatorTreeNode = require("resource://devtools/shared/heapsnapshot/DominatorTreeNode.js"); + +const handlers = Object.create(null); + +handlers[actions.SNAPSHOT_ERROR] = function (snapshots, { id, error }) { + return snapshots.map(snapshot => { + return snapshot.id === id + ? immutableUpdate(snapshot, { state: states.ERROR, error }) + : snapshot; + }); +}; + +handlers[actions.TAKE_SNAPSHOT_START] = function (snapshots, { snapshot }) { + return [...snapshots, snapshot]; +}; + +handlers[actions.TAKE_SNAPSHOT_END] = function (snapshots, { id, path }) { + return snapshots.map(snapshot => { + return snapshot.id === id + ? immutableUpdate(snapshot, { state: states.SAVED, path }) + : snapshot; + }); +}; + +handlers[actions.IMPORT_SNAPSHOT_START] = handlers[actions.TAKE_SNAPSHOT_START]; + +handlers[actions.READ_SNAPSHOT_START] = function (snapshots, { id }) { + return snapshots.map(snapshot => { + return snapshot.id === id + ? immutableUpdate(snapshot, { state: states.READING }) + : snapshot; + }); +}; + +handlers[actions.READ_SNAPSHOT_END] = function ( + snapshots, + { id, creationTime } +) { + return snapshots.map(snapshot => { + return snapshot.id === id + ? immutableUpdate(snapshot, { state: states.READ, creationTime }) + : snapshot; + }); +}; + +handlers[actions.TAKE_CENSUS_START] = function ( + snapshots, + { id, display, filter } +) { + const census = { + report: null, + display, + filter, + state: censusState.SAVING, + }; + + return snapshots.map(snapshot => { + return snapshot.id === id + ? immutableUpdate(snapshot, { census }) + : snapshot; + }); +}; + +handlers[actions.TAKE_CENSUS_END] = function ( + snapshots, + { id, report, parentMap, display, filter } +) { + const census = { + report, + parentMap, + expanded: Immutable.Set(), + display, + filter, + state: censusState.SAVED, + }; + + return snapshots.map(snapshot => { + return snapshot.id === id + ? immutableUpdate(snapshot, { census }) + : snapshot; + }); +}; + +handlers[actions.TAKE_CENSUS_ERROR] = function (snapshots, { id, error }) { + assert(error, "actions with TAKE_CENSUS_ERROR should have an error"); + + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + const census = Object.freeze({ + state: censusState.ERROR, + error, + }); + + return immutableUpdate(snapshot, { census }); + }); +}; + +handlers[actions.TAKE_TREE_MAP_START] = function (snapshots, { id, display }) { + const treeMap = { + report: null, + display, + state: treeMapState.SAVING, + }; + + return snapshots.map(snapshot => { + return snapshot.id === id + ? immutableUpdate(snapshot, { treeMap }) + : snapshot; + }); +}; + +handlers[actions.TAKE_TREE_MAP_END] = function (snapshots, action) { + const { id, report, display } = action; + const treeMap = { + report, + display, + state: treeMapState.SAVED, + }; + + return snapshots.map(snapshot => { + return snapshot.id === id + ? immutableUpdate(snapshot, { treeMap }) + : snapshot; + }); +}; + +handlers[actions.TAKE_TREE_MAP_ERROR] = function (snapshots, { id, error }) { + assert(error, "actions with TAKE_TREE_MAP_ERROR should have an error"); + + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + const treeMap = Object.freeze({ + state: treeMapState.ERROR, + error, + }); + + return immutableUpdate(snapshot, { treeMap }); + }); +}; + +handlers[actions.EXPAND_CENSUS_NODE] = function (snapshots, { id, node }) { + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + assert(snapshot.census, "Should have a census"); + assert(snapshot.census.report, "Should have a census report"); + assert(snapshot.census.expanded, "Should have a census's expanded set"); + + const expanded = snapshot.census.expanded.add(node.id); + const census = immutableUpdate(snapshot.census, { expanded }); + return immutableUpdate(snapshot, { census }); + }); +}; + +handlers[actions.COLLAPSE_CENSUS_NODE] = function (snapshots, { id, node }) { + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + assert(snapshot.census, "Should have a census"); + assert(snapshot.census.report, "Should have a census report"); + assert(snapshot.census.expanded, "Should have a census's expanded set"); + + const expanded = snapshot.census.expanded.delete(node.id); + const census = immutableUpdate(snapshot.census, { expanded }); + return immutableUpdate(snapshot, { census }); + }); +}; + +handlers[actions.FOCUS_CENSUS_NODE] = function (snapshots, { id, node }) { + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + assert(snapshot.census, "Should have a census"); + const census = immutableUpdate(snapshot.census, { focused: node }); + return immutableUpdate(snapshot, { census }); + }); +}; + +handlers[actions.SELECT_SNAPSHOT] = function (snapshots, { id }) { + return snapshots.map(s => immutableUpdate(s, { selected: s.id === id })); +}; + +handlers[actions.DELETE_SNAPSHOTS_START] = function (snapshots, { ids }) { + return snapshots.filter(s => !ids.includes(s.id)); +}; + +handlers[actions.DELETE_SNAPSHOTS_END] = function (snapshots) { + return snapshots; +}; + +handlers[actions.CHANGE_VIEW] = function (snapshots, { newViewState }) { + return newViewState === viewState.DIFFING + ? snapshots.map(s => immutableUpdate(s, { selected: false })) + : snapshots; +}; + +handlers[actions.POP_VIEW] = function (snapshots, { previousView }) { + return snapshots.map(s => + immutableUpdate(s, { + selected: s.id === previousView.selected, + }) + ); +}; + +handlers[actions.COMPUTE_DOMINATOR_TREE_START] = function (snapshots, { id }) { + const dominatorTree = Object.freeze({ + state: dominatorTreeState.COMPUTING, + dominatorTreeId: undefined, + root: undefined, + }); + + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + assert(!snapshot.dominatorTree, "Should not have a dominator tree model"); + return immutableUpdate(snapshot, { dominatorTree }); + }); +}; + +handlers[actions.COMPUTE_DOMINATOR_TREE_END] = function ( + snapshots, + { id, dominatorTreeId } +) { + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + assert(snapshot.dominatorTree, "Should have a dominator tree model"); + assert( + snapshot.dominatorTree.state == dominatorTreeState.COMPUTING, + "Should be in the COMPUTING state" + ); + + const dominatorTree = immutableUpdate(snapshot.dominatorTree, { + state: dominatorTreeState.COMPUTED, + dominatorTreeId, + }); + return immutableUpdate(snapshot, { dominatorTree }); + }); +}; + +handlers[actions.FETCH_DOMINATOR_TREE_START] = function ( + snapshots, + { id, display } +) { + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + assert(snapshot.dominatorTree, "Should have a dominator tree model"); + assert( + snapshot.dominatorTree.state !== dominatorTreeState.COMPUTING && + snapshot.dominatorTree.state !== dominatorTreeState.ERROR, + "Should have already computed the dominator tree, found state = " + + snapshot.dominatorTree.state + ); + + const dominatorTree = immutableUpdate(snapshot.dominatorTree, { + state: dominatorTreeState.FETCHING, + root: undefined, + display, + }); + return immutableUpdate(snapshot, { dominatorTree }); + }); +}; + +handlers[actions.FETCH_DOMINATOR_TREE_END] = function ( + snapshots, + { id, root } +) { + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + assert(snapshot.dominatorTree, "Should have a dominator tree model"); + assert( + snapshot.dominatorTree.state == dominatorTreeState.FETCHING, + "Should be in the FETCHING state" + ); + + let focused; + if (snapshot.dominatorTree.focused) { + focused = (function findFocused(node) { + if (node.nodeId === snapshot.dominatorTree.focused.nodeId) { + return node; + } + + if (node.children) { + const length = node.children.length; + for (let i = 0; i < length; i++) { + const result = findFocused(node.children[i]); + if (result) { + return result; + } + } + } + + return undefined; + })(root); + } + + const dominatorTree = immutableUpdate(snapshot.dominatorTree, { + state: dominatorTreeState.LOADED, + root, + expanded: Immutable.Set(), + focused, + }); + + return immutableUpdate(snapshot, { dominatorTree }); + }); +}; + +handlers[actions.EXPAND_DOMINATOR_TREE_NODE] = function ( + snapshots, + { id, node } +) { + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + assert(snapshot.dominatorTree, "Should have a dominator tree"); + assert( + snapshot.dominatorTree.expanded, + "Should have the dominator tree's expanded set" + ); + + const expanded = snapshot.dominatorTree.expanded.add(node.nodeId); + const dominatorTree = immutableUpdate(snapshot.dominatorTree, { expanded }); + return immutableUpdate(snapshot, { dominatorTree }); + }); +}; + +handlers[actions.COLLAPSE_DOMINATOR_TREE_NODE] = function ( + snapshots, + { id, node } +) { + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + assert(snapshot.dominatorTree, "Should have a dominator tree"); + assert( + snapshot.dominatorTree.expanded, + "Should have the dominator tree's expanded set" + ); + + const expanded = snapshot.dominatorTree.expanded.delete(node.nodeId); + const dominatorTree = immutableUpdate(snapshot.dominatorTree, { expanded }); + return immutableUpdate(snapshot, { dominatorTree }); + }); +}; + +handlers[actions.FOCUS_DOMINATOR_TREE_NODE] = function ( + snapshots, + { id, node } +) { + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + assert(snapshot.dominatorTree, "Should have a dominator tree"); + const dominatorTree = immutableUpdate(snapshot.dominatorTree, { + focused: node, + }); + return immutableUpdate(snapshot, { dominatorTree }); + }); +}; + +handlers[actions.FETCH_IMMEDIATELY_DOMINATED_START] = function ( + snapshots, + { id } +) { + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + assert(snapshot.dominatorTree, "Should have a dominator tree model"); + assert( + snapshot.dominatorTree.state == dominatorTreeState.INCREMENTAL_FETCHING || + snapshot.dominatorTree.state == dominatorTreeState.LOADED, + "The dominator tree should be loaded if we are going to " + + "incrementally fetch children." + ); + + const activeFetchRequestCount = snapshot.dominatorTree + .activeFetchRequestCount + ? snapshot.dominatorTree.activeFetchRequestCount + 1 + : 1; + + const dominatorTree = immutableUpdate(snapshot.dominatorTree, { + state: dominatorTreeState.INCREMENTAL_FETCHING, + activeFetchRequestCount, + }); + + return immutableUpdate(snapshot, { dominatorTree }); + }); +}; + +handlers[actions.FETCH_IMMEDIATELY_DOMINATED_END] = function ( + snapshots, + { id, path, nodes, moreChildrenAvailable } +) { + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + assert(snapshot.dominatorTree, "Should have a dominator tree model"); + assert( + snapshot.dominatorTree.root, + "Should have a dominator tree model root" + ); + assert( + snapshot.dominatorTree.state === dominatorTreeState.INCREMENTAL_FETCHING, + "The dominator tree state should be INCREMENTAL_FETCHING" + ); + + const root = DominatorTreeNode.insert( + snapshot.dominatorTree.root, + path, + nodes, + moreChildrenAvailable + ); + + const focused = snapshot.dominatorTree.focused + ? DominatorTreeNode.getNodeByIdAlongPath( + snapshot.dominatorTree.focused.nodeId, + root, + path + ) + : undefined; + + const activeFetchRequestCount = + snapshot.dominatorTree.activeFetchRequestCount === 1 + ? undefined + : snapshot.dominatorTree.activeFetchRequestCount - 1; + + // If there are still outstanding requests, we need to stay in the + // INCREMENTAL_FETCHING state until they complete. + const state = activeFetchRequestCount + ? dominatorTreeState.INCREMENTAL_FETCHING + : dominatorTreeState.LOADED; + + const dominatorTree = immutableUpdate(snapshot.dominatorTree, { + state, + root, + focused, + activeFetchRequestCount, + }); + + return immutableUpdate(snapshot, { dominatorTree }); + }); +}; + +handlers[actions.DOMINATOR_TREE_ERROR] = function (snapshots, { id, error }) { + assert(error, "actions with DOMINATOR_TREE_ERROR should have an error"); + + return snapshots.map(snapshot => { + if (snapshot.id !== id) { + return snapshot; + } + + const dominatorTree = Object.freeze({ + state: dominatorTreeState.ERROR, + error, + }); + + return immutableUpdate(snapshot, { dominatorTree }); + }); +}; + +module.exports = function (snapshots = [], action) { + const handler = handlers[action.type]; + if (handler) { + return handler(snapshots, action); + } + return snapshots; +}; diff --git a/devtools/client/memory/reducers/tree-map-display.js b/devtools/client/memory/reducers/tree-map-display.js new file mode 100644 index 0000000000..7f145e8181 --- /dev/null +++ b/devtools/client/memory/reducers/tree-map-display.js @@ -0,0 +1,22 @@ +/* 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 { + actions, + treeMapDisplays, +} = require("resource://devtools/client/memory/constants.js"); +const DEFAULT_TREE_MAP_DISPLAY = treeMapDisplays.coarseType; + +const handlers = Object.create(null); + +handlers[actions.SET_TREE_MAP_DISPLAY] = function (_, { display }) { + return display; +}; + +module.exports = function (state = DEFAULT_TREE_MAP_DISPLAY, action) { + const handler = handlers[action.type]; + return handler ? handler(state, action) : state; +}; diff --git a/devtools/client/memory/reducers/view.js b/devtools/client/memory/reducers/view.js new file mode 100644 index 0000000000..99dab7ef03 --- /dev/null +++ b/devtools/client/memory/reducers/view.js @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + actions, + viewState, +} = require("resource://devtools/client/memory/constants.js"); + +const handlers = Object.create(null); + +handlers[actions.POP_VIEW] = function (view, _) { + assert(view.previous, "Had better have a previous view state when POP_VIEW"); + return Object.freeze({ + state: view.previous.state, + previous: null, + }); +}; + +handlers[actions.CHANGE_VIEW] = function (view, action) { + const { newViewState, oldDiffing, oldSelected } = action; + assert(newViewState); + + if (newViewState === viewState.INDIVIDUALS) { + assert(oldDiffing || oldSelected); + return Object.freeze({ + state: newViewState, + previous: Object.freeze({ + state: view.state, + selected: oldSelected, + diffing: oldDiffing, + }), + }); + } + + return Object.freeze({ + state: newViewState, + previous: null, + }); +}; + +const DEFAULT_VIEW = { + state: viewState.TREE_MAP, + previous: null, +}; + +module.exports = function (view = DEFAULT_VIEW, action) { + const handler = handlers[action.type]; + return handler ? handler(view, action) : view; +}; diff --git a/devtools/client/memory/store.js b/devtools/client/memory/store.js new file mode 100644 index 0000000000..9739ca0fd5 --- /dev/null +++ b/devtools/client/memory/store.js @@ -0,0 +1,15 @@ +/* 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 createStore = require("resource://devtools/client/shared/redux/create-store.js"); +const reducers = require("resource://devtools/client/memory/reducers.js"); + +module.exports = () => + createStore(reducers, { + enableTaskMiddleware: true, + // Uncomment this for logging in tests. + // shouldLog: true, + }); diff --git a/devtools/client/memory/test/browser/browser.ini b/devtools/client/memory/test/browser/browser.ini new file mode 100644 index 0000000000..0d2e770ec5 --- /dev/null +++ b/devtools/client/memory/test/browser/browser.ini @@ -0,0 +1,37 @@ +[DEFAULT] +tags = devtools devtools-memory +subsuite = devtools +support-files = + head.js + doc_big_tree.html + doc_empty.html + doc_steady_allocation.html + !/devtools/client/shared/test/shared-head.js + !/devtools/client/shared/test/telemetry-test-helpers.js + +[browser_memory_allocationStackDisplay_01.js] +skip-if = debug # bug 1219554 +[browser_memory_allocationStackDisplay_02.js] +skip-if = debug # bug 1219554 +[browser_memory_displays_01.js] +[browser_memory_clear_snapshots.js] +[browser_memory_diff_01.js] +[browser_memory_dominator_trees_01.js] +skip-if = ccov # bug 1347244 +[browser_memory_dominator_trees_02.js] +skip-if = ccov # bug 1347244 +[browser_memory_filter_01.js] +skip-if = ccov # bug 1347244 +[browser_memory_fission_switch_target.js] +[browser_memory_individuals_01.js] +[browser_memory_keyboard.js] +[browser_memory_keyboard-snapshot-list.js] +[browser_memory_no_allocation_stacks.js] +[browser_memory_no_auto_expand.js] +skip-if = debug # bug 1219554 +[browser_memory_percents_01.js] +[browser_memory_refresh_does_not_leak.js] +[browser_memory_simple_01.js] +[browser_memory_transferHeapSnapshot_e10s_01.js] +[browser_memory_tree_map-01.js] +[browser_memory_tree_map-02.js] diff --git a/devtools/client/memory/test/browser/browser_memory_allocationStackDisplay_01.js b/devtools/client/memory/test/browser/browser_memory_allocationStackDisplay_01.js new file mode 100644 index 0000000000..b526025b17 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_allocationStackDisplay_01.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Sanity test that we can show allocation stack displays in the tree. + +"use strict"; + +const { + toggleRecordingAllocationStacks, +} = require("resource://devtools/client/memory/actions/allocations.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const censusDisplayActions = require("resource://devtools/client/memory/actions/census-display.js"); +const { viewState } = require("resource://devtools/client/memory/constants.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_steady_allocation.html"; + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + const heapWorker = panel.panelWin.gHeapAnalysesClient; + const { getState, dispatch } = panel.panelWin.gStore; + const front = getState().front; + const doc = panel.panelWin.document; + + dispatch(changeView(viewState.CENSUS)); + + dispatch( + censusDisplayActions.setCensusDisplay( + censusDisplays.invertedAllocationStack + ) + ); + is(getState().censusDisplay.breakdown.by, "allocationStack"); + + await dispatch(toggleRecordingAllocationStacks(panel._commands)); + ok(getState().allocations.recording); + + // Let some allocations build up. + await waitForTime(500); + + await dispatch(takeSnapshotAndCensus(front, heapWorker)); + + const names = [...doc.querySelectorAll(".frame-link-function-display-name")]; + ok(names.length, "Should have rendered some allocation stack tree items"); + ok( + names.some(e => !!e.textContent.trim()), + "And at least some of them should have functionDisplayNames" + ); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_allocationStackDisplay_02.js b/devtools/client/memory/test/browser/browser_memory_allocationStackDisplay_02.js new file mode 100644 index 0000000000..34492187a2 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_allocationStackDisplay_02.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Sanity test that we can show allocation stack work when loading a new page + +"use strict"; + +const { + toggleRecordingAllocationStacks, +} = require("resource://devtools/client/memory/actions/allocations.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const censusDisplayActions = require("resource://devtools/client/memory/actions/census-display.js"); +const { viewState } = require("resource://devtools/client/memory/constants.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const TEST_URL = + "https://example.com/browser/devtools/client/memory/test/browser/doc_steady_allocation.html"; + +this.test = makeMemoryTest("about:blank", async function ({ tab, panel }) { + const heapWorker = panel.panelWin.gHeapAnalysesClient; + const { getState, dispatch } = panel.panelWin.gStore; + const doc = panel.panelWin.document; + + dispatch(changeView(viewState.CENSUS)); + + dispatch( + censusDisplayActions.setCensusDisplay( + censusDisplays.invertedAllocationStack + ) + ); + is(getState().censusDisplay.breakdown.by, "allocationStack"); + + await dispatch(toggleRecordingAllocationStacks(panel._commands)); + ok(getState().allocations.recording); + + await navigateTo(TEST_URL); + + const front = getState().front; + await dispatch(takeSnapshotAndCensus(front, heapWorker)); + + const names = [...doc.querySelectorAll(".frame-link-function-display-name")]; + ok(names.length, "Should have rendered some allocation stack tree items"); + ok( + names.some(e => !!e.textContent.trim()), + "And at least some of them should have functionDisplayNames" + ); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_clear_snapshots.js b/devtools/client/memory/test/browser/browser_memory_clear_snapshots.js new file mode 100644 index 0000000000..52eecd2838 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_clear_snapshots.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests taking and then clearing snapshots. + */ + +const { + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_steady_allocation.html"; + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + const { gStore, document } = panel.panelWin; + const { getState } = gStore; + + let snapshotEls = document.querySelectorAll( + "#memory-tool-container .list li" + ); + is(getState().snapshots.length, 0, "Starts with no snapshots in store"); + is(snapshotEls.length, 0, "No snapshots visible"); + + info("Take two snapshots"); + takeSnapshot(panel.panelWin); + takeSnapshot(panel.panelWin); + takeSnapshot(panel.panelWin); + await waitUntilState( + gStore, + state => + state.snapshots.length === 3 && + state.snapshots[0].treeMap && + state.snapshots[1].treeMap && + state.snapshots[2].treeMap && + state.snapshots[0].treeMap.state === treeMapState.SAVED && + state.snapshots[1].treeMap.state === treeMapState.SAVED && + state.snapshots[2].treeMap.state === treeMapState.SAVED + ); + + snapshotEls = document.querySelectorAll("#memory-tool-container .list li"); + is(snapshotEls.length, 3, "Three snapshots visible"); + is( + document.querySelectorAll(".selected").length, + 1, + "One selected snapshot visible" + ); + ok(snapshotEls[2].classList.contains("selected"), "Third snapshot selected"); + + info("Clicking on first snapshot delete button"); + document.querySelectorAll(".delete")[0].click(); + + await waitUntilState( + gStore, + state => + state.snapshots.length === 2 && + state.snapshots[0].treeMap && + state.snapshots[1].treeMap && + state.snapshots[0].treeMap.state === treeMapState.SAVED && + state.snapshots[1].treeMap.state === treeMapState.SAVED + ); + + snapshotEls = document.querySelectorAll(".snapshot-list-item"); + is(snapshotEls.length, 2, "Two snapshots visible"); + // Bug 1476289 + ok( + !snapshotEls[0].classList.contains("selected"), + "First snapshot not selected" + ); + ok(snapshotEls[1].classList.contains("selected"), "Second snapshot selected"); + + info("Click on Clear Snapshots"); + await clearSnapshots(panel.panelWin); + is(getState().snapshots.length, 0, "No snapshots in store"); + snapshotEls = document.querySelectorAll("#memory-tool-container .list li"); + is(snapshotEls.length, 0, "No snapshot visible"); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_diff_01.js b/devtools/client/memory/test/browser/browser_memory_diff_01.js new file mode 100644 index 0000000000..7b6487565f --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_diff_01.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test diffing. + +"use strict"; + +const { + diffingState, + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); + +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_steady_allocation.html"; + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + const store = panel.panelWin.gStore; + const { getState } = store; + const doc = panel.panelWin.document; + + ok(!getState().diffing, "Not diffing by default."); + + // Take two snapshots. + const takeSnapshotButton = doc.getElementById("take-snapshot"); + EventUtils.synthesizeMouseAtCenter(takeSnapshotButton, {}, panel.panelWin); + await waitForTime(1000); + EventUtils.synthesizeMouseAtCenter(takeSnapshotButton, {}, panel.panelWin); + + // Enable diffing mode. + const diffButton = doc.getElementById("diff-snapshots"); + EventUtils.synthesizeMouseAtCenter(diffButton, {}, panel.panelWin); + await waitUntilState( + store, + state => !!state.diffing && state.diffing.state === diffingState.SELECTING + ); + ok(true, "Clicking the diffing button put us into the diffing state."); + is(getDisplayedSnapshotStatus(doc), "Select the baseline snapshot"); + + await waitUntilState( + store, + state => + state.snapshots.length === 2 && + state.snapshots[0].treeMap && + state.snapshots[1].treeMap && + state.snapshots[0].treeMap.state === treeMapState.SAVED && + state.snapshots[1].treeMap.state === treeMapState.SAVED + ); + + const listItems = [...doc.querySelectorAll(".snapshot-list-item")]; + is(listItems.length, 2, "Should have two snapshot list items"); + + // Select the first snapshot. + EventUtils.synthesizeMouseAtCenter(listItems[0], {}, panel.panelWin); + await waitUntilState( + store, + state => + state.diffing.state === diffingState.SELECTING && + state.diffing.firstSnapshotId + ); + is( + getDisplayedSnapshotStatus(doc), + "Select the snapshot to compare to the baseline" + ); + + // Select the second snapshot. + EventUtils.synthesizeMouseAtCenter(listItems[1], {}, panel.panelWin); + await waitUntilState( + store, + state => state.diffing.state === diffingState.TAKING_DIFF + ); + ok(true, "Selecting two snapshots for diffing triggers computing the diff"); + + // .startsWith because the ellipsis is lost in translation. + ok(getDisplayedSnapshotStatus(doc).startsWith("Computing difference")); + + await waitUntilState( + store, + state => state.diffing.state === diffingState.TOOK_DIFF + ); + ok(true, "And that diff is computed successfully"); + is(getDisplayedSnapshotStatus(doc), null, "No status text anymore"); + ok( + doc.querySelector(".heap-tree-item"), + "And instead we should be showing the tree" + ); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_displays_01.js b/devtools/client/memory/test/browser/browser_memory_displays_01.js new file mode 100644 index 0000000000..89296b77f4 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_displays_01.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that the heap tree renders rows based on the display + */ + +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_steady_allocation.html"; +const { viewState } = require("resource://devtools/client/memory/constants.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + const { gStore, document } = panel.panelWin; + + const { dispatch } = panel.panelWin.gStore; + + function $$(selector) { + return [...document.querySelectorAll(selector)]; + } + dispatch(changeView(viewState.CENSUS)); + + await takeSnapshot(panel.panelWin); + + await waitUntilState( + gStore, + state => + state.snapshots[0].census && + state.snapshots[0].census.state === censusState.SAVED + ); + + info("Check coarse type heap view"); + + ["Function", "js::PropMap", "Object", "strings"].forEach(findNameCell); + + await setCensusDisplay(panel.panelWin, censusDisplays.allocationStack); + info("Check allocation stack heap view"); + [L10N.getStr("tree-item.nostack")].forEach(findNameCell); + + function findNameCell(name) { + const el = $$(".tree .heap-tree-item-name").find( + e => e.textContent === name + ); + ok(el, `Found heap tree item cell for ${name}.`); + } +}); diff --git a/devtools/client/memory/test/browser/browser_memory_dominator_trees_01.js b/devtools/client/memory/test/browser/browser_memory_dominator_trees_01.js new file mode 100644 index 0000000000..1faaf365a1 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_dominator_trees_01.js @@ -0,0 +1,178 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Sanity test for dominator trees, their focused nodes, and keyboard navigating +// through nodes across incrementally fetching subtrees. + +"use strict"; + +const { + dominatorTreeState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + expandDominatorTreeNode, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_big_tree.html"; + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + // Taking snapshots and computing dominator trees is slow :-/ + requestLongerTimeout(4); + + const store = panel.panelWin.gStore; + const { getState, dispatch } = store; + const doc = panel.panelWin.document; + + dispatch(changeView(viewState.DOMINATOR_TREE)); + + // Take a snapshot. + + const takeSnapshotButton = doc.getElementById("take-snapshot"); + EventUtils.synthesizeMouseAtCenter(takeSnapshotButton, {}, panel.panelWin); + + // Wait for the dominator tree to be computed and fetched. + + await waitUntilDominatorTreeState(store, [dominatorTreeState.LOADED]); + ok(true, "Computed and fetched the dominator tree."); + + // Expand all the dominator tree nodes that are eagerly fetched, except for + // the leaves which will trigger fetching their lazily loaded subtrees. + + const id = getState().snapshots[0].id; + const root = getState().snapshots[0].dominatorTree.root; + (function expandAllEagerlyFetched(node = root) { + if (!node.moreChildrenAvailable || node.children) { + dispatch(expandDominatorTreeNode(id, node)); + } + + if (node.children) { + for (const child of node.children) { + expandAllEagerlyFetched(child); + } + } + })(); + + // Find the deepest eagerly loaded node: one which has more children but none + // of them are loaded. + + const deepest = (function findDeepest(node = root) { + if (node.moreChildrenAvailable && !node.children) { + return node; + } + + if (node.children) { + for (const child of node.children) { + const found = findDeepest(child); + if (found) { + return found; + } + } + } + + return null; + })(); + + ok(deepest, "Found the deepest node"); + ok( + !getState().snapshots[0].dominatorTree.expanded.has(deepest.nodeId), + "The deepest node should not be expanded" + ); + + // Select the deepest node. + + EventUtils.synthesizeMouseAtCenter( + doc.querySelector(`.node-${deepest.nodeId}`), + {}, + panel.panelWin + ); + await waitUntilState( + store, + state => state.snapshots[0].dominatorTree.focused.nodeId === deepest.nodeId + ); + ok( + doc.querySelector(`.node-${deepest.nodeId}`).classList.contains("focused"), + "The deepest node should be focused now" + ); + + // Expand the deepest node, which triggers an incremental fetch of its lazily + // loaded subtree. + + EventUtils.synthesizeKey("VK_RIGHT", {}, panel.panelWin); + await waitUntilState(store, state => + state.snapshots[0].dominatorTree.expanded.has(deepest.nodeId) + ); + is( + getState().snapshots[0].dominatorTree.state, + dominatorTreeState.INCREMENTAL_FETCHING, + "Expanding the deepest node should start an incremental fetch of its subtree" + ); + ok( + doc.querySelector(`.node-${deepest.nodeId}`).classList.contains("focused"), + "The deepest node should still be focused after expansion" + ); + + // Wait for the incremental fetch to complete. + + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + ok(true, "And the incremental fetch completes."); + ok( + doc.querySelector(`.node-${deepest.nodeId}`).classList.contains("focused"), + "The deepest node should still be focused after we have loaded its children" + ); + + // Find the most up-to-date version of the node whose children we just + // incrementally fetched. + + const newDeepest = (function findNewDeepest( + node = getState().snapshots[0].dominatorTree.root + ) { + if (node.nodeId === deepest.nodeId) { + return node; + } + + if (node.children) { + for (const child of node.children) { + const found = findNewDeepest(child); + if (found) { + return found; + } + } + } + + return null; + })(); + + ok(newDeepest, "We found the up-to-date version of deepest"); + ok(newDeepest.children, "And its children are loaded"); + ok(newDeepest.children.length, "And there are more than 0 children"); + + const firstChild = newDeepest.children[0]; + ok(firstChild, "deepest should have a first child"); + ok( + doc.querySelector(`.node-${firstChild.nodeId}`), + "and the first child should exist in the dom" + ); + + // Select the newly loaded first child by pressing the right arrow once more. + + EventUtils.synthesizeKey("VK_RIGHT", {}, panel.panelWin); + await waitUntilState( + store, + state => state.snapshots[0].dominatorTree.focused === firstChild + ); + ok( + doc + .querySelector(`.node-${firstChild.nodeId}`) + .classList.contains("focused"), + "The first child should now be focused" + ); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_dominator_trees_02.js b/devtools/client/memory/test/browser/browser_memory_dominator_trees_02.js new file mode 100644 index 0000000000..fea3603c52 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_dominator_trees_02.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Integration test for mouse interaction in the dominator tree + +"use strict"; + +const { + dominatorTreeState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_steady_allocation.html"; + +function clickOnNodeArrow(node, panel) { + EventUtils.synthesizeMouseAtCenter( + node.querySelector(".arrow"), + {}, + panel.panelWin + ); +} + +this.test = makeMemoryTest(TEST_URL, async function ({ panel }) { + // Taking snapshots and computing dominator trees is slow :-/ + requestLongerTimeout(4); + + const store = panel.panelWin.gStore; + const { getState, dispatch } = store; + const doc = panel.panelWin.document; + + dispatch(changeView(viewState.DOMINATOR_TREE)); + + // Take a snapshot. + const takeSnapshotButton = doc.getElementById("take-snapshot"); + EventUtils.synthesizeMouseAtCenter(takeSnapshotButton, {}, panel.panelWin); + + // Wait for the dominator tree to be computed and fetched. + await waitUntilState( + store, + state => + state.snapshots[0] && + state.snapshots[0].dominatorTree && + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + ok(true, "Computed and fetched the dominator tree."); + + const root = getState().snapshots[0].dominatorTree.root; + ok( + getState().snapshots[0].dominatorTree.expanded.has(root.nodeId), + "Root node is expanded by default" + ); + + // Click on root arrow to collapse the root element + const rootNode = doc.querySelector(`.node-${root.nodeId}`); + clickOnNodeArrow(rootNode, panel); + + await waitUntilState( + store, + state => + state.snapshots[0] && + state.snapshots[0].dominatorTree && + !state.snapshots[0].dominatorTree.expanded.has(root.nodeId) + ); + ok(true, "Root node collapsed"); + + // Click on root arrow to expand it again + clickOnNodeArrow(rootNode, panel); + + await waitUntilState( + store, + state => + state.snapshots[0] && + state.snapshots[0].dominatorTree && + state.snapshots[0].dominatorTree.expanded.has(root.nodeId) + ); + ok(true, "Root node is expanded again"); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_filter_01.js b/devtools/client/memory/test/browser/browser_memory_filter_01.js new file mode 100644 index 0000000000..00dcfdb951 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_filter_01.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Sanity test that we can show allocation stack displays in the tree. + +"use strict"; + +const { + dominatorTreeState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + changeViewAndRefresh, + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_steady_allocation.html"; + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + const heapWorker = panel.panelWin.gHeapAnalysesClient; + const store = panel.panelWin.gStore; + const { dispatch } = store; + const doc = panel.panelWin.document; + + dispatch(changeView(viewState.CENSUS)); + + const takeSnapshotButton = doc.getElementById("take-snapshot"); + EventUtils.synthesizeMouseAtCenter(takeSnapshotButton, {}, panel.panelWin); + + await waitUntilState( + store, + state => + state.snapshots.length === 1 && + state.snapshots[0].census && + state.snapshots[0].census.state === censusState.SAVING + ); + + let filterInput = doc.getElementById("filter"); + EventUtils.synthesizeMouseAtCenter(filterInput, {}, panel.panelWin); + EventUtils.sendString("js::Shape", panel.panelWin); + + await waitUntilState( + store, + state => + state.snapshots.length === 1 && + state.snapshots[0].census && + state.snapshots[0].census.state === censusState.SAVING + ); + ok(true, "adding a filter string should trigger census recompute"); + + await waitUntilState( + store, + state => + state.snapshots.length === 1 && + state.snapshots[0].census && + state.snapshots[0].census.state === censusState.SAVED + ); + + let nameElem = doc.querySelector(".heap-tree-item-field.heap-tree-item-name"); + ok(nameElem, "Should get a tree item row with a name"); + is( + nameElem.textContent.trim(), + "js::Shape", + "the tree item should be the one we filtered for" + ); + is( + filterInput.value, + "js::Shape", + "and filter input contains the user value" + ); + + // Now switch the dominator view, then switch back to census view + // and check that the filter word is still correctly applied + dispatch(changeViewAndRefresh(viewState.DOMINATOR_TREE, heapWorker)); + ok(true, "change view to dominator tree"); + + // Wait for the dominator tree to be computed and fetched. + await waitUntilDominatorTreeState(store, [dominatorTreeState.LOADED]); + ok(true, "computed and fetched the dominator tree."); + + dispatch(changeViewAndRefresh(viewState.CENSUS, heapWorker)); + ok(true, "change view back to census"); + + await waitUntilState( + store, + state => + state.snapshots.length === 1 && + state.snapshots[0].census && + state.snapshots[0].census.state === censusState.SAVED + ); + + nameElem = doc.querySelector(".heap-tree-item-field.heap-tree-item-name"); + filterInput = doc.getElementById("filter"); + + ok(nameElem, "Should still get a tree item row with a name"); + is( + nameElem.textContent.trim(), + "js::Shape", + "the tree item should still be the one we filtered for" + ); + is( + filterInput.value, + "js::Shape", + "and filter input still contains the user value" + ); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_fission_switch_target.js b/devtools/client/memory/test/browser/browser_memory_fission_switch_target.js new file mode 100644 index 0000000000..5d0d474d76 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_fission_switch_target.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test top-level target switching for memory panel. + +const { + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); +const PARENT_PROCESS_URI = "about:robots"; +const CONTENT_PROCESS_URI = + "data:text/html,<section>content process page</section>"; +const EXPECTED_ELEMENT_IN_PARENT_PROCESS = "button"; +const EXPECTED_ELEMENT_IN_CONTENT_PROCESS = "section"; + +add_task(async () => { + info("Open the memory panel with empty page"); + const tab = await addTab(); + const { panel } = await openMemoryPanel(tab); + const { gStore: store } = panel.panelWin; + + info("Open a page running on the content process"); + await navigateTo(CONTENT_PROCESS_URI); + await takeAndWaitSnapshot( + panel.panelWin, + store, + EXPECTED_ELEMENT_IN_CONTENT_PROCESS + ); + ok(true, "Can take a snapshot for content process page correctly"); + + info("Navigate to a page running on parent process"); + await navigateTo(PARENT_PROCESS_URI); + await takeAndWaitSnapshot( + panel.panelWin, + store, + EXPECTED_ELEMENT_IN_PARENT_PROCESS + ); + ok(true, "Can take a snapshot for parent process page correctly"); + + info("Return to a page running on content process again"); + await navigateTo(CONTENT_PROCESS_URI); + await takeAndWaitSnapshot( + panel.panelWin, + store, + EXPECTED_ELEMENT_IN_CONTENT_PROCESS + ); + ok( + true, + "Can take a snapshot for content process page correctly after switching targets twice" + ); +}); + +async function takeAndWaitSnapshot(window, store, expectedElement) { + await asyncWaitUntil(async () => { + await takeSnapshot(window); + + await waitUntilState( + store, + state => + state.snapshots[0].treeMap && + state.snapshots[0].treeMap.state === treeMapState.SAVED + ); + + const snapshot = store.getState().snapshots[0]; + const nodeNames = getNodeNames(snapshot); + + await clearSnapshots(window); + + return nodeNames.includes(expectedElement); + }); +} + +function getNodeNames(snapshot) { + const domNodePart = snapshot.treeMap.report.children.find( + child => child.name === "domNode" + ); + return domNodePart.children.map(child => child.name.toLowerCase()); +} diff --git a/devtools/client/memory/test/browser/browser_memory_individuals_01.js b/devtools/client/memory/test/browser/browser_memory_individuals_01.js new file mode 100644 index 0000000000..f54154b949 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_individuals_01.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Sanity test that we can show census group individuals, and then go back to +// the previous view. + +"use strict"; + +const { + individualsState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_steady_allocation.html"; + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + const store = panel.panelWin.gStore; + const { dispatch } = store; + const doc = panel.panelWin.document; + + dispatch(changeView(viewState.CENSUS)); + + // Take a snapshot and wait for the census to finish. + + const takeSnapshotButton = doc.getElementById("take-snapshot"); + EventUtils.synthesizeMouseAtCenter(takeSnapshotButton, {}, panel.panelWin); + + await waitUntilState(store, state => { + return ( + state.snapshots.length === 1 && + state.snapshots[0].census && + state.snapshots[0].census.state === censusState.SAVED + ); + }); + + // Click on the first individuals button found, and wait for the individuals + // to be fetched. + + const individualsButton = doc.querySelector(".individuals-button"); + EventUtils.synthesizeMouseAtCenter(individualsButton, {}, panel.panelWin); + + await waitUntilState(store, state => { + return ( + state.view.state === viewState.INDIVIDUALS && + state.individuals && + state.individuals.state === individualsState.FETCHED + ); + }); + + ok( + doc.getElementById("shortest-paths"), + "Should be showing the shortest paths component" + ); + ok(doc.querySelector(".heap-tree-item"), "Should be showing the individuals"); + + // Go back to the previous view. + + const popViewButton = doc.getElementById("pop-view-button"); + ok(popViewButton, "Should be showing the #pop-view-button"); + EventUtils.synthesizeMouseAtCenter(popViewButton, {}, panel.panelWin); + + await waitUntilState(store, state => { + return state.view.state === viewState.CENSUS; + }); + + ok( + !doc.getElementById("shortest-paths"), + "Should not be showing the shortest paths component anymore" + ); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_keyboard-snapshot-list.js b/devtools/client/memory/test/browser/browser_memory_keyboard-snapshot-list.js new file mode 100644 index 0000000000..7a2e2e12f7 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_keyboard-snapshot-list.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that using ACCEL+UP/DOWN, the user can navigate between snapshots. + +"use strict"; + +const { viewState } = require("resource://devtools/client/memory/constants.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_steady_allocation.html"; + +this.test = makeMemoryTest(TEST_URL, async function ({ panel }) { + // Creating snapshots already takes ~25 seconds on linux 32 debug machines + // which makes the test very likely to go over the allowed timeout + requestLongerTimeout(2); + + const heapWorker = panel.panelWin.gHeapAnalysesClient; + const store = panel.panelWin.gStore; + const { dispatch } = store; + const front = store.getState().front; + const doc = panel.panelWin.document; + + dispatch(changeView(viewState.CENSUS)); + + info("Take 3 snapshots"); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + + await waitUntilState( + store, + state => + state.snapshots.length == 3 && + state.snapshots.every( + s => s.census && s.census.state === censusState.SAVED + ) + ); + ok(true, "All snapshots censuses are in SAVED state"); + + await waitUntilSnapshotSelected(store, 2); + ok(true, "Third snapshot selected after creating all snapshots."); + + info("Press ACCEL+UP key, expect second snapshot selected."); + EventUtils.synthesizeKey("VK_UP", { accelKey: true }, panel.panelWin); + await waitUntilSnapshotSelected(store, 1); + ok(true, "Second snapshot selected after alt+UP."); + + info("Press ACCEL+UP key, expect first snapshot selected."); + EventUtils.synthesizeKey("VK_UP", { accelKey: true }, panel.panelWin); + await waitUntilSnapshotSelected(store, 0); + ok(true, "First snapshot is selected after ACCEL+UP"); + + info("Check ACCEL+UP is a noop when the first snapshot is selected."); + EventUtils.synthesizeKey("VK_UP", { accelKey: true }, panel.panelWin); + // We assume the snapshot selection should be synchronous here. + is(getSelectedSnapshotIndex(store), 0, "First snapshot is still selected"); + + info("Press ACCEL+DOWN key, expect second snapshot selected."); + EventUtils.synthesizeKey("VK_DOWN", { accelKey: true }, panel.panelWin); + await waitUntilSnapshotSelected(store, 1); + ok(true, "Second snapshot is selected after ACCEL+DOWN"); + + info("Click on first node."); + const firstNode = doc.querySelector(".tree .heap-tree-item-name"); + EventUtils.synthesizeMouseAtCenter(firstNode, {}, panel.panelWin); + await waitUntilState( + store, + state => + state.snapshots[1].census.focused === + state.snapshots[1].census.report.children[0] + ); + ok(true, "First root is selected after click."); + + info("Press DOWN key, expect second root focused."); + EventUtils.synthesizeKey("VK_DOWN", {}, panel.panelWin); + await waitUntilState( + store, + state => + state.snapshots[1].census.focused === + state.snapshots[1].census.report.children[1] + ); + ok(true, "Second root is selected after pressing DOWN."); + is(getSelectedSnapshotIndex(store), 1, "Second snapshot is still selected"); + + info("Press UP key, expect second root focused."); + EventUtils.synthesizeKey("VK_UP", {}, panel.panelWin); + await waitUntilState( + store, + state => + state.snapshots[1].census.focused === + state.snapshots[1].census.report.children[0] + ); + ok(true, "First root is selected after pressing UP."); + is(getSelectedSnapshotIndex(store), 1, "Second snapshot is still selected"); + + info("Press ACCEL+DOWN key, expect third snapshot selected."); + EventUtils.synthesizeKey("VK_DOWN", { accelKey: true }, panel.panelWin); + await waitUntilSnapshotSelected(store, 2); + ok(true, "Thirdˆ snapshot is selected after ACCEL+DOWN"); + + info("Check ACCEL+DOWN is a noop when the last snapshot is selected."); + EventUtils.synthesizeKey("VK_DOWN", { accelKey: true }, panel.panelWin); + // We assume the snapshot selection should be synchronous here. + is(getSelectedSnapshotIndex(store), 2, "Third snapshot is still selected"); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_keyboard.js b/devtools/client/memory/test/browser/browser_memory_keyboard.js new file mode 100644 index 0000000000..0fba33c456 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_keyboard.js @@ -0,0 +1,111 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Bug 1246570 - Check that when pressing on LEFT arrow, the parent tree node +// gets focused. + +"use strict"; + +const { viewState } = require("resource://devtools/client/memory/constants.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_steady_allocation.html"; + +function waitUntilFocused(store, node) { + return waitUntilState( + store, + state => + state.snapshots.length === 1 && + state.snapshots[0].census && + state.snapshots[0].census.state === censusState.SAVED && + state.snapshots[0].census.focused && + state.snapshots[0].census.focused === node + ); +} + +function waitUntilExpanded(store, node) { + return waitUntilState( + store, + state => + state.snapshots[0] && + state.snapshots[0].census && + state.snapshots[0].census.expanded.has(node.id) + ); +} + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + const heapWorker = panel.panelWin.gHeapAnalysesClient; + const store = panel.panelWin.gStore; + const { getState, dispatch } = store; + const front = getState().front; + const doc = panel.panelWin.document; + + dispatch(changeView(viewState.CENSUS)); + + is(getState().censusDisplay.breakdown.by, "coarseType"); + + await dispatch(takeSnapshotAndCensus(front, heapWorker)); + const census = getState().snapshots[0].census; + const root1 = census.report.children[0]; + const root2 = census.report.children[0]; + const root3 = census.report.children[0]; + const root4 = census.report.children[0]; + const child1 = root1.children[0]; + + info("Click on first node."); + const firstNode = doc.querySelector(".tree .heap-tree-item-name"); + EventUtils.synthesizeMouseAtCenter(firstNode, {}, panel.panelWin); + await waitUntilFocused(store, root1); + ok(true, "First root is selected after click."); + + info("Press DOWN key, expect second root focused."); + EventUtils.synthesizeKey("VK_DOWN", {}, panel.panelWin); + await waitUntilFocused(store, root2); + ok(true, "Second root is selected after pressing DOWN arrow."); + + info("Press DOWN key, expect third root focused."); + EventUtils.synthesizeKey("VK_DOWN", {}, panel.panelWin); + await waitUntilFocused(store, root3); + ok(true, "Third root is selected after pressing DOWN arrow."); + + info("Press DOWN key, expect fourth root focused."); + EventUtils.synthesizeKey("VK_DOWN", {}, panel.panelWin); + await waitUntilFocused(store, root4); + ok(true, "Fourth root is selected after pressing DOWN arrow."); + + info("Press UP key, expect third root focused."); + EventUtils.synthesizeKey("VK_UP", {}, panel.panelWin); + await waitUntilFocused(store, root3); + ok(true, "Third root is selected after pressing UP arrow."); + + info("Press UP key, expect second root focused."); + EventUtils.synthesizeKey("VK_UP", {}, panel.panelWin); + await waitUntilFocused(store, root2); + ok(true, "Second root is selected after pressing UP arrow."); + + info("Press UP key, expect first root focused."); + EventUtils.synthesizeKey("VK_UP", {}, panel.panelWin); + await waitUntilFocused(store, root1); + ok(true, "First root is selected after pressing UP arrow."); + + info("Press RIGHT key"); + EventUtils.synthesizeKey("VK_RIGHT", {}, panel.panelWin); + await waitUntilExpanded(store, root1); + ok(true, "Root node is expanded."); + + info("Press RIGHT key, expect first child focused."); + EventUtils.synthesizeKey("VK_RIGHT", {}, panel.panelWin); + await waitUntilFocused(store, child1); + ok(true, "First child is selected after pressing RIGHT arrow."); + + info("Press LEFT key, expect first root focused."); + EventUtils.synthesizeKey("VK_LEFT", {}, panel.panelWin); + await waitUntilFocused(store, root1); + ok(true, "First root is selected after pressing LEFT arrow."); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_no_allocation_stacks.js b/devtools/client/memory/test/browser/browser_memory_no_allocation_stacks.js new file mode 100644 index 0000000000..b8b09b35d0 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_no_allocation_stacks.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Sanity test that we can show allocation stack displays in the tree. + +"use strict"; + +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const censusDisplayActions = require("resource://devtools/client/memory/actions/census-display.js"); +const { viewState } = require("resource://devtools/client/memory/constants.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_steady_allocation.html"; + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + const heapWorker = panel.panelWin.gHeapAnalysesClient; + const { getState, dispatch } = panel.panelWin.gStore; + const front = getState().front; + const doc = panel.panelWin.document; + + dispatch(changeView(viewState.CENSUS)); + + ok(!getState().allocations.recording, "Should not be recording allocagtions"); + + await dispatch(takeSnapshotAndCensus(front, heapWorker)); + await dispatch( + censusDisplayActions.setCensusDisplayAndRefresh( + heapWorker, + censusDisplays.allocationStack + ) + ); + + is( + getState().censusDisplay.breakdown.by, + "allocationStack", + "Should be using allocation stack breakdown" + ); + + ok( + !getState().allocations.recording, + "Should still not be recording allocagtions" + ); + + ok( + doc.querySelector(".no-allocation-stacks"), + "Because we did not record allocations, " + + "the no-allocation-stack warning should be visible" + ); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_no_auto_expand.js b/devtools/client/memory/test/browser/browser_memory_no_auto_expand.js new file mode 100644 index 0000000000..9704d925d1 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_no_auto_expand.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Bug 1221150 - Ensure that census trees do not accidentally auto expand +// when clicking on the allocation stacks checkbox. + +"use strict"; + +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { viewState } = require("resource://devtools/client/memory/constants.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_steady_allocation.html"; + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + const heapWorker = panel.panelWin.gHeapAnalysesClient; + const { getState, dispatch } = panel.panelWin.gStore; + const front = getState().front; + const doc = panel.panelWin.document; + + dispatch(changeView(viewState.CENSUS)); + + await dispatch(takeSnapshotAndCensus(front, heapWorker)); + + is(getState().allocations.recording, false); + const recordingCheckbox = doc.getElementById( + "record-allocation-stacks-checkbox" + ); + EventUtils.synthesizeMouseAtCenter(recordingCheckbox, {}, panel.panelWin); + is(getState().allocations.recording, true); + + const nameElems = [ + ...doc.querySelectorAll(".heap-tree-item-field.heap-tree-item-name"), + ]; + + for (const el of nameElems) { + dumpn(`Found ${el.textContent.trim()}`); + is( + el.style.marginInlineStart, + "0px", + "None of the elements should be an indented/expanded child" + ); + } +}); diff --git a/devtools/client/memory/test/browser/browser_memory_percents_01.js b/devtools/client/memory/test/browser/browser_memory_percents_01.js new file mode 100644 index 0000000000..67c4f4368b --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_percents_01.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Sanity test that we calculate percentages in the tree. + +"use strict"; + +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { viewState } = require("resource://devtools/client/memory/constants.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_steady_allocation.html"; + +function checkCells(cells) { + ok(cells.length > 1, "Should have found some"); + // Ignore the first header cell. + for (const cell of cells.slice(1)) { + const percent = cell.querySelector(".heap-tree-percent"); + ok(percent, "should have a percent cell"); + ok( + percent.textContent.match(/^\d?\d%$/), + "should be of the form nn% or n%" + ); + } +} + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + const heapWorker = panel.panelWin.gHeapAnalysesClient; + const { getState, dispatch } = panel.panelWin.gStore; + const front = getState().front; + const doc = panel.panelWin.document; + + dispatch(changeView(viewState.CENSUS)); + + await dispatch(takeSnapshotAndCensus(front, heapWorker)); + is( + getState().censusDisplay.breakdown.by, + "coarseType", + "Should be using coarse type breakdown" + ); + + const bytesCells = [...doc.querySelectorAll(".heap-tree-item-bytes")]; + checkCells(bytesCells); + + const totalBytesCells = [ + ...doc.querySelectorAll(".heap-tree-item-total-bytes"), + ]; + checkCells(totalBytesCells); + + const countCells = [...doc.querySelectorAll(".heap-tree-item-count")]; + checkCells(countCells); + + const totalCountCells = [ + ...doc.querySelectorAll(".heap-tree-item-total-count"), + ]; + checkCells(totalCountCells); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_refresh_does_not_leak.js b/devtools/client/memory/test/browser/browser_memory_refresh_does_not_leak.js new file mode 100644 index 0000000000..e0ee1eee41 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_refresh_does_not_leak.js @@ -0,0 +1,139 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* global ChromeUtils */ + +// Test that refreshing the page with devtools open does not leak the old +// windows from previous navigations. +// +// IF THIS TEST STARTS FAILING, YOU ARE LEAKING EVERY WINDOW EVER NAVIGATED TO +// WHILE DEVTOOLS ARE OPEN! THIS IS NOT SPECIFIC TO THE MEMORY TOOL ONLY! + +"use strict"; + +const { + getLabelAndShallowSize, +} = require("resource://devtools/shared/heapsnapshot/DominatorTreeNode.js"); + +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_empty.html"; + +async function getWindowsInSnapshot(front) { + dumpn("Taking snapshot."); + const path = await front.saveHeapSnapshot(); + dumpn("Took snapshot with path = " + path); + const snapshot = ChromeUtils.readHeapSnapshot(path); + dumpn("Read snapshot into memory, taking census."); + const report = snapshot.takeCensus({ + breakdown: { + by: "objectClass", + then: { by: "bucket" }, + other: { by: "count", count: true, bytes: false }, + }, + }); + dumpn("Took census, window count = " + report.Window.count); + return report.Window; +} + +const DESCRIPTION = { + by: "coarseType", + objects: { + by: "objectClass", + then: { by: "count", count: true, bytes: false }, + other: { by: "count", count: true, bytes: false }, + }, + strings: { by: "count", count: true, bytes: false }, + scripts: { + by: "internalType", + then: { by: "count", count: true, bytes: false }, + }, + other: { + by: "internalType", + then: { by: "count", count: true, bytes: false }, + }, +}; + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + let front = panel.panelWin.gStore.getState().front; + + const startWindows = await getWindowsInSnapshot(front); + dumpn( + "Initial windows found = " + + startWindows.map(w => "0x" + w.toString(16)).join(", ") + ); + is(startWindows.length, 1); + + await reloadBrowser(); + + // Update the front as we may have switched to a new target and a new memory front + front = panel.panelWin.gStore.getState().front; + + const endWindows = await getWindowsInSnapshot(front); + is(endWindows.length, 1); + + if (endWindows.length === 1) { + return; + } + + dumpn("Test failed, diagnosing leaking windows."); + dumpn( + "(This may fail if a moving GC has relocated the initial Window objects.)" + ); + + dumpn("Taking full runtime snapshot."); + const path = await front.saveHeapSnapshot({ boundaries: { runtime: true } }); + dumpn("Full runtime's snapshot path = " + path); + + dumpn("Reading full runtime heap snapshot."); + const snapshot = ChromeUtils.readHeapSnapshot(path); + dumpn("Done reading full runtime heap snapshot."); + + const dominatorTree = snapshot.computeDominatorTree(); + const paths = snapshot.computeShortestPaths( + dominatorTree.root, + startWindows, + 50 + ); + + for (let i = 0; i < startWindows.length; i++) { + dumpn( + "Shortest retaining paths for leaking Window 0x" + + startWindows[i].toString(16) + + " =========================" + ); + let j = 0; + for (const retainingPath of paths.get(startWindows[i])) { + if (retainingPath.find(part => part.predecessor === startWindows[i])) { + // Skip paths that loop out from the target window and back to it again. + continue; + } + + dumpn( + " Path #" + + ++j + + ": --------------------------------------------------------------------" + ); + for (const part of retainingPath) { + const { label } = getLabelAndShallowSize( + part.predecessor, + snapshot, + DESCRIPTION + ); + dumpn( + " 0x" + + part.predecessor.toString(16) + + " (" + + label.join(" > ") + + ")" + ); + dumpn(" |"); + dumpn(" " + part.edge); + dumpn(" |"); + dumpn(" V"); + } + dumpn( + " 0x" + startWindows[i].toString(16) + " (objects > Window)" + ); + } + } +}); diff --git a/devtools/client/memory/test/browser/browser_memory_simple_01.js b/devtools/client/memory/test/browser/browser_memory_simple_01.js new file mode 100644 index 0000000000..a983e23395 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_simple_01.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests taking snapshots and default states. + */ + +const TEST_URL = + "http://example.com/browser/devtools/client/memory/test/browser/doc_steady_allocation.html"; +const { viewState } = require("resource://devtools/client/memory/constants.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + const { gStore, document } = panel.panelWin; + const { getState, dispatch } = gStore; + + dispatch(changeView(viewState.CENSUS)); + + let snapshotEls = document.querySelectorAll( + "#memory-tool-container .list li" + ); + is(getState().snapshots.length, 0, "Starts with no snapshots in store"); + is(snapshotEls.length, 0, "No snapshots rendered"); + + await takeSnapshot(panel.panelWin); + snapshotEls = document.querySelectorAll("#memory-tool-container .list li"); + is(getState().snapshots.length, 1, "One snapshot was created in store"); + is(snapshotEls.length, 1, "One snapshot was rendered"); + ok( + snapshotEls[0].classList.contains("selected"), + "Only snapshot has `selected` class" + ); + + await takeSnapshot(panel.panelWin); + snapshotEls = document.querySelectorAll("#memory-tool-container .list li"); + is(getState().snapshots.length, 2, "Two snapshots created in store"); + is(snapshotEls.length, 2, "Two snapshots rendered"); + ok( + !snapshotEls[0].classList.contains("selected"), + "First snapshot no longer has `selected` class" + ); + ok( + snapshotEls[1].classList.contains("selected"), + "Second snapshot has `selected` class" + ); + + await waitUntilCensusState(gStore, s => s.census, [ + censusState.SAVED, + censusState.SAVED, + ]); + + ok( + document.querySelector(".heap-tree-item-name"), + "Should have rendered some tree items" + ); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_transferHeapSnapshot_e10s_01.js b/devtools/client/memory/test/browser/browser_memory_transferHeapSnapshot_e10s_01.js new file mode 100644 index 0000000000..c647836332 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_transferHeapSnapshot_e10s_01.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* global ChromeUtils, HeapSnapshot */ + +// Test that we can save a heap snapshot and transfer it over the RDP in e10s +// where the child process is sandboxed and so we have to use +// HeapSnapshotFileActor to get the heap snapshot file. + +"use strict"; + +const TEST_URL = "data:text/html,<html><body></body></html>"; + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + const memoryFront = panel.panelWin.gStore.getState().front; + ok(memoryFront, "Should get the MemoryFront"); + + const snapshotFilePath = await memoryFront.saveHeapSnapshot({ + // Force a copy so that we go through the HeapSnapshotFileActor's + // transferHeapSnapshot request and exercise this code path on e10s. + forceCopy: true, + }); + + ok( + !!(await IOUtils.stat(snapshotFilePath)), + "Should have the heap snapshot file" + ); + + const snapshot = ChromeUtils.readHeapSnapshot(snapshotFilePath); + ok( + HeapSnapshot.isInstance(snapshot), + "And we should be able to read a HeapSnapshot instance from the file" + ); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_tree_map-01.js b/devtools/client/memory/test/browser/browser_memory_tree_map-01.js new file mode 100644 index 0000000000..c65b7fc079 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_tree_map-01.js @@ -0,0 +1,136 @@ +/* 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/. */ + +// Make sure the canvases are created correctly + +"use strict"; + +const CanvasUtils = require("resource://devtools/client/memory/components/tree-map/canvas-utils.js"); +const D3_SCRIPT = + '<script type="application/javascript" ' + + 'src="chrome://global/content/third_party/d3/d3.js">'; +const TEST_URL = `data:text/html,<html><body>${D3_SCRIPT}</body></html>`; + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + const document = panel.panelWin.document; + const window = panel.panelWin; + const div = document.createElement("div"); + + Object.assign(div.style, { + width: "100px", + height: "200px", + position: "absolute", + }); + + document.body.appendChild(div); + + info("Create the canvases"); + + const canvases = new CanvasUtils(div, 0); + + info("Test the shape of the returned object"); + + is(typeof canvases, "object", "Canvases create an object"); + is(typeof canvases.emit, "function", "Decorated with an EventEmitter"); + is(typeof canvases.on, "function", "Decorated with an EventEmitter"); + is(div.children[0], canvases.container, "Div has the container"); + ok( + canvases.main.canvas instanceof window.HTMLCanvasElement, + "Creates the main canvas" + ); + ok( + canvases.zoom.canvas instanceof window.HTMLCanvasElement, + "Creates the zoom canvas" + ); + ok( + canvases.main.ctx instanceof window.CanvasRenderingContext2D, + "Creates the main canvas context" + ); + ok( + canvases.zoom.ctx instanceof window.CanvasRenderingContext2D, + "Creates the zoom canvas context" + ); + + info("Test resizing"); + + let timesResizeCalled = 0; + canvases.on("resize", function () { + timesResizeCalled++; + }); + + const main = canvases.main.canvas; + const zoom = canvases.zoom.canvas; + const ratio = window.devicePixelRatio; + + is( + main.width, + 100 * ratio, + "Main canvas width is the same as the parent div" + ); + is( + main.height, + 200 * ratio, + "Main canvas height is the same as the parent div" + ); + is( + zoom.width, + 100 * ratio, + "Zoom canvas width is the same as the parent div" + ); + is( + zoom.height, + 200 * ratio, + "Zoom canvas height is the same as the parent div" + ); + is(timesResizeCalled, 0, "Resize was not emitted"); + + div.style.width = "500px"; + div.style.height = "700px"; + + window.dispatchEvent(new Event("resize")); + + is( + main.width, + 500 * ratio, + "Main canvas width is resized to be the same as the parent div" + ); + is( + main.height, + 700 * ratio, + "Main canvas height is resized to be the same as the parent div" + ); + is( + zoom.width, + 500 * ratio, + "Zoom canvas width is resized to be the same as the parent div" + ); + is( + zoom.height, + 700 * ratio, + "Zoom canvas height is resized to be the same as the parent div" + ); + is(timesResizeCalled, 1, "'resize' was emitted was emitted"); + + div.style.width = "1100px"; + div.style.height = "1300px"; + + canvases.destroy(); + window.dispatchEvent(new Event("resize")); + + is(main.width, 500 * ratio, "Main canvas width is not resized after destroy"); + is( + main.height, + 700 * ratio, + "Main canvas height is not resized after destroy" + ); + is(zoom.width, 500 * ratio, "Zoom canvas width is not resized after destroy"); + is( + zoom.height, + 700 * ratio, + "Zoom canvas height is not resized after destroy" + ); + is(timesResizeCalled, 1, "onResize was not called again"); + + document.body.removeChild(div); +}); diff --git a/devtools/client/memory/test/browser/browser_memory_tree_map-02.js b/devtools/client/memory/test/browser/browser_memory_tree_map-02.js new file mode 100644 index 0000000000..890ede23b5 --- /dev/null +++ b/devtools/client/memory/test/browser/browser_memory_tree_map-02.js @@ -0,0 +1,199 @@ +/* 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/. */ + +// Test the drag and zooming behavior + +"use strict"; + +const CanvasUtils = require("resource://devtools/client/memory/components/tree-map/canvas-utils.js"); +const DragZoom = require("resource://devtools/client/memory/components/tree-map/drag-zoom.js"); + +const TEST_URL = "data:text/html,<html><body></body></html>"; +const PIXEL_SCROLL_MODE = 0; +const PIXEL_DELTA = 10; +const MAX_RAF_LOOP = 1000; + +this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + const panelWin = panel.panelWin; + const panelDoc = panelWin.document; + const div = panelDoc.createElement("div"); + + Object.assign(div.style, { + width: "100px", + height: "200px", + position: "absolute", + left: 0, + top: 0, + }); + + const rafMock = createRAFMock(); + + panelDoc.body.appendChild(div); + + const canvases = new CanvasUtils(div, 0); + const dragZoom = new DragZoom(canvases.container, 0, rafMock.raf); + const style = canvases.container.style; + + info("Check initial state of dragZoom"); + { + is(dragZoom.zoom, 0, "Zooming starts at 0"); + is(dragZoom.smoothZoom, 0, "Smoothed zooming starts at 0"); + is(rafMock.timesCalled, 0, "No RAFs have been queued"); + is( + style.transform, + "translate(0px) scale(1)", + "No transforms have been done." + ); + + canvases.container.dispatchEvent( + new WheelEvent("wheel", { + deltaY: -PIXEL_DELTA, + deltaMode: PIXEL_SCROLL_MODE, + }) + ); + + is( + style.transform, + "translate(0px) scale(1.05)", + "The div has been slightly scaled." + ); + is( + dragZoom.zoom, + PIXEL_DELTA * dragZoom.ZOOM_SPEED, + "The zoom was increased" + ); + ok( + floatEquality(dragZoom.smoothZoom, 0.05), + "The smooth zoom is between the initial value and the target" + ); + is(rafMock.timesCalled, 1, "A RAF has been queued"); + } + + info("RAF will eventually stop once the smooth values approach the target"); + { + let i; + let lastCallCount; + for (i = 0; i < MAX_RAF_LOOP; i++) { + if (lastCallCount === rafMock.timesCalled) { + break; + } + lastCallCount = rafMock.timesCalled; + rafMock.nextFrame(); + } + is( + style.transform, + "translate(0px) scale(1.1)", + "The scale has been fully applied" + ); + is( + dragZoom.zoom, + dragZoom.smoothZoom, + "The smooth and target zoom values match" + ); + isnot(MAX_RAF_LOOP, i, "The RAF loop correctly stopped"); + } + + info("Dragging correctly translates the div"); + { + div.dispatchEvent( + new MouseEvent("mousemove", { + clientX: 10, + clientY: 10, + }) + ); + div.dispatchEvent(new MouseEvent("mousedown")); + div.dispatchEvent( + new MouseEvent("mousemove", { + clientX: 20, + clientY: 20, + }) + ); + div.dispatchEvent(new MouseEvent("mouseup")); + + is( + style.transform, + "translate(2.5px, 5px) scale(1.1)", + "The style is correctly translated" + ); + ok( + floatEquality(dragZoom.translateX, 5), + "Translate X moved by some pixel amount" + ); + ok( + floatEquality(dragZoom.translateY, 10), + "Translate Y moved by some pixel amount" + ); + } + + info("Zooming centers around the mouse"); + { + canvases.container.dispatchEvent( + new WheelEvent("wheel", { + deltaY: -PIXEL_DELTA, + deltaMode: PIXEL_SCROLL_MODE, + }) + ); + // Run through the RAF loop to zoom in towards that value. + let lastCallCount; + for (let i = 0; i < MAX_RAF_LOOP; i++) { + if (lastCallCount === rafMock.timesCalled) { + break; + } + lastCallCount = rafMock.timesCalled; + rafMock.nextFrame(); + } + is( + style.transform, + "translate(8.18182px, 18.1818px) scale(1.2)", + "Zooming affects the translation to keep the mouse centered" + ); + ok( + floatEquality(dragZoom.translateX, 8.181818181818185), + "Translate X was affected by the mouse position" + ); + ok( + floatEquality(dragZoom.translateY, 18.18181818181817), + "Translate Y was affected by the mouse position" + ); + is(dragZoom.zoom, 0.2, "Zooming starts at 0"); + } + + dragZoom.destroy(); + + info("Scroll isn't tracked after destruction"); + { + const previousZoom = dragZoom.zoom; + const previousSmoothZoom = dragZoom.smoothZoom; + + canvases.container.dispatchEvent( + new WheelEvent("wheel", { + deltaY: -PIXEL_DELTA, + deltaMode: PIXEL_SCROLL_MODE, + }) + ); + + is(dragZoom.zoom, previousZoom, "The zoom stayed the same"); + is( + dragZoom.smoothZoom, + previousSmoothZoom, + "The smooth zoom stayed the same" + ); + } + + info("Translation isn't tracked after destruction"); + { + const initialX = dragZoom.translateX; + const initialY = dragZoom.translateY; + + div.dispatchEvent(new MouseEvent("mousedown")); + div.dispatchEvent(new MouseEvent("mousemove"), { + clientX: 40, + clientY: 40, + }); + div.dispatchEvent(new MouseEvent("mouseup")); + is(dragZoom.translateX, initialX, "The translationX didn't change"); + is(dragZoom.translateY, initialY, "The translationY didn't change"); + } + panelDoc.body.removeChild(div); +}); diff --git a/devtools/client/memory/test/browser/doc_big_tree.html b/devtools/client/memory/test/browser/doc_big_tree.html new file mode 100644 index 0000000000..9fe74cd28b --- /dev/null +++ b/devtools/client/memory/test/browser/doc_big_tree.html @@ -0,0 +1,20 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"/> + </head> + <body> + <script> + "use strict"; + + window.big = (function makeBig(depth = 0) { + let big = Array(5); + big.fill(undefined); + if (depth < 5) { + big = big.map(_ => makeBig(depth + 1)); + } + return big; + }()); + </script> + </body> +</html> diff --git a/devtools/client/memory/test/browser/doc_empty.html b/devtools/client/memory/test/browser/doc_empty.html new file mode 100644 index 0000000000..ef123d8d20 --- /dev/null +++ b/devtools/client/memory/test/browser/doc_empty.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"/> + </head> + <body> + This is an empty window. + </body> +</html> diff --git a/devtools/client/memory/test/browser/doc_steady_allocation.html b/devtools/client/memory/test/browser/doc_steady_allocation.html new file mode 100644 index 0000000000..3e168507fa --- /dev/null +++ b/devtools/client/memory/test/browser/doc_steady_allocation.html @@ -0,0 +1,21 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + </head> + <body> + <script> + "use strict"; + + var objects = window.objects = []; + var allocate = this.allocate = function allocate() { + for (let i = 0; i < 100; i++) { + objects.push({}); + } + setTimeout(allocate, 10); + }; + + allocate(); + </script> + </body> +</html> diff --git a/devtools/client/memory/test/browser/head.js b/devtools/client/memory/test/browser/head.js new file mode 100644 index 0000000000..8c8b3e580d --- /dev/null +++ b/devtools/client/memory/test/browser/head.js @@ -0,0 +1,269 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Load the shared test helpers into this compartment. +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +var { + censusDisplays, + censusState, + snapshotState: states, +} = require("resource://devtools/client/memory/constants.js"); +var { L10N } = require("resource://devtools/client/memory/utils.js"); + +Services.prefs.setBoolPref("devtools.memory.enabled", true); + +/** + * Open the memory panel for the given tab. + */ +this.openMemoryPanel = async function (tab) { + info("Opening memory panel."); + const toolbox = await gDevTools.showToolboxForTab(tab, { toolId: "memory" }); + info("Memory panel shown successfully."); + const panel = toolbox.getCurrentPanel(); + return { tab, panel }; +}; + +/** + * Close the memory panel for the given tab. + */ +this.closeMemoryPanel = async function (tab) { + info("Closing memory panel."); + const toolbox = await gDevTools.getToolboxForTab(tab); + await toolbox.destroy(); + info("Closed memory panel successfully."); +}; + +/** + * Return a test function that adds a tab with the given url, opens the memory + * panel, runs the given generator, closes the memory panel, removes the tab, + * and finishes. + * + * Example usage: + * + * this.test = makeMemoryTest(TEST_URL, async function ({ tab, panel }) { + * // Your tests go here... + * }); + */ +function makeMemoryTest(url, generator) { + return async function () { + waitForExplicitFinish(); + + // It can take a long time to save a snapshot to disk, read the snapshots + // back from disk, and finally perform analyses on them. + requestLongerTimeout(2); + + const tab = await addTab(url); + const results = await openMemoryPanel(tab); + + try { + await generator(results); + } catch (err) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(err)); + } + + await closeMemoryPanel(tab); + await removeTab(tab); + + finish(); + }; +} + +function dumpn(msg) { + dump(`MEMORY-TEST: ${msg}\n`); +} + +/** + * Returns a promise that will resolve when the provided store matches + * the expected array. expectedStates is an array of dominatorTree states. + * Expectations : + * - store.getState().snapshots.length == expected.length + * - snapshots[i].dominatorTree.state == expected[i] + * + * @param {Store} store + * @param {Array<string>} expectedStates [description] + * @return {Promise} + */ +function waitUntilDominatorTreeState(store, expected) { + const predicate = () => { + const snapshots = store.getState().snapshots; + return ( + snapshots.length === expected.length && + expected.every((state, i) => { + return ( + snapshots[i].dominatorTree && + snapshots[i].dominatorTree.state === state + ); + }) + ); + }; + info(`Waiting for dominator trees to be of state: ${expected}`); + return waitUntilState(store, predicate); +} + +function takeSnapshot(window) { + const { gStore, document } = window; + const snapshotCount = gStore.getState().snapshots.length; + info("Taking snapshot..."); + document.querySelector(".devtools-toolbar .take-snapshot").click(); + return waitUntilState( + gStore, + () => gStore.getState().snapshots.length === snapshotCount + 1 + ); +} + +function clearSnapshots(window) { + const { gStore, document } = window; + document.querySelector(".devtools-toolbar .clear-snapshots").click(); + return waitUntilState(gStore, () => + gStore + .getState() + .snapshots.every(snapshot => snapshot.state !== states.READ) + ); +} + +/** + * Sets the current requested display and waits for the selected snapshot to use + * it and complete the new census that entails. + */ +function setCensusDisplay(window, display) { + info(`Setting census display to ${display}...`); + const { gStore, gHeapAnalysesClient } = window; + // XXX: Should handle this via clicking the DOM, but React doesn't + // fire the onChange event, so just change it in the store. + // window.document.querySelector(`.select-display`).value = type; + gStore.dispatch( + require("resource://devtools/client/memory/actions/census-display.js").setCensusDisplayAndRefresh( + gHeapAnalysesClient, + display + ) + ); + + return waitUntilState(window.gStore, () => { + const selected = window.gStore.getState().snapshots.find(s => s.selected); + return ( + selected.state === states.READ && + selected.census && + selected.census.state === censusState.SAVED && + selected.census.display === display + ); + }); +} + +/** + * Get the snapshot tatus text currently displayed, or null if none is + * displayed. + * + * @param {Document} document + */ +function getDisplayedSnapshotStatus(document) { + const status = document.querySelector(".snapshot-status"); + return status ? status.textContent.trim() : null; +} + +/** + * Get the index of the currently selected snapshot. + * + * @return {Number} + */ +function getSelectedSnapshotIndex(store) { + const snapshots = store.getState().snapshots; + const selectedSnapshot = snapshots.find(s => s.selected); + return snapshots.indexOf(selectedSnapshot); +} + +/** + * Returns a promise that will resolve when the snapshot with provided index + * becomes selected. + * + * @return {Promise} + */ +function waitUntilSnapshotSelected(store, snapshotIndex) { + return waitUntilState( + store, + state => + state.snapshots[snapshotIndex] && + state.snapshots[snapshotIndex].selected === true + ); +} + +/** + * Wait until the state has censuses in a certain state. + * + * @return {Promise} + */ +function waitUntilCensusState(store, getCensus, expected) { + const predicate = () => { + const snapshots = store.getState().snapshots; + + info( + "Current census state:" + + snapshots.map(x => (getCensus(x) ? getCensus(x).state : null)) + ); + + return ( + snapshots.length === expected.length && + expected.every((state, i) => { + const census = getCensus(snapshots[i]); + return ( + state === "*" || + (!census && !state) || + (census && census.state === state) + ); + }) + ); + }; + info(`Waiting for snapshot censuses to be of state: ${expected}`); + return waitUntilState(store, predicate); +} + +/** + * Mock out the requestAnimationFrame. + * + * @return {Object} + * @function nextFrame + * Call the last queued function + * @function raf + * The mocked raf function + * @function timesCalled + * How many times the RAF has been called + */ +function createRAFMock() { + let queuedFns = []; + const mock = { timesCalled: 0 }; + + mock.nextFrame = function () { + const thisQueue = queuedFns; + queuedFns = []; + for (let i = 0; i < thisQueue.length; i++) { + thisQueue[i](); + } + }; + + mock.raf = function (fn) { + mock.timesCalled++; + queuedFns.push(fn); + }; + return mock; +} + +/** + * Test to see if two floats are equivalent. + * + * @param {Float} a + * @param {Float} b + * @return {Boolean} + */ +function floatEquality(a, b) { + const EPSILON = 0.00000000001; + const equals = Math.abs(a - b) < EPSILON; + if (!equals) { + info(`${a} not equal to ${b}`); + } + return equals; +} diff --git a/devtools/client/memory/test/chrome/chrome.ini b/devtools/client/memory/test/chrome/chrome.ini new file mode 100644 index 0000000000..7803bcda35 --- /dev/null +++ b/devtools/client/memory/test/chrome/chrome.ini @@ -0,0 +1,20 @@ +[DEFAULT] +support-files = + head.js + +[test_CensusTreeItem_01.html] +[test_DominatorTree_01.html] +[test_DominatorTree_02.html] +[test_DominatorTree_03.html] +[test_DominatorTreeItem_01.html] +[test_Heap_01.html] +[test_Heap_02.html] +[test_Heap_03.html] +[test_Heap_04.html] +[test_Heap_05.html] +[test_List_01.html] +[test_ShortestPaths_01.html] +[test_ShortestPaths_02.html] +[test_SnapshotListItem_01.html] +[test_Toolbar_01.html] +[test_TreeMap_01.html] diff --git a/devtools/client/memory/test/chrome/head.js b/devtools/client/memory/test/chrome/head.js new file mode 100644 index 0000000000..685fa62928 --- /dev/null +++ b/devtools/client/memory/test/chrome/head.js @@ -0,0 +1,354 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var { BrowserLoader } = ChromeUtils.import( + "resource://devtools/shared/loader/browser-loader.js" +); +var { require } = BrowserLoader({ + baseURI: "resource://devtools/client/memory/", + window, +}); +var { Assert } = ChromeUtils.importESModule( + "resource://testing-common/Assert.sys.mjs" +); + +var EXPECTED_DTU_ASSERT_FAILURE_COUNT = 0; + +SimpleTest.registerCleanupFunction(function () { + if ( + DevToolsUtils.assertionFailureCount !== EXPECTED_DTU_ASSERT_FAILURE_COUNT + ) { + ok( + false, + "Should have had the expected number of DevToolsUtils.assert() failures." + + "Expected " + + EXPECTED_DTU_ASSERT_FAILURE_COUNT + + ", got " + + DevToolsUtils.assertionFailureCount + ); + } +}); + +var DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +var { immutableUpdate } = DevToolsUtils; + +var constants = require("resource://devtools/client/memory/constants.js"); +var { + censusDisplays, + diffingState, + labelDisplays, + dominatorTreeState, + snapshotState, + viewState, + censusState, +} = constants; + +const { L10N } = require("resource://devtools/client/memory/utils.js"); + +var models = require("resource://devtools/client/memory/models.js"); + +var Immutable = require("resource://devtools/client/shared/vendor/immutable.js"); +var React = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +var ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js"); +var { createFactory } = React; +var Heap = createFactory( + require("resource://devtools/client/memory/components/Heap.js") +); +var CensusTreeItem = createFactory( + require("resource://devtools/client/memory/components/CensusTreeItem.js") +); +var DominatorTreeComponent = createFactory( + require("resource://devtools/client/memory/components/DominatorTree.js") +); +var DominatorTreeItem = createFactory( + require("resource://devtools/client/memory/components/DominatorTreeItem.js") +); +var ShortestPaths = createFactory( + require("resource://devtools/client/memory/components/ShortestPaths.js") +); +var TreeMap = createFactory( + require("resource://devtools/client/memory/components/TreeMap.js") +); +var SnapshotListItem = createFactory( + require("resource://devtools/client/memory/components/SnapshotListItem.js") +); +var List = createFactory( + require("resource://devtools/client/memory/components/List.js") +); +var Toolbar = createFactory( + require("resource://devtools/client/memory/components/Toolbar.js") +); + +// All tests are asynchronous. +SimpleTest.waitForExplicitFinish(); + +var noop = () => {}; + +var TEST_CENSUS_TREE_ITEM_PROPS = Object.freeze({ + item: Object.freeze({ + bytes: 10, + count: 1, + totalBytes: 10, + totalCount: 1, + name: "foo", + children: [ + Object.freeze({ + bytes: 10, + count: 1, + totalBytes: 10, + totalCount: 1, + name: "bar", + }), + ], + }), + depth: 0, + arrow: ">", + focused: true, + getPercentBytes: () => 50, + getPercentCount: () => 50, + showSign: false, + onViewSourceInDebugger: noop, + inverted: false, +}); + +// Counter for mock DominatorTreeNode ids. +var TEST_NODE_ID_COUNTER = 0; + +/** + * Create a mock DominatorTreeNode for testing, with sane defaults. Override any + * property by providing it on `opts`. Optionally pass child nodes as well. + * + * @param {Object} opts + * @param {Array<DominatorTreeNode>?} children + * + * @returns {DominatorTreeNode} + */ +function makeTestDominatorTreeNode(opts, children) { + const nodeId = TEST_NODE_ID_COUNTER++; + + const node = Object.assign( + { + nodeId, + label: ["other", "SomeType"], + shallowSize: 1, + retainedSize: (children || []).reduce( + (size, c) => size + c.retainedSize, + 1 + ), + parentId: undefined, + children, + moreChildrenAvailable: true, + }, + opts + ); + + if (children && children.length) { + children.map(c => { + c.parentId = node.nodeId; + }); + } + + return node; +} + +var TEST_DOMINATOR_TREE = Object.freeze({ + dominatorTreeId: 666, + root: (function makeTree(depth = 0) { + let children; + if (depth <= 3) { + children = [ + makeTree(depth + 1), + makeTree(depth + 1), + makeTree(depth + 1), + ]; + } + return makeTestDominatorTreeNode({}, children); + })(), + expanded: new Set(), + focused: null, + error: null, + display: labelDisplays.coarseType, + activeFetchRequestCount: null, + state: dominatorTreeState.LOADED, +}); + +var TEST_DOMINATOR_TREE_PROPS = Object.freeze({ + dominatorTree: TEST_DOMINATOR_TREE, + onLoadMoreSiblings: noop, + onViewSourceInDebugger: noop, + onExpand: noop, + onCollapse: noop, +}); + +var TEST_SHORTEST_PATHS_PROPS = Object.freeze({ + graph: Object.freeze({ + nodes: [ + { id: 1, label: ["other", "SomeType"] }, + { id: 2, label: ["other", "SomeType"] }, + { id: 3, label: ["other", "SomeType"] }, + ], + edges: [ + { from: 1, to: 2, name: "1->2" }, + { from: 1, to: 3, name: "1->3" }, + { from: 2, to: 3, name: "2->3" }, + ], + }), +}); + +var TEST_SNAPSHOT = Object.freeze({ + id: 1337, + selected: true, + path: "/fake/path/to/snapshot", + census: Object.freeze({ + report: Object.freeze({ + objects: Object.freeze({ count: 4, bytes: 400 }), + scripts: Object.freeze({ count: 3, bytes: 300 }), + strings: Object.freeze({ count: 2, bytes: 200 }), + other: Object.freeze({ count: 1, bytes: 100 }), + }), + display: Object.freeze({ + displayName: "Test Display", + tooltip: "Test display tooltup", + inverted: false, + breakdown: Object.freeze({ + by: "coarseType", + objects: Object.freeze({ by: "count", count: true, bytes: true }), + scripts: Object.freeze({ by: "count", count: true, bytes: true }), + strings: Object.freeze({ by: "count", count: true, bytes: true }), + other: Object.freeze({ by: "count", count: true, bytes: true }), + }), + }), + state: censusState.SAVED, + inverted: false, + filter: null, + expanded: new Set(), + focused: null, + parentMap: Object.freeze(Object.create(null)), + }), + dominatorTree: TEST_DOMINATOR_TREE, + error: null, + imported: false, + creationTime: 0, + state: snapshotState.READ, +}); + +var TEST_HEAP_PROPS = Object.freeze({ + onSnapshotClick: noop, + onLoadMoreSiblings: noop, + onCensusExpand: noop, + onCensusCollapse: noop, + onDominatorTreeExpand: noop, + onDominatorTreeCollapse: noop, + onCensusFocus: noop, + onDominatorTreeFocus: noop, + onViewSourceInDebugger: noop, + diffing: null, + view: { state: viewState.CENSUS }, + snapshot: TEST_SNAPSHOT, + sizes: Object.freeze({ shortestPathsSize: 0.5 }), + onShortestPathsResize: noop, +}); + +var TEST_TOOLBAR_PROPS = Object.freeze({ + censusDisplays: [ + censusDisplays.coarseType, + censusDisplays.allocationStack, + censusDisplays.invertedAllocationStack, + ], + censusDisplay: censusDisplays.coarseType, + onTakeSnapshotClick: noop, + onImportClick: noop, + onCensusDisplayChange: noop, + onToggleRecordAllocationStacks: noop, + allocations: models.allocations, + onToggleInverted: noop, + inverted: false, + filterString: null, + setFilterString: noop, + diffing: null, + onToggleDiffing: noop, + view: { state: viewState.CENSUS }, + onViewChange: noop, + labelDisplays: [labelDisplays.coarseType, labelDisplays.allocationStack], + labelDisplay: labelDisplays.coarseType, + onLabelDisplayChange: noop, + snapshots: [], +}); + +function makeTestCensusNode() { + return { + name: "Function", + bytes: 100, + totalBytes: 100, + count: 100, + totalCount: 100, + children: [], + }; +} + +var TEST_TREE_MAP_PROPS = Object.freeze({ + treeMap: Object.freeze({ + report: { + name: null, + bytes: 0, + totalBytes: 400, + count: 0, + totalCount: 400, + children: [ + { + name: "objects", + bytes: 0, + totalBytes: 200, + count: 0, + totalCount: 200, + children: [makeTestCensusNode(), makeTestCensusNode()], + }, + { + name: "other", + bytes: 0, + totalBytes: 200, + count: 0, + totalCount: 200, + children: [makeTestCensusNode(), makeTestCensusNode()], + }, + ], + }, + }), +}); + +var TEST_SNAPSHOT_LIST_ITEM_PROPS = Object.freeze({ + onClick: noop, + onSave: noop, + onDelete: noop, + item: TEST_SNAPSHOT, + index: 1234, +}); + +function onNextAnimationFrame(fn) { + return () => requestAnimationFrame(() => requestAnimationFrame(fn)); +} + +/** + * Render the provided ReactElement in the provided HTML container. + * Returns a Promise that will resolve the rendered element as a React + * component. + */ +function renderComponent(element, container) { + return new Promise(resolve => { + const component = ReactDOM.render( + element, + container, + onNextAnimationFrame(() => { + dumpn("Rendered = " + container.innerHTML); + resolve(component); + }) + ); + }); +} + +function dumpn(msg) { + dump(`MEMORY-TEST: ${msg}\n`); +} diff --git a/devtools/client/memory/test/chrome/test_CensusTreeItem_01.html b/devtools/client/memory/test/chrome/test_CensusTreeItem_01.html new file mode 100644 index 0000000000..21d900b668 --- /dev/null +++ b/devtools/client/memory/test/chrome/test_CensusTreeItem_01.html @@ -0,0 +1,65 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that children pointers show up at the correct times. +--> +<head> + <meta charset="utf-8"> + <title>Tree 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> + <!-- Give the container height so that the whole tree is rendered. --> + <div id="container" style="height: 900px;"></div> + + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + const container = document.getElementById("container"); + + await renderComponent(CensusTreeItem(immutableUpdate(TEST_CENSUS_TREE_ITEM_PROPS, { + inverted: true, + depth: 0, + })), container); + + ok(!container.querySelector(".children-pointer"), + "Don't show children pointer for roots when we are inverted"); + + await renderComponent(CensusTreeItem(immutableUpdate(TEST_CENSUS_TREE_ITEM_PROPS, { + inverted: true, + depth: 1, + })), container); + + ok(container.querySelector(".children-pointer"), + "Do show children pointer for non-roots when we are inverted"); + + await renderComponent(CensusTreeItem(immutableUpdate(TEST_CENSUS_TREE_ITEM_PROPS, { + inverted: false, + item: immutableUpdate(TEST_CENSUS_TREE_ITEM_PROPS.item, { children: undefined }), + })), container); + + ok(!container.querySelector(".children-pointer"), + "Don't show children pointer when non-inverted and no children"); + + await renderComponent(CensusTreeItem(immutableUpdate(TEST_CENSUS_TREE_ITEM_PROPS, { + inverted: false, + depth: 0, + item: immutableUpdate(TEST_CENSUS_TREE_ITEM_PROPS.item, { children: [{}] }), + })), container); + + ok(container.querySelector(".children-pointer"), + "Do show children pointer when non-inverted and have children"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_DominatorTreeItem_01.html b/devtools/client/memory/test/chrome/test_DominatorTreeItem_01.html new file mode 100644 index 0000000000..c8cb6b6036 --- /dev/null +++ b/devtools/client/memory/test/chrome/test_DominatorTreeItem_01.html @@ -0,0 +1,46 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that we don't display `JS::ubi::RootList` for the root, and instead show "GC Roots". +--> +<head> + <meta charset="utf-8"> + <title>Tree 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> + <!-- Give the container height so that the whole tree is rendered. --> + <div id="container" style="height: 900px;"></div> + + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + const container = document.getElementById("container"); + + await renderComponent(DominatorTreeItem({ + item: makeTestDominatorTreeNode({ label: ["other", "JS::ubi::RootList"] }), + depth: 0, + arrow: dom.div(), + focused: true, + getPercentSize: _ => 50, + onViewSourceInDebugger: _ => { }, + }), container); + + ok(!container.textContent.includes("JS::ubi::RootList"), + "Should not display `JS::ubi::RootList`"); + ok(container.textContent.includes("GC Roots"), + "Should display `GC Roots` instead"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_DominatorTree_01.html b/devtools/client/memory/test/chrome/test_DominatorTree_01.html new file mode 100644 index 0000000000..0067a0f412 --- /dev/null +++ b/devtools/client/memory/test/chrome/test_DominatorTree_01.html @@ -0,0 +1,52 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that we show a place holder for a subtree we are lazily fetching. +--> +<head> + <meta charset="utf-8"> + <title>Tree 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> + <!-- Give the container height so that the whole tree is rendered. --> + <div id="container" style="height: 900px;"></div> + + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + const container = document.getElementById("container"); + + const root = makeTestDominatorTreeNode({ moreChildrenAvailable: true}); + ok(!root.children); + + const expanded = new Set(); + expanded.add(root.nodeId); + + await renderComponent(DominatorTreeComponent(immutableUpdate( + TEST_DOMINATOR_TREE_PROPS, { + dominatorTree: immutableUpdate(TEST_DOMINATOR_TREE_PROPS.dominatorTree, { + expanded, + root, + state: dominatorTreeState.INCREMENTAL_FETCHING, + activeFetchRequestCount: 1, + }), + })), container); + + ok(container.querySelector(".subtree-fetching"), + "Expanded nodes with more children available, but no children " + + "loaded, should get a placeholder"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_DominatorTree_02.html b/devtools/client/memory/test/chrome/test_DominatorTree_02.html new file mode 100644 index 0000000000..66aad95296 --- /dev/null +++ b/devtools/client/memory/test/chrome/test_DominatorTree_02.html @@ -0,0 +1,52 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that we show a link to load more children when some (but not all) are loaded. +--> +<head> + <meta charset="utf-8"> + <title>Tree 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> + <!-- Give the container height so that the whole tree is rendered. --> + <div id="container" style="height: 900px;"></div> + + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + const container = document.getElementById("container"); + + const root = makeTestDominatorTreeNode({ moreChildrenAvailable: true }, [ + makeTestDominatorTreeNode({}), + ]); + ok(root.children); + ok(root.moreChildrenAvailable); + + const expanded = new Set(); + expanded.add(root.nodeId); + + await renderComponent(DominatorTreeComponent(immutableUpdate( + TEST_DOMINATOR_TREE_PROPS, { + dominatorTree: immutableUpdate(TEST_DOMINATOR_TREE_PROPS.dominatorTree, { + expanded, + root, + }), + })), container); + + ok(container.querySelector(".more-children"), + "Should get a link to load more children"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_DominatorTree_03.html b/devtools/client/memory/test/chrome/test_DominatorTree_03.html new file mode 100644 index 0000000000..6fcfa3d577 --- /dev/null +++ b/devtools/client/memory/test/chrome/test_DominatorTree_03.html @@ -0,0 +1,77 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that expanded DominatorTreeItems are correctly rendered and updated +--> +<head> + <meta charset="utf-8"> + <title>Tree 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> + <!-- Give the container height so that the whole tree is rendered. --> + <div id="container" style="height: 900px;"></div> + + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + const container = document.getElementById("container"); + + // simple tree with one root and one child + const root = makeTestDominatorTreeNode( + { moreChildrenAvailable: false }, + [ + makeTestDominatorTreeNode({ moreChildrenAvailable: false }), + ]); + ok(root.children); + + // root node is expanded + const expanded = new Set(); + expanded.add(root.nodeId); + + await renderComponent( + DominatorTreeComponent(immutableUpdate( + TEST_DOMINATOR_TREE_PROPS, + { + dominatorTree: immutableUpdate( + TEST_DOMINATOR_TREE_PROPS.dominatorTree, + { expanded, root } + ), + })), container); + ok(true, "Dominator tree rendered"); + + is(container.querySelectorAll(".tree-node").length, 2, + "Should display two rows"); + is(container.querySelectorAll(".arrow.open").length, 1, + "Should display one expanded arrow"); + + await renderComponent( + DominatorTreeComponent(immutableUpdate( + TEST_DOMINATOR_TREE_PROPS, + { + dominatorTree: immutableUpdate( + TEST_DOMINATOR_TREE_PROPS.dominatorTree, + { expanded: new Set(), root } + ), + })), container); + + ok(true, "Dominator tree props updated to collapse all nodes"); + + is(container.querySelectorAll(".tree-node").length, 1, + "Should display only one row"); + is(container.querySelectorAll(".arrow.open").length, 0, + "Should display no expanded arrow"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_Heap_01.html b/devtools/client/memory/test/chrome/test_Heap_01.html new file mode 100644 index 0000000000..2facc3d221 --- /dev/null +++ b/devtools/client/memory/test/chrome/test_Heap_01.html @@ -0,0 +1,51 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that rendering a dominator tree error is handled correctly. +--> +<head> + <meta charset="utf-8"> + <title>Tree 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> + <div id="container"></div> + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + ok(React, "Should get React"); + ok(Heap, "Should get Heap"); + + const errorMessage = "Something went wrong!"; + const container = document.getElementById("container"); + + const props = immutableUpdate(TEST_HEAP_PROPS, { + view: { state: viewState.DOMINATOR_TREE }, + snapshot: immutableUpdate(TEST_HEAP_PROPS.snapshot, { + dominatorTree: { + error: new Error(errorMessage), + state: dominatorTreeState.ERROR, + }, + }), + }); + + await renderComponent(Heap(props), container); + + ok(container.querySelector(".error"), "Should render an error view"); + ok(container.textContent.includes(errorMessage), + "Should see our error message"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_Heap_02.html b/devtools/client/memory/test/chrome/test_Heap_02.html new file mode 100644 index 0000000000..343ee66714 --- /dev/null +++ b/devtools/client/memory/test/chrome/test_Heap_02.html @@ -0,0 +1,78 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that the currently selected view is rendered. +--> +<head> + <meta charset="utf-8"> + <title>Tree 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> + <div id="container"></div> + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + ok(React, "Should get React"); + ok(Heap, "Should get Heap"); + + const container = document.getElementById("container"); + + // Dominator tree view. + + await renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, { + view: { state: viewState.DOMINATOR_TREE }, + })), container); + + ok(container.querySelector(`[data-state=${dominatorTreeState.LOADED}]`), + "Should render the dominator tree."); + + // Census view. + + await renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, { + view: { state: viewState.CENSUS }, + })), container); + + ok(container.querySelector(`[data-state=${censusState.SAVED}]`), + "Should render the census."); + + // Diffing view. + + await renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, { + view: { state: viewState.DIFFING }, + snapshot: null, + diffing: { + firstSnapshotId: null, + secondSnapshotId: null, + census: null, + error: null, + state: diffingState.SELECTING, + }, + })), container); + + ok(container.querySelector(`[data-state=${diffingState.SELECTING}]`), + "Should render the diffing."); + + // Initial view. + + await renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, { + snapshot: null, + diffing: null, + })), container); + + ok(container.querySelector("[data-state=initial]"), + "With no snapshot, nor a diffing, should render initial prompt."); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_Heap_03.html b/devtools/client/memory/test/chrome/test_Heap_03.html new file mode 100644 index 0000000000..69f95f4275 --- /dev/null +++ b/devtools/client/memory/test/chrome/test_Heap_03.html @@ -0,0 +1,79 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that we show a throbber while computing and fetching dominator trees, +but not in other dominator tree states. +--> +<head> + <meta charset="utf-8"> + <title>Tree 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> + <div id="container"></div> + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + const container = document.getElementById("container"); + + for (const state of [dominatorTreeState.COMPUTING, dominatorTreeState.FETCHING]) { + await renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, { + view: { state: viewState.DOMINATOR_TREE }, + snapshot: immutableUpdate(TEST_HEAP_PROPS.snapshot, { + dominatorTree: immutableUpdate(TEST_HEAP_PROPS.snapshot.dominatorTree, { + state, + root: null, + dominatorTreeId: state === dominatorTreeState.FETCHING ? 1 : null, + }), + }), + })), container); + + ok(container.querySelector(".devtools-throbber"), + `Should show a throbber for state = ${state}`); + } + + for ( + const state of [ + dominatorTreeState.LOADED, dominatorTreeState.INCREMENTAL_FETCHING, + ]) { + await renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, { + view: { state: viewState.DOMINATOR_TREE }, + snapshot: immutableUpdate(TEST_HEAP_PROPS.snapshot, { + dominatorTree: immutableUpdate(TEST_HEAP_PROPS.snapshot.dominatorTree, { + state, + activeFetchRequestCount: + state === dominatorTreeState.INCREMENTAL_FETCHING ? 1 : undefined, + }), + }), + })), container); + + ok(!container.querySelector(".devtools-throbber"), + `Should not show a throbber for state = ${state}`); + } + + await renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, { + view: { state: viewState.DOMINATOR_TREE }, + snapshot: immutableUpdate(TEST_HEAP_PROPS.snapshot, { + dominatorTree: { + state: dominatorTreeState.ERROR, + error: new Error("example error for testing"), + }, + }), + })), container); + + ok(!container.querySelector(".devtools-throbber"), + `Should not show a throbber for ERROR state`); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_Heap_04.html b/devtools/client/memory/test/chrome/test_Heap_04.html new file mode 100644 index 0000000000..76de5e4826 --- /dev/null +++ b/devtools/client/memory/test/chrome/test_Heap_04.html @@ -0,0 +1,122 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that we show the "hey you're not recording allocation stacks" message at the appropriate times. +--> +<head> + <meta charset="utf-8"> + <title>Tree 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> + <div id="container"></div> + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + const container = document.getElementById("container"); + + await renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, { + snapshot: immutableUpdate(TEST_HEAP_PROPS.snapshot, { + census: immutableUpdate(TEST_HEAP_PROPS.snapshot.census, { + report: { + bytes: 1, + totalBytes: 1, + count: 1, + totalCount: 1, + id: 1, + parent: undefined, + children: [ + { + name: "noStack", + bytes: 1, + totalBytes: 1, + count: 1, + totalCount: 1, + children: undefined, + id: 3, + parent: 1, + }, + ], + }, + display: censusDisplays.allocationStack, + }), + }), + })), container); + + ok(container.querySelector(".no-allocation-stacks"), + "When there are no allocation stacks, we should show the message"); + + await renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, { + snapshot: immutableUpdate(TEST_HEAP_PROPS.snapshot, { + census: immutableUpdate(TEST_HEAP_PROPS.snapshot.census, { + report: { + bytes: 1, + totalBytes: 1, + count: 1, + totalCount: 1, + id: 1, + parent: undefined, + children: [ + { + name: Cu.getJSTestingFunctions().saveStack(), + bytes: 1, + totalBytes: 1, + count: 1, + totalCount: 1, + children: undefined, + id: 2, + parent: 1, + }, + { + name: "noStack", + bytes: 1, + totalBytes: 1, + count: 1, + totalCount: 1, + children: undefined, + id: 3, + parent: 1, + }, + ], + }, + display: censusDisplays.allocationStack, + }), + }), + })), container); + + ok(!container.querySelector(".no-allocation-stacks"), + "When there are allocation stacks, we should not show the message"); + + await renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, { + snapshot: immutableUpdate(TEST_HEAP_PROPS.snapshot, { + census: immutableUpdate(TEST_HEAP_PROPS.snapshot.census, { + report: { + bytes: 1, + totalBytes: 1, + count: 1, + totalCount: 1, + id: 1, + parent: undefined, + children: undefined, + }, + display: censusDisplays.allocationStack, + }), + }), + })), container); + + ok(!container.querySelector(".no-allocation-stacks"), + "When there isn't census data, we should not show the message"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_Heap_05.html b/devtools/client/memory/test/chrome/test_Heap_05.html new file mode 100644 index 0000000000..bd3fc393cc --- /dev/null +++ b/devtools/client/memory/test/chrome/test_Heap_05.html @@ -0,0 +1,133 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that we show a message when the census results are empty. +--> +<head> + <meta charset="utf-8"> + <title>Tree 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> + <div id="container"></div> + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + const container = document.getElementById("container"); + + await renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, { + snapshot: immutableUpdate(TEST_HEAP_PROPS.snapshot, { + census: immutableUpdate(TEST_HEAP_PROPS.snapshot.census, { + report: { + bytes: 1, + totalBytes: 1, + count: 1, + totalCount: 1, + id: 1, + parent: undefined, + children: [ + { + name: Cu.getJSTestingFunctions().saveStack(), + bytes: 1, + totalBytes: 1, + count: 1, + totalCount: 1, + children: undefined, + id: 2, + parent: 1, + }, + { + name: "noStack", + bytes: 1, + totalBytes: 1, + count: 1, + totalCount: 1, + children: undefined, + id: 3, + parent: 1, + }, + ], + }, + display: censusDisplays.allocationStack, + }), + }), + })), container); + + ok(!container.querySelector(".empty"), + "When the report is not empty, we should not show the empty message"); + + // Empty Census Report + + const emptyCensus = { + report: { + bytes: 0, + totalBytes: 0, + count: 0, + totalCount: 0, + id: 1, + parent: undefined, + children: undefined, + }, + parentMap: Object.create(null), + display: censusDisplays.allocationStack, + filter: null, + expanded: new Immutable.Set(), + focused: null, + state: censusState.SAVED, + }; + + await renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, { + snapshot: immutableUpdate(TEST_HEAP_PROPS.snapshot, { + census: immutableUpdate(TEST_HEAP_PROPS.snapshot.census, emptyCensus), + }), + })), container); + + ok(container.querySelector(".empty"), + "When the report is empty in census view, we show the empty message"); + ok(container.textContent.includes(L10N.getStr("heapview.empty"))); + + // Empty Diffing Report + + await renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, { + view: { state: viewState.DIFFING }, + diffing: { + firstSnapshotId: 1, + secondSnapshotId: 2, + census: emptyCensus, + state: diffingState.TOOK_DIFF, + }, + snapshot: null, + })), container); + + ok(container.querySelector(".empty"), + "When the report is empty in diffing view, the empty message is shown"); + ok(container.textContent.includes(L10N.getStr("heapview.no-difference"))); + + // Empty Filtered Census + + await renderComponent(Heap(immutableUpdate(TEST_HEAP_PROPS, { + snapshot: immutableUpdate(TEST_HEAP_PROPS.snapshot, { + census: immutableUpdate( + TEST_HEAP_PROPS.snapshot.census, immutableUpdate(emptyCensus, { + filter: "zzzz", + })), + }), + })), container); + + ok(container.querySelector(".empty"), + "When the report is empty in census view w/ filter, we show the empty message"); + ok(container.textContent.includes(L10N.getStr("heapview.none-match"))); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_List_01.html b/devtools/client/memory/test/chrome/test_List_01.html new file mode 100644 index 0000000000..4ffab49620 --- /dev/null +++ b/devtools/client/memory/test/chrome/test_List_01.html @@ -0,0 +1,73 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test to verify the delete button calls the onDelete handler for an item +--> +<head> + <meta charset="utf-8"> + <title>Tree 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> + <div id="container"></div> + + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + const container = document.getElementById("container"); + + const deletedSnapshots = []; + + const snapshots = [ TEST_SNAPSHOT, TEST_SNAPSHOT, TEST_SNAPSHOT ] + .map((snapshot, index) => immutableUpdate(snapshot, { + index: snapshot.index + index, + })); + + await renderComponent( + List({ + itemComponent: SnapshotListItem, + onClick: noop, + onDelete: (item) => deletedSnapshots.push(item), + items: snapshots, + }), + container + ); + + const deleteButtons = container.querySelectorAll(".delete"); + + is(container.querySelectorAll(".snapshot-list-item").length, 3, + "There are 3 list items\n"); + is(deletedSnapshots.length, 0, + "Not snapshots have been deleted\n"); + + deleteButtons[1].click(); + + is(deletedSnapshots.length, 1, "One snapshot was deleted\n"); + is(deletedSnapshots[0], snapshots[1], + "Deleted snapshot was added to the deleted list\n"); + + deleteButtons[0].click(); + + is(deletedSnapshots.length, 2, "Two snapshots were deleted\n"); + is(deletedSnapshots[1], snapshots[0], + "Deleted snapshot was added to the deleted list\n"); + + deleteButtons[2].click(); + + is(deletedSnapshots.length, 3, "Three snapshots were deleted\n"); + is(deletedSnapshots[2], snapshots[2], + "Deleted snapshot was added to the deleted list\n"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_ShortestPaths_01.html b/devtools/client/memory/test/chrome/test_ShortestPaths_01.html new file mode 100644 index 0000000000..29905bfaca --- /dev/null +++ b/devtools/client/memory/test/chrome/test_ShortestPaths_01.html @@ -0,0 +1,112 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that the ShortestPaths component properly renders a graph of the merged shortest paths. +--> +<head> + <meta charset="utf-8"> + <title>Tree 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"> + + <script type="application/javascript" + src="chrome://global/content/third_party/d3/d3.js"> + </script> + <script type="application/javascript" + src="chrome://devtools/content/shared/vendor/dagre-d3.js"> + </script> +</head> +<body> + <!-- Give the container height so that the whole tree is rendered. --> + <div id="container" style="height: 900px;"></div> + + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + const container = document.getElementById("container"); + + await renderComponent(ShortestPaths(TEST_SHORTEST_PATHS_PROPS), container); + + let found1 = false; + let found2 = false; + let found3 = false; + + let found1to2 = false; + let found1to3 = false; + let found2to3 = false; + + const tspans = [...container.querySelectorAll("tspan")]; + for (const el of tspans) { + const text = el.textContent.trim(); + dumpn("tspan's text = " + text); + + switch (text) { + // Nodes + + case "other › SomeType @ 0x1": { + ok(!found1, "Should only find node 1 once"); + found1 = true; + break; + } + + case "other › SomeType @ 0x2": { + ok(!found2, "Should only find node 2 once"); + found2 = true; + break; + } + + case "other › SomeType @ 0x3": { + ok(!found3, "Should only find node 3 once"); + found3 = true; + break; + } + + // Edges + + case "1->2": { + ok(!found1to2, "Should only find edge 1->2 once"); + found1to2 = true; + break; + } + + case "1->3": { + ok(!found1to3, "Should only find edge 1->3 once"); + found1to3 = true; + break; + } + + case "2->3": { + ok(!found2to3, "Should only find edge 2->3 once"); + found2to3 = true; + break; + } + + // Unexpected + + default: { + ok(false, `Unexpected tspan: ${text}`); + break; + } + } + } + + ok(found1, "Should have rendered node 1"); + ok(found2, "Should have rendered node 2"); + ok(found3, "Should have rendered node 3"); + + ok(found1to2, "Should have rendered edge 1->2"); + ok(found1to3, "Should have rendered edge 1->3"); + ok(found2to3, "Should have rendered edge 2->3"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_ShortestPaths_02.html b/devtools/client/memory/test/chrome/test_ShortestPaths_02.html new file mode 100644 index 0000000000..cbf0370d22 --- /dev/null +++ b/devtools/client/memory/test/chrome/test_ShortestPaths_02.html @@ -0,0 +1,46 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that the ShortestPaths component renders a suggestion to select a node when there is no graph. +--> +<head> + <meta charset="utf-8"> + <title>Tree 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"> + + <script type="application/javascript" + src="chrome://global/content/third_party/d3/d3.js"> + </script> + <script type="application/javascript" + src="chrome://devtools/content/shared/vendor/dagre-d3.js"> + </script> +</head> +<body> + <!-- Give the container height so that the whole tree is rendered. --> + <div id="container" style="height: 900px;"></div> + + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + const container = document.getElementById("container"); + + await renderComponent(ShortestPaths(immutableUpdate(TEST_SHORTEST_PATHS_PROPS, + { graph: null })), + container); + + ok(container.textContent.includes(L10N.getStr("shortest-paths.select-node")), + "The node selection prompt is displayed"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_SnapshotListItem_01.html b/devtools/client/memory/test/chrome/test_SnapshotListItem_01.html new file mode 100644 index 0000000000..20fc137479 --- /dev/null +++ b/devtools/client/memory/test/chrome/test_SnapshotListItem_01.html @@ -0,0 +1,53 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test to verify that the delete button only shows up for a snapshot when it has a +path. +--> +<head> + <meta charset="utf-8"> + <title>Tree 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> + <div id="container"></div> + + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + const container = document.getElementById("container"); + + await renderComponent( + SnapshotListItem(TEST_SNAPSHOT_LIST_ITEM_PROPS), + container + ); + + ok(container.querySelector(".delete"), + "Should have delete button when there is a path"); + + const pathlessProps = immutableUpdate( + TEST_SNAPSHOT_LIST_ITEM_PROPS, + {item: immutableUpdate(TEST_SNAPSHOT, {path: null})} + ); + + await renderComponent( + SnapshotListItem(pathlessProps), + container + ); + + ok(!container.querySelector(".delete"), + "No delete button should be found if there is no path\n"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_Toolbar_01.html b/devtools/client/memory/test/chrome/test_Toolbar_01.html new file mode 100644 index 0000000000..3bf934c77c --- /dev/null +++ b/devtools/client/memory/test/chrome/test_Toolbar_01.html @@ -0,0 +1,48 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that the Toolbar component shows the view switcher only at the appropriate times. +--> +<head> + <meta charset="utf-8"> + <title>Tree 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> + <div id="container"></div> + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + const container = document.getElementById("container"); + + // Census and dominator tree views. + + for (const view of [viewState.CENSUS, viewState.DOMINATOR_TREE]) { + await renderComponent(Toolbar(immutableUpdate(TEST_TOOLBAR_PROPS, { + view: { state: view }, + })), container); + + ok(container.querySelector("#select-view"), + `The view selector is shown in view = ${view}`); + } + + await renderComponent(Toolbar(immutableUpdate(TEST_TOOLBAR_PROPS, { + view: { state: viewState.DIFFING }, + })), container); + + ok(!container.querySelector("#select-view"), + "The view selector is NOT shown in the DIFFING view"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/chrome/test_TreeMap_01.html b/devtools/client/memory/test/chrome/test_TreeMap_01.html new file mode 100644 index 0000000000..7b8b98b9a7 --- /dev/null +++ b/devtools/client/memory/test/chrome/test_TreeMap_01.html @@ -0,0 +1,44 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that the Tree Map correctly renders onto 2 managed canvases. +--> +<head> + <meta charset="utf-8"> + <title>Tree 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"> + + <script type="application/javascript" + src="chrome://global/content/third_party/d3/d3.js"> + </script> +</head> +<body> + <!-- Give the container height so that the whole tree is rendered. --> + <div id="container" style="height: 900px;"></div> + + <pre id="test"> + <script src="head.js" type="application/javascript"></script> + <script type="application/javascript"> + "use strict"; + window.onload = async function() { + try { + const container = document.getElementById("container"); + + await renderComponent(TreeMap(TEST_TREE_MAP_PROPS), container); + + const treeMapContainer = container.querySelector(".tree-map-container"); + ok(treeMapContainer, "Component creates a container"); + + const canvases = treeMapContainer.querySelectorAll("canvas"); + is(canvases.length, 2, "Creates 2 canvases"); + } catch (e) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e)); + } finally { + SimpleTest.finish(); + } + }; + </script> + </pre> +</body> +</html> diff --git a/devtools/client/memory/test/xpcshell/.eslintrc.js b/devtools/client/memory/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..f1618e83c2 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/.eslintrc.js @@ -0,0 +1,14 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + extends: "../../../../.eslintrc.xpcshell.js", + rules: { + "no-unused-vars": [ + "error", + { + vars: "local", + }, + ], + }, +}; diff --git a/devtools/client/memory/test/xpcshell/head.js b/devtools/client/memory/test/xpcshell/head.js new file mode 100644 index 0000000000..84369bd49d --- /dev/null +++ b/devtools/client/memory/test/xpcshell/head.js @@ -0,0 +1,187 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// via xpcshell.ini +/* import-globals-from ../../../shared/test/shared-head.js */ + +Services.prefs.setBoolPref("devtools.testing", true); +Services.prefs.setBoolPref("devtools.debugger.log", true); +registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.testing"); + Services.prefs.clearUserPref("devtools.debugger.log"); +}); + +var { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); +var { expectState } = require("resource://devtools/server/actors/common.js"); +var HeapSnapshotFileUtils = require("resource://devtools/shared/heapsnapshot/HeapSnapshotFileUtils.js"); +var HeapAnalysesClient = require("resource://devtools/shared/heapsnapshot/HeapAnalysesClient.js"); +var { addDebuggerToGlobal } = ChromeUtils.importESModule( + "resource://gre/modules/jsdebugger.sys.mjs" +); +var Store = require("resource://devtools/client/memory/store.js"); +var { L10N } = require("resource://devtools/client/memory/utils.js"); +var SYSTEM_PRINCIPAL = Cc["@mozilla.org/systemprincipal;1"].createInstance( + Ci.nsIPrincipal +); + +var EXPECTED_DTU_ASSERT_FAILURE_COUNT = 0; + +registerCleanupFunction(function () { + equal( + DevToolsUtils.assertionFailureCount, + EXPECTED_DTU_ASSERT_FAILURE_COUNT, + "Should have had the expected number of DevToolsUtils.assert() failures." + ); +}); + +function dumpn(msg) { + dump(`MEMORY-TEST: ${msg}\n`); +} + +function initDebugger() { + const global = new Cu.Sandbox(SYSTEM_PRINCIPAL, { freshZone: true }); + addDebuggerToGlobal(global); + return new global.Debugger(); +} + +function StubbedMemoryFront() { + this.state = "detached"; + this.dbg = initDebugger(); +} + +StubbedMemoryFront.prototype.attach = async function () { + this.state = "attached"; +}; + +StubbedMemoryFront.prototype.detach = async function () { + this.state = "detached"; +}; + +StubbedMemoryFront.prototype.saveHeapSnapshot = expectState( + "attached", + async function () { + return ChromeUtils.saveHeapSnapshot({ runtime: true }); + }, + "saveHeapSnapshot" +); + +StubbedMemoryFront.prototype.startRecordingAllocations = expectState( + "attached", + async function () {} +); + +StubbedMemoryFront.prototype.stopRecordingAllocations = expectState( + "attached", + async function () {} +); + +function waitUntilSnapshotState(store, expected) { + const predicate = () => { + const snapshots = store.getState().snapshots; + info(snapshots.map(x => x.state)); + return ( + snapshots.length === expected.length && + expected.every( + (state, i) => state === "*" || snapshots[i].state === state + ) + ); + }; + info(`Waiting for snapshots to be of state: ${expected}`); + return waitUntilState(store, predicate); +} + +function findReportLeafIndex(node, name = null) { + if (node.reportLeafIndex && (!name || node.name === name)) { + return node.reportLeafIndex; + } + + if (node.children) { + for (const child of node.children) { + const found = findReportLeafIndex(child); + if (found) { + return found; + } + } + } + + return null; +} + +function waitUntilCensusState(store, getCensus, expected) { + const predicate = () => { + const snapshots = store.getState().snapshots; + + info( + "Current census state:" + + snapshots.map(x => (getCensus(x) ? getCensus(x).state : null)) + ); + + return ( + snapshots.length === expected.length && + expected.every((state, i) => { + const census = getCensus(snapshots[i]); + return ( + state === "*" || + (!census && !state) || + (census && census.state === state) + ); + }) + ); + }; + info(`Waiting for snapshots' censuses to be of state: ${expected}`); + return waitUntilState(store, predicate); +} + +async function createTempFile() { + const file = FileUtils.getFile("TmpD", ["tmp.fxsnapshot"]); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + const destPath = file.path; + const stat = await IOUtils.stat(destPath); + ok(stat.size === 0, "new file is 0 bytes at start"); + return destPath; +} + +// This is a copy of the same method from shared-head.js as +// xpcshell test aren't using shared-head.js +/** + * Wait for a specific action type to be dispatched. + * + * If the action is async and defines a `status` property, this helper will wait + * for the status to reach either "error" or "done". + * + * @param {Object} store + * Redux store where the action should be dispatched. + * @param {String} actionType + * The actionType to wait for. + * @param {Number} repeat + * Optional, number of time the action is expected to be dispatched. + * Defaults to 1 + * @return {Promise} + */ +function waitForDispatch(store, actionType, repeat = 1) { + let count = 0; + return new Promise(resolve => { + store.dispatch({ + type: "@@service/waitUntil", + predicate: action => { + const isDone = + !action.status || + action.status === "done" || + action.status === "error"; + + if (action.type === actionType && isDone && ++count == repeat) { + return true; + } + + return false; + }, + run: (dispatch, getState, action) => { + resolve(action); + }, + }); + }); +} diff --git a/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_01.js b/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_01.js new file mode 100644 index 0000000000..9511ba413f --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_01.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test clearSnapshots deletes snapshots with READ censuses + +const { + takeSnapshotAndCensus, + clearSnapshots, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { actions } = require("resource://devtools/client/memory/constants.js"); +const { + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.treeMap, [treeMapState.SAVED]); + ok(true, "snapshot created"); + + ok(true, "dispatch clearSnapshots action"); + const deleteEvents = Promise.all([ + waitForDispatch(store, actions.DELETE_SNAPSHOTS_START), + waitForDispatch(store, actions.DELETE_SNAPSHOTS_END), + ]); + dispatch(clearSnapshots(heapWorker)); + await deleteEvents; + ok(true, "received delete snapshots events"); + + equal(getState().snapshots.length, 0, "no snapshot remaining"); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_02.js b/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_02.js new file mode 100644 index 0000000000..09f1fa6b54 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_02.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test clearSnapshots preserves snapshots with state != READ or ERROR + +const { + takeSnapshotAndCensus, + clearSnapshots, + takeSnapshot, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + snapshotState: states, + treeMapState, + actions, +} = require("resource://devtools/client/memory/constants.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + ok(true, "create a snapshot with a census in SAVED state"); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + ok(true, "create a snapshot in SAVED state"); + dispatch(takeSnapshot(front)); + await waitUntilSnapshotState(store, [states.SAVED, states.SAVED]); + await waitUntilCensusState(store, snapshot => snapshot.treeMap, [ + treeMapState.SAVED, + null, + ]); + ok(true, "snapshots created with expected states"); + + ok(true, "dispatch clearSnapshots action"); + const deleteEvents = Promise.all([ + waitForDispatch(store, actions.DELETE_SNAPSHOTS_START), + waitForDispatch(store, actions.DELETE_SNAPSHOTS_END), + ]); + dispatch(clearSnapshots(heapWorker)); + await deleteEvents; + ok(true, "received delete snapshots events"); + + equal(getState().snapshots.length, 1, "one snapshot remaining"); + const remainingSnapshot = getState().snapshots[0]; + equal( + remainingSnapshot.treeMap, + undefined, + "remaining snapshot doesn't have a treeMap property" + ); + equal( + remainingSnapshot.census, + undefined, + "remaining snapshot doesn't have a census property" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_03.js b/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_03.js new file mode 100644 index 0000000000..b920c5264b --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_03.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test clearSnapshots deletes snapshots with state ERROR + +const { + takeSnapshotAndCensus, + clearSnapshots, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + snapshotState: states, + treeMapState, + actions, +} = require("resource://devtools/client/memory/constants.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + ok(true, "create a snapshot with a treeMap"); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilSnapshotState(store, [states.SAVED]); + ok(true, "snapshot created with a SAVED state"); + await waitUntilCensusState(store, snapshot => snapshot.treeMap, [ + treeMapState.SAVED, + ]); + ok(true, "treeMap created with a SAVED state"); + + ok(true, "set snapshot state to error"); + const id = getState().snapshots[0].id; + dispatch({ type: actions.SNAPSHOT_ERROR, id, error: new Error("_") }); + await waitUntilSnapshotState(store, [states.ERROR]); + ok(true, "snapshot set to error state"); + + ok(true, "dispatch clearSnapshots action"); + const deleteEvents = Promise.all([ + waitForDispatch(store, actions.DELETE_SNAPSHOTS_START), + waitForDispatch(store, actions.DELETE_SNAPSHOTS_END), + ]); + dispatch(clearSnapshots(heapWorker)); + await deleteEvents; + ok(true, "received delete snapshots events"); + equal(getState().snapshots.length, 0, "error snapshot deleted"); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_04.js b/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_04.js new file mode 100644 index 0000000000..723029c90b --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_04.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test clearSnapshots deletes several snapshots + +const { + takeSnapshotAndCensus, + clearSnapshots, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + snapshotState: states, + actions, + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + ok(true, "create 3 snapshots with a saved census"); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, snapshot => snapshot.treeMap, [ + treeMapState.SAVED, + treeMapState.SAVED, + treeMapState.SAVED, + ]); + ok(true, "snapshots created with a saved census"); + + ok(true, "set first snapshot state to error"); + const id = getState().snapshots[0].id; + dispatch({ type: actions.SNAPSHOT_ERROR, id, error: new Error("_") }); + await waitUntilSnapshotState(store, [states.ERROR, states.READ, states.READ]); + ok(true, "first snapshot set to error state"); + + ok(true, "dispatch clearSnapshots action"); + const deleteEvents = Promise.all([ + waitForDispatch(store, actions.DELETE_SNAPSHOTS_START), + waitForDispatch(store, actions.DELETE_SNAPSHOTS_END), + ]); + dispatch(clearSnapshots(heapWorker)); + await deleteEvents; + ok(true, "received delete snapshots events"); + + equal(getState().snapshots.length, 0, "no snapshot remaining"); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_05.js b/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_05.js new file mode 100644 index 0000000000..485e0f0513 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_05.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test clearSnapshots deletes several snapshots + +const { + takeSnapshotAndCensus, + clearSnapshots, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + actions, + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + ok(true, "create 2 snapshots with a saved census"); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + ok(true, "snapshots created with a saved census"); + await waitUntilCensusState(store, snapshot => snapshot.treeMap, [ + treeMapState.SAVED, + treeMapState.SAVED, + ]); + + const errorHeapWorker = { + deleteHeapSnapshot() { + return Promise.reject("_"); + }, + }; + + ok(true, "dispatch clearSnapshots action"); + const deleteEvents = Promise.all([ + waitForDispatch(store, actions.DELETE_SNAPSHOTS_START), + waitForDispatch(store, actions.DELETE_SNAPSHOTS_END), + waitForDispatch(store, actions.SNAPSHOT_ERROR), + waitForDispatch(store, actions.SNAPSHOT_ERROR), + ]); + dispatch(clearSnapshots(errorHeapWorker)); + await deleteEvents; + ok(true, "received delete snapshots and snapshot error events"); + equal(getState().snapshots.length, 0, "no snapshot remaining"); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_06.js b/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_06.js new file mode 100644 index 0000000000..d7f1426827 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-clear-snapshots_06.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that clearSnapshots disables diffing when deleting snapshots + +const { + takeSnapshotAndCensus, + clearSnapshots, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + actions, + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); +const { + toggleDiffing, + selectSnapshotForDiffingAndRefresh, +} = require("resource://devtools/client/memory/actions/diffing.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + ok(true, "create 2 snapshots with a saved census"); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, snapshot => snapshot.treeMap, [ + treeMapState.SAVED, + treeMapState.SAVED, + ]); + ok(true, "snapshots created with a saved census"); + + dispatch(toggleDiffing()); + dispatch( + selectSnapshotForDiffingAndRefresh(heapWorker, getState().snapshots[0]) + ); + dispatch( + selectSnapshotForDiffingAndRefresh(heapWorker, getState().snapshots[1]) + ); + + ok(getState().diffing, "We should be in diffing view"); + + await waitForDispatch(store, actions.TAKE_CENSUS_DIFF_END); + ok(true, "Received TAKE_CENSUS_DIFF_END action"); + + ok(true, "Dispatch clearSnapshots action"); + const deleteEvents = Promise.all([ + waitForDispatch(store, actions.DELETE_SNAPSHOTS_START), + waitForDispatch(store, actions.DELETE_SNAPSHOTS_END), + ]); + dispatch(clearSnapshots(heapWorker)); + await deleteEvents; + ok(true, "received delete snapshots events"); + + ok(getState().snapshots.length === 0, "Snapshots array should be empty"); + ok(!getState().diffing, "We should no longer be diffing"); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-export-snapshot.js b/devtools/client/memory/test/xpcshell/test_action-export-snapshot.js new file mode 100644 index 0000000000..8dc2bc6bf1 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-export-snapshot.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test exporting a snapshot to a user specified location on disk. + +const { + exportSnapshot, +} = require("resource://devtools/client/memory/actions/io.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + actions, + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + const destPath = await createTempFile(); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, snapshot => snapshot.treeMap, [ + treeMapState.SAVED, + ]); + + const exportEvents = Promise.all([ + waitForDispatch(store, actions.EXPORT_SNAPSHOT_START), + waitForDispatch(store, actions.EXPORT_SNAPSHOT_END), + ]); + dispatch(exportSnapshot(getState().snapshots[0], destPath)); + await exportEvents; + + const stat = await IOUtils.stat(destPath); + info(stat.size); + ok(stat.size > 0, "destination file is more than 0 bytes"); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-filter-01.js b/devtools/client/memory/test/xpcshell/test_action-filter-01.js new file mode 100644 index 0000000000..b16e66a62c --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-filter-01.js @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test setting the filter string. + +const { + setFilterString, +} = require("resource://devtools/client/memory/actions/filter.js"); + +add_task(async function () { + const store = Store(); + const { getState, dispatch } = store; + + equal(getState().filter, null, "no filter by default"); + + dispatch(setFilterString("my filter")); + equal(getState().filter, "my filter", "now we have the expected filter"); + + dispatch(setFilterString("")); + equal(getState().filter, null, "no filter again"); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-filter-02.js b/devtools/client/memory/test/xpcshell/test_action-filter-02.js new file mode 100644 index 0000000000..828b976aa3 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-filter-02.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that changing filter state properly refreshes the selected census. + +const { + viewState, + censusState, +} = require("resource://devtools/client/memory/constants.js"); +const { + setFilterStringAndRefresh, +} = require("resource://devtools/client/memory/actions/filter.js"); +const { + takeSnapshotAndCensus, + selectSnapshotAndRefresh, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.CENSUS)); + + equal(getState().filter, null, "no filter by default"); + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVED, + censusState.SAVED, + censusState.SAVED, + ]); + ok(true, "saved 3 snapshots and took a census of each of them"); + + dispatch(setFilterStringAndRefresh("str", heapWorker)); + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVED, + censusState.SAVED, + censusState.SAVING, + ]); + ok( + true, + "setting filter string should recompute the selected snapshot's census" + ); + + equal(getState().filter, "str", "now inverted"); + + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVED, + censusState.SAVED, + censusState.SAVED, + ]); + + equal(getState().snapshots[0].census.filter, null); + equal(getState().snapshots[1].census.filter, null); + equal(getState().snapshots[2].census.filter, "str"); + + dispatch(selectSnapshotAndRefresh(heapWorker, getState().snapshots[1].id)); + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVED, + censusState.SAVING, + censusState.SAVED, + ]); + ok(true, "selecting non-inverted census should trigger a recompute"); + + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVED, + censusState.SAVED, + censusState.SAVED, + ]); + + equal(getState().snapshots[0].census.filter, null); + equal(getState().snapshots[1].census.filter, "str"); + equal(getState().snapshots[2].census.filter, "str"); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-filter-03.js b/devtools/client/memory/test/xpcshell/test_action-filter-03.js new file mode 100644 index 0000000000..2fcc3e9156 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-filter-03.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that changing filter state in the middle of taking a snapshot results in +// the properly fitered census. + +const { + snapshotState: states, + censusState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + setFilterString, + setFilterStringAndRefresh, +} = require("resource://devtools/client/memory/actions/filter.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.CENSUS)); + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilSnapshotState(store, [states.SAVING]); + + dispatch(setFilterString("str")); + + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVED, + ]); + equal(getState().filter, "str", "should want filtered trees"); + equal( + getState().snapshots[0].census.filter, + "str", + "snapshot-we-were-in-the-middle-of-saving's census should be filtered" + ); + + dispatch(setFilterStringAndRefresh("", heapWorker)); + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVING, + ]); + ok(true, "changing filter string retriggers census"); + ok(!getState().filter, "no longer filtering"); + + dispatch(setFilterString("obj")); + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVED, + ]); + equal(getState().filter, "obj", "filtering for obj now"); + equal( + getState().snapshots[0].census.filter, + "obj", + "census-we-were-in-the-middle-of-recomputing should be filtered again" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-import-snapshot-and-census.js b/devtools/client/memory/test/xpcshell/test_action-import-snapshot-and-census.js new file mode 100644 index 0000000000..3e97f7c45f --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-import-snapshot-and-census.js @@ -0,0 +1,135 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the task creator `importSnapshotAndCensus()` for the whole flow of + * importing a snapshot, and its sub-actions. + */ + +const { + actions, + snapshotState: states, + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); +const { + exportSnapshot, + importSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/io.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { subscribe, dispatch, getState } = store; + + const destPath = await createTempFile(); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.treeMap, [treeMapState.SAVED]); + + const exportEvents = Promise.all([ + waitForDispatch(store, actions.EXPORT_SNAPSHOT_START), + waitForDispatch(store, actions.EXPORT_SNAPSHOT_END), + ]); + dispatch(exportSnapshot(getState().snapshots[0], destPath)); + await exportEvents; + + // Now import our freshly exported snapshot + let snapshotI = 0; + let censusI = 0; + const snapshotStates = ["IMPORTING", "READING", "READ"]; + const censusStates = ["SAVING", "SAVED"]; + const expectStates = () => { + const snapshot = getState().snapshots[1]; + if (!snapshot) { + return; + } + if (snapshotI < snapshotStates.length) { + const isCorrectState = + snapshot.state === states[snapshotStates[snapshotI]]; + if (isCorrectState) { + ok(true, `Found expected snapshot state ${snapshotStates[snapshotI]}`); + snapshotI++; + } + } + if (snapshot.treeMap && censusI < censusStates.length) { + if (snapshot.treeMap.state === treeMapState[censusStates[censusI]]) { + ok(true, `Found expected census state ${censusStates[censusI]}`); + censusI++; + } + } + }; + + const unsubscribe = subscribe(expectStates); + dispatch(importSnapshotAndCensus(heapWorker, destPath)); + + await waitUntilState(store, () => { + return ( + snapshotI === snapshotStates.length && censusI === censusStates.length + ); + }); + unsubscribe(); + equal( + snapshotI, + snapshotStates.length, + "importSnapshotAndCensus() produces the correct sequence of states in a snapshot" + ); + equal( + getState().snapshots[1].state, + states.READ, + "imported snapshot is in READ state" + ); + equal( + censusI, + censusStates.length, + "importSnapshotAndCensus() produces the correct sequence of states in a census" + ); + equal( + getState().snapshots[1].treeMap.state, + treeMapState.SAVED, + "imported snapshot is in READ state" + ); + ok(getState().snapshots[1].selected, "imported snapshot is selected"); + + // Check snapshot data + const snapshot1 = getState().snapshots[0]; + const snapshot2 = getState().snapshots[1]; + + equal( + snapshot1.treeMap.display, + snapshot2.treeMap.display, + "imported snapshot has correct display" + ); + + // Clone the census data so we can destructively remove the ID/parents to compare + // equal census data + const census1 = stripUnique( + JSON.parse(JSON.stringify(snapshot1.treeMap.report)) + ); + const census2 = stripUnique( + JSON.parse(JSON.stringify(snapshot2.treeMap.report)) + ); + + equal( + JSON.stringify(census1), + JSON.stringify(census2), + "Imported snapshot has correct census" + ); + + function stripUnique(obj) { + const children = obj.children || []; + for (const child of children) { + delete child.id; + delete child.parent; + stripUnique(child); + } + delete obj.id; + delete obj.parent; + return obj; + } +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-import-snapshot-dominator-tree.js b/devtools/client/memory/test/xpcshell/test_action-import-snapshot-dominator-tree.js new file mode 100644 index 0000000000..3c11268e94 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-import-snapshot-dominator-tree.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests `importSnapshotAndCensus()` when importing snapshots from the dominator + * tree view. The snapshot is expected to be loaded and its dominator tree + * should be computed. + */ + +const { + snapshotState, + dominatorTreeState, + viewState, + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); +const { + importSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/io.js"); +const { + changeViewAndRefresh, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { subscribe, dispatch, getState } = store; + + dispatch(changeViewAndRefresh(viewState.DOMINATOR_TREE, heapWorker)); + equal( + getState().view.state, + viewState.DOMINATOR_TREE, + "We should now be in the DOMINATOR_TREE view" + ); + + let i = 0; + const expected = [ + "IMPORTING", + "READING", + "READ", + "treeMap:SAVING", + "treeMap:SAVED", + "dominatorTree:COMPUTING", + "dominatorTree:FETCHING", + "dominatorTree:LOADED", + ]; + const expectStates = () => { + const snapshot = getState().snapshots[0]; + if (snapshot && hasExpectedState(snapshot, expected[i])) { + ok(true, `Found expected state ${expected[i]}`); + i++; + } + }; + + const unsubscribe = subscribe(expectStates); + const snapshotPath = await front.saveHeapSnapshot(); + dispatch(importSnapshotAndCensus(heapWorker, snapshotPath)); + + await waitUntilState(store, () => i === expected.length); + unsubscribe(); + equal( + i, + expected.length, + "importSnapshotAndCensus() produces the correct " + + "sequence of states in a snapshot" + ); + equal( + getState().snapshots[0].dominatorTree.state, + dominatorTreeState.LOADED, + "imported snapshot's dominator tree is in LOADED state" + ); + ok(getState().snapshots[0].selected, "imported snapshot is selected"); +}); + +/** + * Check that the provided snapshot is in the expected state. The expected state + * is a snapshotState by default. If the expected state is prefixed by + * dominatorTree, a dominatorTree is expected on the provided snapshot, in the + * corresponding state from dominatorTreeState. + */ +function hasExpectedState(snapshot, expectedState) { + const isDominatorState = expectedState.indexOf("dominatorTree:") === 0; + if (isDominatorState) { + const state = + dominatorTreeState[expectedState.replace("dominatorTree:", "")]; + return snapshot.dominatorTree && snapshot.dominatorTree.state === state; + } + + const isTreeMapState = expectedState.indexOf("treeMap:") === 0; + if (isTreeMapState) { + const state = treeMapState[expectedState.replace("treeMap:", "")]; + return snapshot.treeMap && snapshot.treeMap.state === state; + } + + const state = snapshotState[expectedState]; + return snapshot.state === state; +} diff --git a/devtools/client/memory/test/xpcshell/test_action-select-snapshot.js b/devtools/client/memory/test/xpcshell/test_action-select-snapshot.js new file mode 100644 index 0000000000..f4abac423b --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-select-snapshot.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the reducer responding to the action `selectSnapshot(snapshot)` + */ + +const actions = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + snapshotState: states, +} = require("resource://devtools/client/memory/constants.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + await front.attach(); + const store = Store(); + + for (let i = 0; i < 5; i++) { + store.dispatch(actions.takeSnapshot(front)); + } + + await waitUntilState( + store, + ({ snapshots }) => snapshots.length === 5 && snapshots.every(isDone) + ); + + for (let i = 0; i < 5; i++) { + info(`Selecting snapshot[${i}]`); + store.dispatch(actions.selectSnapshot(store.getState().snapshots[i].id)); + await waitUntilState(store, ({ snapshots }) => snapshots[i].selected); + + const { snapshots } = store.getState(); + ok(snapshots[i].selected, `snapshot[${i}] selected`); + equal( + snapshots.filter(s => !s.selected).length, + 4, + "All other snapshots are unselected" + ); + } +}); + +function isDone(s) { + return s.state === states.SAVED; +} diff --git a/devtools/client/memory/test/xpcshell/test_action-set-display-and-refresh-01.js b/devtools/client/memory/test/xpcshell/test_action-set-display-and-refresh-01.js new file mode 100644 index 0000000000..39845c1f7b --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-set-display-and-refresh-01.js @@ -0,0 +1,182 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the task creator `setCensusDisplayAndRefreshAndRefresh()` for display + * changing. We test this rather than `setCensusDisplayAndRefresh` directly, as + * we use the refresh action in the app itself composed from + * `setCensusDisplayAndRefresh`. + */ + +const { + censusDisplays, + censusState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + setCensusDisplayAndRefresh, +} = require("resource://devtools/client/memory/actions/census-display.js"); +const { + takeSnapshotAndCensus, + selectSnapshotAndRefresh, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +// We test setting an invalid display, which triggers an assertion failure. +EXPECTED_DTU_ASSERT_FAILURE_COUNT = 1; + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.CENSUS)); + + // Test default display with no snapshots + equal( + getState().censusDisplay.breakdown.by, + "coarseType", + "default coarseType display selected at start." + ); + dispatch( + setCensusDisplayAndRefresh(heapWorker, censusDisplays.allocationStack) + ); + equal( + getState().censusDisplay.breakdown.by, + "allocationStack", + "display changed with no snapshots" + ); + + // Test invalid displays + ok(getState().errors.length === 0, "No error actions in the queue."); + dispatch(setCensusDisplayAndRefresh(heapWorker, {})); + await waitUntilState(store, () => getState().errors.length === 1); + ok(true, "Emits an error action when passing in an invalid display object"); + + equal( + getState().censusDisplay.breakdown.by, + "allocationStack", + "current display unchanged when passing invalid display" + ); + + // Test new snapshots + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVED, + ]); + + equal( + getState().snapshots[0].census.display, + censusDisplays.allocationStack, + "New snapshot's census uses correct display" + ); + + // Updates when changing display during `SAVING` + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVED, + censusState.SAVING, + ]); + dispatch(setCensusDisplayAndRefresh(heapWorker, censusDisplays.coarseType)); + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVED, + censusState.SAVED, + ]); + equal( + getState().snapshots[1].census.display, + censusDisplays.coarseType, + "Changing display while saving a snapshot results " + + "in a census using the new display" + ); + + // Updates when changing display during `SAVING_CENSUS` + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVED, + censusState.SAVED, + censusState.SAVING, + ]); + dispatch( + setCensusDisplayAndRefresh(heapWorker, censusDisplays.allocationStack) + ); + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVED, + censusState.SAVED, + censusState.SAVED, + ]); + equal( + getState().snapshots[2].census.display, + censusDisplays.allocationStack, + "Display can be changed while saving census, stores updated display in snapshot" + ); + + // Updates census on currently selected snapshot when changing display + ok(getState().snapshots[2].selected, "Third snapshot currently selected"); + dispatch(setCensusDisplayAndRefresh(heapWorker, censusDisplays.coarseType)); + await waitUntilState( + store, + state => state.snapshots[2].census.state === censusState.SAVING + ); + await waitUntilState( + store, + state => state.snapshots[2].census.state === censusState.SAVED + ); + equal( + getState().snapshots[2].census.display, + censusDisplays.coarseType, + "Snapshot census updated when changing displays " + + "after already generating one census" + ); + + dispatch( + setCensusDisplayAndRefresh(heapWorker, censusDisplays.allocationStack) + ); + await waitUntilState( + store, + state => state.snapshots[2].census.state === censusState.SAVED + ); + equal( + getState().snapshots[2].census.display, + censusDisplays.allocationStack, + "Snapshot census updated when changing displays " + + "after already generating one census" + ); + + // Does not update unselected censuses. + ok(!getState().snapshots[1].selected, "Second snapshot selected currently"); + equal( + getState().snapshots[1].census.display, + censusDisplays.coarseType, + "Second snapshot using `coarseType` display still and " + + "not yet updated to correct display" + ); + + // Updates to current display when switching to stale snapshot. + dispatch(selectSnapshotAndRefresh(heapWorker, getState().snapshots[1].id)); + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVED, + censusState.SAVING, + censusState.SAVED, + ]); + await waitUntilCensusState(store, snapshot => snapshot.census, [ + censusState.SAVED, + censusState.SAVED, + censusState.SAVED, + ]); + + ok(getState().snapshots[1].selected, "Second snapshot selected currently"); + equal( + getState().snapshots[1].census.display, + censusDisplays.allocationStack, + "Second snapshot using `allocationStack` display and updated to correct display" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-set-display-and-refresh-02.js b/devtools/client/memory/test/xpcshell/test_action-set-display-and-refresh-02.js new file mode 100644 index 0000000000..2b2b91bc97 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-set-display-and-refresh-02.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the task creator `setCensusDisplayAndRefreshAndRefresh()` for custom + * displays. + */ + +const { + censusState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + setCensusDisplayAndRefresh, +} = require("resource://devtools/client/memory/actions/census-display.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const CUSTOM = { + displayName: "Custom", + tooltip: "Custom tooltip", + inverted: false, + breakdown: { + by: "internalType", + then: { by: "count", bytes: true, count: false }, + }, +}; + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.CENSUS)); + dispatch(setCensusDisplayAndRefresh(heapWorker, CUSTOM)); + equal( + getState().censusDisplay, + CUSTOM, + "CUSTOM display stored in display state." + ); + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.census, [censusState.SAVED]); + + equal( + getState().snapshots[0].census.display, + CUSTOM, + "New snapshot stored CUSTOM display when done taking census" + ); + ok( + getState().snapshots[0].census.report.children.length, + "Census has some children" + ); + // Ensure we don't have `count` in any results + ok( + getState().snapshots[0].census.report.children.every(c => !c.count), + "Census used CUSTOM display without counts" + ); + // Ensure we do have `bytes` in the results + ok( + getState().snapshots[0].census.report.children.every( + c => typeof c.bytes === "number" + ), + "Census used CUSTOM display with bytes" + ); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-set-display.js b/devtools/client/memory/test/xpcshell/test_action-set-display.js new file mode 100644 index 0000000000..dfc14f609a --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-set-display.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the action creator `setCensusDisplay()` for display changing. Does not + * test refreshing the census information, check `setCensusDisplayAndRefresh` + * action for that. + */ + +const { + censusDisplays, + censusState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + setCensusDisplay, +} = require("resource://devtools/client/memory/actions/census-display.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +// We test setting an invalid display, which triggers an assertion failure. +EXPECTED_DTU_ASSERT_FAILURE_COUNT = 1; + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.CENSUS)); + + // Test default display with no snapshots + equal( + getState().censusDisplay.breakdown.by, + "coarseType", + "default coarseType display selected at start." + ); + + dispatch(setCensusDisplay(censusDisplays.allocationStack)); + equal( + getState().censusDisplay.breakdown.by, + "allocationStack", + "display changed with no snapshots" + ); + + // Test invalid displays + try { + dispatch(setCensusDisplay({})); + ok(false, "Throws when passing in an invalid display object"); + } catch (e) { + ok(true, "Throws when passing in an invalid display object"); + } + equal( + getState().censusDisplay.breakdown.by, + "allocationStack", + "current display unchanged when passing invalid display" + ); + + // Test new snapshots + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.census, [censusState.SAVED]); + equal( + getState().snapshots[0].census.display, + censusDisplays.allocationStack, + "New snapshots use the current, non-default display" + ); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-take-census.js b/devtools/client/memory/test/xpcshell/test_action-take-census.js new file mode 100644 index 0000000000..005208024b --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-take-census.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the async reducer responding to the action `takeCensus(heapWorker, snapshot)` + */ + +var { + snapshotState: states, + censusDisplays, + censusState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +var actions = require("resource://devtools/client/memory/actions/snapshot.js"); +var { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +// This tests taking a census on a snapshot that is still being read, which +// triggers an assertion failure. +EXPECTED_DTU_ASSERT_FAILURE_COUNT = 1; + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + + store.dispatch(changeView(viewState.CENSUS)); + + store.dispatch(actions.takeSnapshot(front)); + await waitUntilState(store, () => { + const snapshots = store.getState().snapshots; + return snapshots.length === 1 && snapshots[0].state === states.SAVED; + }); + + let snapshot = store.getState().snapshots[0]; + equal(snapshot.census, null, "No census data exists yet on the snapshot."); + + // Test error case of wrong state. + store.dispatch(actions.takeCensus(heapWorker, snapshot.id)); + await waitUntilState(store, () => store.getState().errors.length === 1); + + dumpn("Found error: " + store.getState().errors[0]); + ok( + /Assertion failure/.test(store.getState().errors[0]), + "Error thrown when taking a census of a snapshot that has not been read." + ); + + store.dispatch(actions.readSnapshot(heapWorker, snapshot.id)); + await waitUntilState( + store, + () => store.getState().snapshots[0].state === states.READ + ); + + store.dispatch(actions.takeCensus(heapWorker, snapshot.id)); + await waitUntilCensusState(store, s => s.census, [censusState.SAVING]); + await waitUntilCensusState(store, s => s.census, [censusState.SAVED]); + + snapshot = store.getState().snapshots[0]; + ok(snapshot.census, "Snapshot has census after saved census"); + ok(snapshot.census.report.children.length, "Census is in tree node form"); + equal( + snapshot.census.display, + censusDisplays.coarseType, + "Snapshot stored correct display used for the census" + ); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-take-snapshot-and-census.js b/devtools/client/memory/test/xpcshell/test_action-take-snapshot-and-census.js new file mode 100644 index 0000000000..ce7d166aca --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-take-snapshot-and-census.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the task creator `takeSnapshotAndCensus()` for the whole flow of + * taking a snapshot, and its sub-actions. + */ + +const { + snapshotState: states, + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); +const actions = require("resource://devtools/client/memory/actions/snapshot.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + + let snapshotI = 0; + let censusI = 0; + const snapshotStates = ["SAVING", "SAVED", "READING", "READ"]; + const censusStates = ["SAVING", "SAVED"]; + const expectStates = () => { + const snapshot = store.getState().snapshots[0]; + if (!snapshot) { + return; + } + if (snapshotI < snapshotStates.length) { + const isCorrectState = + snapshot.state === states[snapshotStates[snapshotI]]; + if (isCorrectState) { + ok(true, `Found expected snapshot state ${snapshotStates[snapshotI]}`); + snapshotI++; + } + } + if (snapshot.treeMap && censusI < censusStates.length) { + if (snapshot.treeMap.state === treeMapState[censusStates[censusI]]) { + ok(true, `Found expected census state ${censusStates[censusI]}`); + censusI++; + } + } + }; + + const unsubscribe = store.subscribe(expectStates); + store.dispatch(actions.takeSnapshotAndCensus(front, heapWorker)); + + await waitUntilState(store, () => { + return ( + snapshotI === snapshotStates.length && censusI === censusStates.length + ); + }); + unsubscribe(); + + ok( + true, + "takeSnapshotAndCensus() produces the correct sequence of states in a snapshot" + ); + const snapshot = store.getState().snapshots[0]; + ok(snapshot.treeMap, "snapshot has tree map census data"); + ok(snapshot.selected, "snapshot is selected"); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-take-snapshot.js b/devtools/client/memory/test/xpcshell/test_action-take-snapshot.js new file mode 100644 index 0000000000..e8bd46898c --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-take-snapshot.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the async reducer responding to the action `takeSnapshot(front)` + */ + +const actions = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + snapshotState: states, +} = require("resource://devtools/client/memory/constants.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + await front.attach(); + const store = Store(); + + const unsubscribe = store.subscribe(checkState); + + let foundPendingState = false; + let foundDoneState = false; + + function checkState() { + const { snapshots } = store.getState(); + const lastSnapshot = snapshots[snapshots.length - 1]; + + if (lastSnapshot.state === states.SAVING) { + foundPendingState = true; + ok( + foundPendingState, + "Got state change for pending heap snapshot request" + ); + ok(!lastSnapshot.path, "Snapshot does not yet have a path"); + ok(!lastSnapshot.census, "Has no census data when loading"); + } else if (lastSnapshot.state === states.SAVED) { + foundDoneState = true; + ok( + foundDoneState, + "Got state change for completed heap snapshot request" + ); + ok(foundPendingState, "SAVED state occurs after SAVING state"); + ok(lastSnapshot.path, "Snapshot fetched with a path"); + ok( + snapshots.every(s => s.selected === (s.id === lastSnapshot.id)), + "Only recent snapshot is selected" + ); + } + } + + for (let i = 0; i < 4; i++) { + store.dispatch(actions.takeSnapshot(front)); + await waitUntilState(store, () => foundPendingState && foundDoneState); + + // reset state trackers + foundDoneState = foundPendingState = false; + } + + unsubscribe(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-toggle-inverted-and-refresh-01.js b/devtools/client/memory/test/xpcshell/test_action-toggle-inverted-and-refresh-01.js new file mode 100644 index 0000000000..6744390ecd --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-toggle-inverted-and-refresh-01.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that changing displays with different inverted state properly +// refreshes the selected census. + +const { + censusDisplays, + censusState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + setCensusDisplayAndRefresh, +} = require("resource://devtools/client/memory/actions/census-display.js"); +const { + takeSnapshotAndCensus, + selectSnapshotAndRefresh, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.CENSUS)); + + // Select a non-inverted display. + dispatch( + setCensusDisplayAndRefresh(heapWorker, censusDisplays.allocationStack) + ); + equal(getState().censusDisplay.inverted, false, "not inverted by default"); + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + + await waitUntilCensusState(store, s => s.census, [ + censusState.SAVED, + censusState.SAVED, + censusState.SAVED, + ]); + ok(true, "saved 3 snapshots and took a census of each of them"); + + // Select an inverted display. + dispatch( + setCensusDisplayAndRefresh( + heapWorker, + censusDisplays.invertedAllocationStack + ) + ); + + await waitUntilCensusState(store, s => s.census, [ + censusState.SAVED, + censusState.SAVED, + censusState.SAVING, + ]); + ok(true, "toggling inverted should recompute the selected snapshot's census"); + + equal(getState().censusDisplay.inverted, true, "now inverted"); + + await waitUntilCensusState(store, s => s.census, [ + censusState.SAVED, + censusState.SAVED, + censusState.SAVED, + ]); + + equal(getState().snapshots[0].census.display.inverted, false); + equal(getState().snapshots[1].census.display.inverted, false); + equal(getState().snapshots[2].census.display.inverted, true); + + dispatch(selectSnapshotAndRefresh(heapWorker, getState().snapshots[1].id)); + await waitUntilCensusState(store, s => s.census, [ + censusState.SAVED, + censusState.SAVING, + censusState.SAVED, + ]); + ok(true, "selecting non-inverted census should trigger a recompute"); + + await waitUntilCensusState(store, s => s.census, [ + censusState.SAVED, + censusState.SAVED, + censusState.SAVED, + ]); + + equal(getState().snapshots[0].census.display.inverted, false); + equal(getState().snapshots[1].census.display.inverted, true); + equal(getState().snapshots[2].census.display.inverted, true); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-toggle-inverted-and-refresh-02.js b/devtools/client/memory/test/xpcshell/test_action-toggle-inverted-and-refresh-02.js new file mode 100644 index 0000000000..7047f6410e --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-toggle-inverted-and-refresh-02.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that changing inverted state in the middle of taking a snapshot results +// in an inverted census. + +const { + censusDisplays, + snapshotState: states, + censusState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + setCensusDisplay, + setCensusDisplayAndRefresh, +} = require("resource://devtools/client/memory/actions/census-display.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.CENSUS)); + + dispatch(setCensusDisplay(censusDisplays.allocationStack)); + equal( + getState().censusDisplay.inverted, + false, + "Should not have an inverted census display" + ); + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilSnapshotState(store, [states.SAVING]); + + dispatch( + setCensusDisplayAndRefresh( + heapWorker, + censusDisplays.invertedAllocationStack + ) + ); + + await waitUntilCensusState(store, s => s.census, [censusState.SAVED]); + + ok(getState().censusDisplay.inverted, "should want inverted trees"); + ok( + getState().snapshots[0].census.display.inverted, + "snapshot-we-were-in-the-middle-of-saving's census should be inverted" + ); + + dispatch( + setCensusDisplayAndRefresh(heapWorker, censusDisplays.allocationStack) + ); + await waitUntilCensusState(store, s => s.census, [censusState.SAVING]); + ok(true, "toggling inverted retriggers census"); + ok(!getState().censusDisplay.inverted, "no longer inverted"); + + dispatch( + setCensusDisplayAndRefresh( + heapWorker, + censusDisplays.invertedAllocationStack + ) + ); + await waitUntilCensusState(store, s => s.census, [censusState.SAVED]); + ok(getState().censusDisplay.inverted, "inverted again"); + ok( + getState().snapshots[0].census.display.inverted, + "census-we-were-in-the-middle-of-recomputing should be inverted again" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-toggle-inverted.js b/devtools/client/memory/test/xpcshell/test_action-toggle-inverted.js new file mode 100644 index 0000000000..8d946303fa --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-toggle-inverted.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the top level inversion state of the tree. + +const { + censusDisplays, +} = require("resource://devtools/client/memory/constants.js"); +const { + setCensusDisplay, +} = require("resource://devtools/client/memory/actions/census-display.js"); + +add_task(async function () { + const store = Store(); + const { getState, dispatch } = store; + + dispatch(setCensusDisplay(censusDisplays.allocationStack)); + equal(getState().censusDisplay.inverted, false, "not inverted initially"); + + dispatch(setCensusDisplay(censusDisplays.invertedAllocationStack)); + equal(getState().censusDisplay.inverted, true, "now inverted after toggling"); + + dispatch(setCensusDisplay(censusDisplays.allocationStack)); + equal( + getState().censusDisplay.inverted, + false, + "not inverted again after toggling again" + ); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action-toggle-recording-allocations.js b/devtools/client/memory/test/xpcshell/test_action-toggle-recording-allocations.js new file mode 100644 index 0000000000..9ff428b093 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action-toggle-recording-allocations.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test toggling the recording of allocation stacks. + */ + +const { + toggleRecordingAllocationStacks, +} = require("resource://devtools/client/memory/actions/allocations.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + await front.attach(); + // Implement the minimal mock, doing nothing to make toggleRecordingAllocationStacks pass + const commands = { + targetCommand: { + hasTargetWatcherSupport() { + return true; + }, + }, + targetConfigurationCommand: { + updateConfiguration() {}, + }, + }; + const store = Store(); + const { getState, dispatch } = store; + + equal(getState().allocations.recording, false, "not recording by default"); + equal( + getState().allocations.togglingInProgress, + false, + "not in the process of toggling by default" + ); + + dispatch(toggleRecordingAllocationStacks(commands)); + await waitUntilState(store, () => getState().allocations.togglingInProgress); + ok(true, "`togglingInProgress` set to true when toggling on"); + await waitUntilState(store, () => !getState().allocations.togglingInProgress); + + equal(getState().allocations.recording, true, "now we are recording"); + + dispatch(toggleRecordingAllocationStacks(commands)); + await waitUntilState(store, () => getState().allocations.togglingInProgress); + ok(true, "`togglingInProgress` set to true when toggling off"); + await waitUntilState(store, () => !getState().allocations.togglingInProgress); + + equal(getState().allocations.recording, false, "now we are not recording"); + + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action_diffing_01.js b/devtools/client/memory/test/xpcshell/test_action_diffing_01.js new file mode 100644 index 0000000000..ede92d0ed7 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action_diffing_01.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling of diffing. + +const { + toggleDiffing, +} = require("resource://devtools/client/memory/actions/diffing.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + equal(getState().diffing, null, "not diffing by default"); + + dispatch(toggleDiffing()); + ok(getState().diffing, "now diffing after toggling"); + + dispatch(toggleDiffing()); + equal(getState().diffing, null, "not diffing again after toggling again"); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action_diffing_02.js b/devtools/client/memory/test/xpcshell/test_action_diffing_02.js new file mode 100644 index 0000000000..b0c4f21fc1 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action_diffing_02.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that toggling diffing unselects all snapshots. + +const { + censusState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + toggleDiffing, +} = require("resource://devtools/client/memory/actions/diffing.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.CENSUS)); + + equal(getState().diffing, null, "not diffing by default"); + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.census, [ + censusState.SAVED, + censusState.SAVED, + censusState.SAVED, + ]); + + ok( + getState().snapshots.some(s => s.selected), + "One of the new snapshots is selected" + ); + + dispatch(toggleDiffing()); + ok(getState().diffing, "now diffing after toggling"); + + for (const s of getState().snapshots) { + ok( + !s.selected, + "No snapshot should be selected after entering diffing mode" + ); + } + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action_diffing_03.js b/devtools/client/memory/test/xpcshell/test_action_diffing_03.js new file mode 100644 index 0000000000..8db0e8568b --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action_diffing_03.js @@ -0,0 +1,147 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test selecting snapshots for diffing. + +const { + diffingState, + snapshotState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + toggleDiffing, + selectSnapshotForDiffing, +} = require("resource://devtools/client/memory/actions/diffing.js"); +const { + takeSnapshot, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +// We test that you (1) cannot select a snapshot that is not in a diffable +// state, and (2) cannot select more than 2 snapshots for diffing. Both attempts +// trigger assertion failures. +EXPECTED_DTU_ASSERT_FAILURE_COUNT = 2; + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.CENSUS)); + equal(getState().diffing, null, "not diffing by default"); + + dispatch(takeSnapshot(front, heapWorker)); + dispatch(takeSnapshot(front, heapWorker)); + dispatch(takeSnapshot(front, heapWorker)); + + await waitUntilSnapshotState(store, [ + snapshotState.SAVED, + snapshotState.SAVED, + snapshotState.SAVED, + ]); + dispatch(takeSnapshot(front)); + + // Start diffing. + dispatch(toggleDiffing()); + ok(getState().diffing, "now diffing after toggling"); + equal(getState().diffing.firstSnapshotId, null, "no first snapshot selected"); + equal( + getState().diffing.secondSnapshotId, + null, + "no second snapshot selected" + ); + equal( + getState().diffing.state, + diffingState.SELECTING, + "should be in diffing state SELECTING" + ); + + // Can't select a snapshot that is not in a diffable state. + equal( + getState().snapshots[3].state, + snapshotState.SAVING, + "the last snapshot is still in the process of being saved" + ); + dumpn("Expecting exception:"); + let threw = false; + try { + dispatch(selectSnapshotForDiffing(getState().snapshots[3])); + } catch (error) { + threw = true; + } + ok( + threw, + "Should not be able to select snapshots that aren't ready for diffing" + ); + + // Select first snapshot for diffing. + dispatch(selectSnapshotForDiffing(getState().snapshots[0])); + ok(getState().diffing, "now diffing after toggling"); + equal( + getState().diffing.firstSnapshotId, + getState().snapshots[0].id, + "first snapshot selected" + ); + equal( + getState().diffing.secondSnapshotId, + null, + "no second snapshot selected" + ); + equal( + getState().diffing.state, + diffingState.SELECTING, + "should still be in diffing state SELECTING" + ); + + // Can't diff first snapshot with itself; this is a noop. + dispatch(selectSnapshotForDiffing(getState().snapshots[0])); + ok(getState().diffing, "still diffing"); + equal( + getState().diffing.firstSnapshotId, + getState().snapshots[0].id, + "first snapshot still selected" + ); + equal( + getState().diffing.secondSnapshotId, + null, + "still no second snapshot selected" + ); + equal( + getState().diffing.state, + diffingState.SELECTING, + "should still be in diffing state SELECTING" + ); + + // Select second snapshot for diffing. + dispatch(selectSnapshotForDiffing(getState().snapshots[1])); + ok(getState().diffing, "still diffing"); + equal( + getState().diffing.firstSnapshotId, + getState().snapshots[0].id, + "first snapshot still selected" + ); + equal( + getState().diffing.secondSnapshotId, + getState().snapshots[1].id, + "second snapshot selected" + ); + + // Can't select more than two snapshots for diffing. + dumpn("Expecting exception:"); + threw = false; + try { + dispatch(selectSnapshotForDiffing(getState().snapshots[2])); + } catch (error) { + threw = true; + } + ok(threw, "Can't select more than two snapshots for diffing"); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action_diffing_04.js b/devtools/client/memory/test/xpcshell/test_action_diffing_04.js new file mode 100644 index 0000000000..92a57276bb --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action_diffing_04.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we compute census diffs. + +const { + diffingState, + snapshotState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + toggleDiffing, + selectSnapshotForDiffingAndRefresh, +} = require("resource://devtools/client/memory/actions/diffing.js"); +const { + takeSnapshot, + readSnapshot, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + dispatch(changeView(viewState.CENSUS)); + + equal(getState().diffing, null, "not diffing by default"); + + const s1 = await dispatch(takeSnapshot(front, heapWorker)); + const s2 = await dispatch(takeSnapshot(front, heapWorker)); + const s3 = await dispatch(takeSnapshot(front, heapWorker)); + dispatch(readSnapshot(heapWorker, s1)); + dispatch(readSnapshot(heapWorker, s2)); + dispatch(readSnapshot(heapWorker, s3)); + await waitUntilSnapshotState(store, [ + snapshotState.READ, + snapshotState.READ, + snapshotState.READ, + ]); + + dispatch(toggleDiffing()); + dispatch( + selectSnapshotForDiffingAndRefresh(heapWorker, getState().snapshots[0]) + ); + dispatch( + selectSnapshotForDiffingAndRefresh(heapWorker, getState().snapshots[1]) + ); + + ok(getState().diffing, "We should be diffing."); + equal( + getState().diffing.firstSnapshotId, + getState().snapshots[0].id, + "First snapshot selected." + ); + equal( + getState().diffing.secondSnapshotId, + getState().snapshots[1].id, + "Second snapshot selected." + ); + + await waitUntilState( + store, + state => state.diffing.state === diffingState.TAKING_DIFF + ); + ok( + true, + "Selecting two snapshots for diffing should trigger computing a diff." + ); + + await waitUntilState( + store, + state => state.diffing.state === diffingState.TOOK_DIFF + ); + ok(true, "And then the diff should complete."); + ok(getState().diffing.census, "And we should have a census."); + ok(getState().diffing.census.report, "And that census should have a report."); + equal( + getState().diffing.census.display, + getState().censusDisplay, + "And that census should have the correct display" + ); + equal( + getState().diffing.census.filter, + getState().filter, + "And that census should have the correct filter" + ); + equal( + getState().diffing.census.display.inverted, + getState().censusDisplay.inverted, + "And that census should have the correct inversion" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_action_diffing_05.js b/devtools/client/memory/test/xpcshell/test_action_diffing_05.js new file mode 100644 index 0000000000..885ae5c1d4 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_action_diffing_05.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we recompute census diffs at the appropriate times. + +const { + diffingState, + snapshotState, + censusDisplays, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + setCensusDisplayAndRefresh, +} = require("resource://devtools/client/memory/actions/census-display.js"); +const { + toggleDiffing, + selectSnapshotForDiffingAndRefresh, +} = require("resource://devtools/client/memory/actions/diffing.js"); +const { + setFilterStringAndRefresh, +} = require("resource://devtools/client/memory/actions/filter.js"); +const { + takeSnapshot, + readSnapshot, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + dispatch(changeView(viewState.CENSUS)); + + await dispatch( + setCensusDisplayAndRefresh(heapWorker, censusDisplays.allocationStack) + ); + equal(getState().censusDisplay.inverted, false, "not inverted at start"); + + equal(getState().diffing, null, "not diffing by default"); + + const s1 = await dispatch(takeSnapshot(front, heapWorker)); + const s2 = await dispatch(takeSnapshot(front, heapWorker)); + const s3 = await dispatch(takeSnapshot(front, heapWorker)); + dispatch(readSnapshot(heapWorker, s1)); + dispatch(readSnapshot(heapWorker, s2)); + dispatch(readSnapshot(heapWorker, s3)); + await waitUntilSnapshotState(store, [ + snapshotState.READ, + snapshotState.READ, + snapshotState.READ, + ]); + + await dispatch(toggleDiffing()); + dispatch( + selectSnapshotForDiffingAndRefresh(heapWorker, getState().snapshots[0]) + ); + dispatch( + selectSnapshotForDiffingAndRefresh(heapWorker, getState().snapshots[1]) + ); + await waitUntilState( + store, + state => state.diffing.state === diffingState.TOOK_DIFF + ); + + const shouldTriggerRecompute = [ + { + name: "toggling inversion", + func: () => + dispatch( + setCensusDisplayAndRefresh( + heapWorker, + censusDisplays.invertedAllocationStack + ) + ), + }, + { + name: "filtering", + func: () => dispatch(setFilterStringAndRefresh("scr", heapWorker)), + }, + { + name: "changing displays", + func: () => + dispatch( + setCensusDisplayAndRefresh(heapWorker, censusDisplays.coarseType) + ), + }, + ]; + + for (const { name, func } of shouldTriggerRecompute) { + dumpn(`Testing that "${name}" triggers a diff recompute`); + func(); + + await waitUntilState( + store, + state => state.diffing.state === diffingState.TAKING_DIFF + ); + ok(true, "triggered diff recompute."); + + await waitUntilState( + store, + state => state.diffing.state === diffingState.TOOK_DIFF + ); + ok(true, "And then the diff should complete."); + ok(getState().diffing.census, "And we should have a census."); + ok( + getState().diffing.census.report, + "And that census should have a report." + ); + equal( + getState().diffing.census.display, + getState().censusDisplay, + "And that census should have the correct display" + ); + equal( + getState().diffing.census.filter, + getState().filter, + "And that census should have the correct filter" + ); + equal( + getState().diffing.census.display.inverted, + getState().censusDisplay.inverted, + "And that census should have the correct inversion" + ); + } + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_dominator_trees_01.js b/devtools/client/memory/test/xpcshell/test_dominator_trees_01.js new file mode 100644 index 0000000000..9b8bbb2533 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_dominator_trees_01.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we can compute and fetch the dominator tree for a snapshot. + +const { + dominatorTreeState, + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); +const { + takeSnapshotAndCensus, + computeAndFetchDominatorTree, +} = require("resource://devtools/client/memory/actions/snapshot.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.treeMap, [treeMapState.SAVED]); + ok( + !getState().snapshots[0].dominatorTree, + "There shouldn't be a dominator tree model yet since it is not computed " + + "until we switch to the dominators view." + ); + + // Change to the dominator tree view. + dispatch( + computeAndFetchDominatorTree(heapWorker, getState().snapshots[0].id) + ); + ok( + getState().snapshots[0].dominatorTree, + "Should now have a dominator tree model for the selected snapshot" + ); + + // Wait for the dominator tree to start being computed. + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.COMPUTING + ); + ok(true, "The dominator tree started computing"); + ok( + !getState().snapshots[0].dominatorTree.root, + "When the dominator tree is computing, we should not have its root" + ); + + // Wait for the dominator tree to finish computing and start being fetched. + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.FETCHING + ); + ok(true, "The dominator tree started fetching"); + ok( + !getState().snapshots[0].dominatorTree.root, + "When the dominator tree is fetching, we should not have its root" + ); + + // Wait for the dominator tree to finish being fetched. + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + ok(true, "The dominator tree was fetched"); + ok( + getState().snapshots[0].dominatorTree.root, + "When the dominator tree is loaded, we should have its root" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_dominator_trees_02.js b/devtools/client/memory/test/xpcshell/test_dominator_trees_02.js new file mode 100644 index 0000000000..eb38591286 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_dominator_trees_02.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that selecting the dominator tree view automatically kicks off fetching +// and computing dominator trees. + +const { + dominatorTreeState, + viewState, + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeViewAndRefresh, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.treeMap, [treeMapState.SAVED]); + ok( + !getState().snapshots[0].dominatorTree, + "There shouldn't be a dominator tree model yet since it is not computed " + + "until we switch to the dominators view." + ); + + dispatch(changeViewAndRefresh(viewState.DOMINATOR_TREE, heapWorker)); + ok( + getState().snapshots[0].dominatorTree, + "Should now have a dominator tree model for the selected snapshot" + ); + + // Wait for the dominator tree to start being computed. + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.COMPUTING + ); + ok(true, "The dominator tree started computing"); + ok( + !getState().snapshots[0].dominatorTree.root, + "When the dominator tree is computing, we should not have its root" + ); + + // Wait for the dominator tree to finish computing and start being fetched. + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.FETCHING + ); + ok(true, "The dominator tree started fetching"); + ok( + !getState().snapshots[0].dominatorTree.root, + "When the dominator tree is fetching, we should not have its root" + ); + + // Wait for the dominator tree to finish being fetched. + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + ok(true, "The dominator tree was fetched"); + ok( + getState().snapshots[0].dominatorTree.root, + "When the dominator tree is loaded, we should have its root" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_dominator_trees_03.js b/devtools/client/memory/test/xpcshell/test_dominator_trees_03.js new file mode 100644 index 0000000000..e46c1969c6 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_dominator_trees_03.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that selecting the dominator tree view and then taking a snapshot +// properly kicks off fetching and computing dominator trees. + +const { + dominatorTreeState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.DOMINATOR_TREE)); + equal( + getState().view.state, + viewState.DOMINATOR_TREE, + "We should now be in the DOMINATOR_TREE view" + ); + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + + // Wait for the dominator tree to start being computed. + await waitUntilState( + store, + state => state.snapshots[0] && state.snapshots[0].dominatorTree + ); + equal( + getState().snapshots[0].dominatorTree.state, + dominatorTreeState.COMPUTING, + "The dominator tree started computing" + ); + ok( + !getState().snapshots[0].dominatorTree.root, + "When the dominator tree is computing, we should not have its root" + ); + + // Wait for the dominator tree to finish computing and start being fetched. + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.FETCHING + ); + ok(true, "The dominator tree started fetching"); + ok( + !getState().snapshots[0].dominatorTree.root, + "When the dominator tree is fetching, we should not have its root" + ); + + // Wait for the dominator tree to finish being fetched. + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + ok(true, "The dominator tree was fetched"); + ok( + getState().snapshots[0].dominatorTree.root, + "When the dominator tree is loaded, we should have its root" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_dominator_trees_04.js b/devtools/client/memory/test/xpcshell/test_dominator_trees_04.js new file mode 100644 index 0000000000..d9814590a9 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_dominator_trees_04.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that selecting the dominator tree view while in the middle of taking a +// snapshot properly kicks off fetching and computing dominator trees. + +const { + snapshotState: states, + dominatorTreeState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + + for (const intermediateSnapshotState of [ + states.SAVING, + states.READING, + states.READ, + ]) { + dumpn( + "Testing switching to the DOMINATOR_TREE view in the middle of the " + + `${intermediateSnapshotState} snapshot state` + ); + + const store = Store(); + const { getState, dispatch } = store; + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilSnapshotState(store, [intermediateSnapshotState]); + + dispatch(changeView(viewState.DOMINATOR_TREE)); + equal( + getState().view.state, + viewState.DOMINATOR_TREE, + "We should now be in the DOMINATOR_TREE view" + ); + + // Wait for the dominator tree to start being computed. + await waitUntilState( + store, + state => state.snapshots[0] && state.snapshots[0].dominatorTree + ); + equal( + getState().snapshots[0].dominatorTree.state, + dominatorTreeState.COMPUTING, + "The dominator tree started computing" + ); + ok( + !getState().snapshots[0].dominatorTree.root, + "When the dominator tree is computing, we should not have its root" + ); + + // Wait for the dominator tree to finish computing and start being fetched. + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.FETCHING + ); + ok(true, "The dominator tree started fetching"); + ok( + !getState().snapshots[0].dominatorTree.root, + "When the dominator tree is fetching, we should not have its root" + ); + + // Wait for the dominator tree to finish being fetched. + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + ok(true, "The dominator tree was fetched"); + ok( + getState().snapshots[0].dominatorTree.root, + "When the dominator tree is loaded, we should have its root" + ); + } + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_dominator_trees_05.js b/devtools/client/memory/test/xpcshell/test_dominator_trees_05.js new file mode 100644 index 0000000000..7f6ed31bb3 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_dominator_trees_05.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that changing the currently selected snapshot to a snapshot that does +// not have a dominator tree will automatically compute and fetch one for it. + +const { + dominatorTreeState, + viewState, + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); +const { + takeSnapshotAndCensus, + selectSnapshotAndRefresh, +} = require("resource://devtools/client/memory/actions/snapshot.js"); + +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.treeMap, [ + treeMapState.SAVED, + treeMapState.SAVED, + ]); + + ok(getState().snapshots[1].selected, "The second snapshot is selected"); + + // Change to the dominator tree view. + dispatch(changeView(viewState.DOMINATOR_TREE)); + + // Wait for the dominator tree to finish being fetched. + await waitUntilState( + store, + state => + state.snapshots[1].dominatorTree && + state.snapshots[1].dominatorTree.state === dominatorTreeState.LOADED + ); + ok(true, "The second snapshot's dominator tree was fetched"); + + // Select the first snapshot. + dispatch(selectSnapshotAndRefresh(heapWorker, getState().snapshots[0].id)); + + // And now the first snapshot should have its dominator tree fetched and + // computed because of the new selection. + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree && + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + ok(true, "The first snapshot's dominator tree was fetched"); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_dominator_trees_06.js b/devtools/client/memory/test/xpcshell/test_dominator_trees_06.js new file mode 100644 index 0000000000..cf32c10a9c --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_dominator_trees_06.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we can incrementally fetch a subtree of a dominator tree. + +const { + dominatorTreeState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + takeSnapshotAndCensus, + fetchImmediatelyDominated, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const DominatorTreeLazyChildren = require("resource://devtools/client/memory/dominator-tree-lazy-children.js"); + +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.DOMINATOR_TREE)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + + // Wait for the dominator tree to finish being fetched. + await waitUntilState( + store, + state => + state.snapshots[0] && + state.snapshots[0].dominatorTree && + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + ok( + getState().snapshots[0].dominatorTree.root, + "The dominator tree was fetched" + ); + + // Find a node that has children, but none of them are loaded. + + function findNode(node) { + if (node.moreChildrenAvailable && !node.children) { + return node; + } + + if (node.children) { + for (const child of node.children) { + const found = findNode(child); + if (found) { + return found; + } + } + } + + return null; + } + + const oldRoot = getState().snapshots[0].dominatorTree.root; + const oldNode = findNode(oldRoot); + ok( + oldNode, + "Should have found a node with children that are not loaded since we " + + "only send partial dominator trees across initially and load the rest " + + "on demand" + ); + ok(oldNode !== oldRoot, "But the node should not be the root"); + + const lazyChildren = new DominatorTreeLazyChildren(oldNode.nodeId, 0); + dispatch( + fetchImmediatelyDominated( + heapWorker, + getState().snapshots[0].id, + lazyChildren + ) + ); + + equal( + getState().snapshots[0].dominatorTree.state, + dominatorTreeState.INCREMENTAL_FETCHING, + "Fetching immediately dominated children should put us in the " + + "INCREMENTAL_FETCHING state" + ); + + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + ok( + true, + "The dominator tree should go back to LOADED after the incremental " + + "fetching is done." + ); + + const newRoot = getState().snapshots[0].dominatorTree.root; + ok(oldRoot !== newRoot, "When we insert new nodes, we get a new tree"); + equal( + oldRoot.children.length, + newRoot.children.length, + "The new tree's root should have the same number of children as the " + + "old root's" + ); + + let differentChildrenCount = 0; + for (let i = 0; i < oldRoot.children.length; i++) { + if (oldRoot.children[i] !== newRoot.children[i]) { + differentChildrenCount++; + } + } + equal( + differentChildrenCount, + 1, + "All subtrees except the subtree we inserted incrementally fetched " + + "children into should be the same because we use persistent updates" + ); + + // Find the new node which has the children inserted. + + function findNewNode(node) { + if (node.nodeId === oldNode.nodeId) { + return node; + } + + if (node.children) { + for (const child of node.children) { + const found = findNewNode(child); + if (found) { + return found; + } + } + } + + return null; + } + + const newNode = findNewNode(newRoot); + ok(newNode, "Should find the node in the new tree again"); + ok( + newNode !== oldNode, + "We did not mutate the old node in place, instead created a new node" + ); + ok(newNode.children, "And the new node should have the children attached"); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_dominator_trees_07.js b/devtools/client/memory/test/xpcshell/test_dominator_trees_07.js new file mode 100644 index 0000000000..7185aab5c0 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_dominator_trees_07.js @@ -0,0 +1,174 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we can incrementally fetch two subtrees in the same dominator tree +// concurrently. This exercises the activeFetchRequestCount machinery. + +const { + dominatorTreeState, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + takeSnapshotAndCensus, + fetchImmediatelyDominated, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const DominatorTreeLazyChildren = require("resource://devtools/client/memory/dominator-tree-lazy-children.js"); + +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.DOMINATOR_TREE)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + + // Wait for the dominator tree to finish being fetched. + await waitUntilState( + store, + state => + state.snapshots[0] && + state.snapshots[0].dominatorTree && + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + ok( + getState().snapshots[0].dominatorTree.root, + "The dominator tree was fetched" + ); + + // Find a node that has more children. + + function findNode(node) { + if (node.moreChildrenAvailable && !node.children) { + return node; + } + + if (node.children) { + for (const child of node.children) { + const found = findNode(child); + if (found) { + return found; + } + } + } + + return null; + } + + const oldRoot = getState().snapshots[0].dominatorTree.root; + const oldNode = findNode(oldRoot); + ok(oldNode, "Should have found a node with more children."); + + // Find another node that has more children. + function findNodeRev(node) { + if (node.moreChildrenAvailable && !node.children) { + return node; + } + + if (node.children) { + for (const child of node.children.slice().reverse()) { + const found = findNodeRev(child); + if (found) { + return found; + } + } + } + + return null; + } + + const oldNode2 = findNodeRev(oldRoot); + ok(oldNode2, "Should have found another node with more children."); + ok( + oldNode !== oldNode2, + "The second node should not be the same as the first one" + ); + + // Fetch both subtrees concurrently. + dispatch( + fetchImmediatelyDominated( + heapWorker, + getState().snapshots[0].id, + new DominatorTreeLazyChildren(oldNode.nodeId, 0) + ) + ); + dispatch( + fetchImmediatelyDominated( + heapWorker, + getState().snapshots[0].id, + new DominatorTreeLazyChildren(oldNode2.nodeId, 0) + ) + ); + + equal( + getState().snapshots[0].dominatorTree.state, + dominatorTreeState.INCREMENTAL_FETCHING, + "Fetching immediately dominated children should put us in the " + + "INCREMENTAL_FETCHING state" + ); + + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + ok( + true, + "The dominator tree should go back to LOADED after the incremental " + + "fetching is done." + ); + + const newRoot = getState().snapshots[0].dominatorTree.root; + ok(oldRoot !== newRoot, "When we insert new nodes, we get a new tree"); + + // Find the new node which has the children inserted. + + function findNodeWithId(id, node) { + if (node.nodeId === id) { + return node; + } + + if (node.children) { + for (const child of node.children) { + const found = findNodeWithId(id, child); + if (found) { + return found; + } + } + } + + return null; + } + + const newNode = findNodeWithId(oldNode.nodeId, newRoot); + ok(newNode, "Should find the node in the new tree again"); + ok( + newNode !== oldNode, + "We did not mutate the old node in place, instead created a new node" + ); + ok( + newNode.children.length, + "And the new node should have the new children attached" + ); + + const newNode2 = findNodeWithId(oldNode2.nodeId, newRoot); + ok(newNode2, "Should find the second node in the new tree again"); + ok( + newNode2 !== oldNode2, + "We did not mutate the second old node in place, instead created a new node" + ); + ok( + newNode2.children, + "And the new node should have the new children attached" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_dominator_trees_08.js b/devtools/client/memory/test/xpcshell/test_dominator_trees_08.js new file mode 100644 index 0000000000..e61c8b66b8 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_dominator_trees_08.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we can change the display with which we describe a dominator tree +// and that the dominator tree is re-fetched. + +const { + dominatorTreeState, + viewState, + labelDisplays, + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); +const { + setLabelDisplayAndRefresh, +} = require("resource://devtools/client/memory/actions/label-display.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.DOMINATOR_TREE)); + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.treeMap, [treeMapState.SAVED]); + ok( + !getState().snapshots[0].dominatorTree, + "There shouldn't be a dominator tree model yet since it is not computed " + + "until we switch to the dominators view." + ); + + // Wait for the dominator tree to finish being fetched. + await waitUntilState( + store, + state => + state.snapshots[0] && + state.snapshots[0].dominatorTree && + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + + ok( + getState().labelDisplay, + "We have a default display for describing nodes in a dominator tree" + ); + equal( + getState().labelDisplay, + labelDisplays.coarseType, + "and the default is coarse type" + ); + equal( + getState().labelDisplay, + getState().snapshots[0].dominatorTree.display, + "and the newly computed dominator tree has that display" + ); + + // Switch to the allocationStack display. + dispatch( + setLabelDisplayAndRefresh(heapWorker, labelDisplays.allocationStack) + ); + + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.FETCHING + ); + ok( + true, + "switching display types caused the dominator tree to be fetched " + + "again." + ); + + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + equal( + getState().snapshots[0].dominatorTree.display, + labelDisplays.allocationStack, + "The new dominator tree's display is allocationStack" + ); + equal( + getState().labelDisplay, + labelDisplays.allocationStack, + "as is our requested dominator tree display" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_dominator_trees_09.js b/devtools/client/memory/test/xpcshell/test_dominator_trees_09.js new file mode 100644 index 0000000000..b87964970e --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_dominator_trees_09.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we can change the display with which we describe a dominator tree +// while the dominator tree is in the middle of being fetched. + +const { + dominatorTreeState, + viewState, + labelDisplays, + treeMapState, +} = require("resource://devtools/client/memory/constants.js"); +const { + setLabelDisplayAndRefresh, +} = require("resource://devtools/client/memory/actions/label-display.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.DOMINATOR_TREE)); + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.treeMap, [treeMapState.SAVED]); + ok( + !getState().snapshots[0].dominatorTree, + "There shouldn't be a dominator tree model yet since it is not computed " + + "until we switch to the dominators view." + ); + + // Wait for the dominator tree to start fetching. + await waitUntilState( + store, + state => + state.snapshots[0] && + state.snapshots[0].dominatorTree && + state.snapshots[0].dominatorTree.state === dominatorTreeState.FETCHING + ); + + ok( + getState().labelDisplay, + "We have a default display for describing nodes in a dominator tree" + ); + equal( + getState().labelDisplay, + labelDisplays.coarseType, + "and the default is coarse type" + ); + equal( + getState().labelDisplay, + getState().snapshots[0].dominatorTree.display, + "and the newly computed dominator tree has that display" + ); + + // Switch to the allocationStack display while we are still fetching the + // dominator tree. + dispatch( + setLabelDisplayAndRefresh(heapWorker, labelDisplays.allocationStack) + ); + + // Wait for the dominator tree to finish being fetched. + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + + equal( + getState().snapshots[0].dominatorTree.display, + labelDisplays.allocationStack, + "The new dominator tree's display is allocationStack" + ); + equal( + getState().labelDisplay, + labelDisplays.allocationStack, + "as is our requested dominator tree display" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_dominator_trees_10.js b/devtools/client/memory/test/xpcshell/test_dominator_trees_10.js new file mode 100644 index 0000000000..f6dc791461 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_dominator_trees_10.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we maintain focus of the selected dominator tree node across +// changing breakdowns for labeling them. + +const { + dominatorTreeState, + labelDisplays, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { + takeSnapshotAndCensus, + focusDominatorTreeNode, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); +const { + setLabelDisplayAndRefresh, +} = require("resource://devtools/client/memory/actions/label-display.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.DOMINATOR_TREE)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + + // Wait for the dominator tree to finish being fetched. + await waitUntilState( + store, + state => + state.snapshots[0] && + state.snapshots[0].dominatorTree && + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + ok(true, "The dominator tree was fetched"); + + const root = getState().snapshots[0].dominatorTree.root; + ok(root, "When the dominator tree is loaded, we should have its root"); + + dispatch(focusDominatorTreeNode(getState().snapshots[0].id, root)); + equal( + root, + getState().snapshots[0].dominatorTree.focused, + "The root should be focused." + ); + + equal( + getState().labelDisplay, + labelDisplays.coarseType, + "Using labelDisplays.coarseType by default" + ); + dispatch( + setLabelDisplayAndRefresh(heapWorker, labelDisplays.allocationStack) + ); + equal( + getState().labelDisplay, + labelDisplays.allocationStack, + "Using labelDisplays.allocationStack now" + ); + + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.FETCHING + ); + ok(true, "We started re-fetching the dominator tree"); + + await waitUntilState( + store, + state => + state.snapshots[0].dominatorTree.state === dominatorTreeState.LOADED + ); + ok(true, "The dominator tree was loaded again"); + + ok( + getState().snapshots[0].dominatorTree.focused, + "Still have a focused node" + ); + equal( + getState().snapshots[0].dominatorTree.focused.nodeId, + root.nodeId, + "Focused node is the same as before" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_individuals_01.js b/devtools/client/memory/test/xpcshell/test_individuals_01.js new file mode 100644 index 0000000000..9278537136 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_individuals_01.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Basic test for switching to the individuals view. + +const { + censusState, + viewState, + individualsState, +} = require("resource://devtools/client/memory/constants.js"); +const { + fetchIndividuals, + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const EXPECTED_INDIVIDUAL_STATES = [ + individualsState.COMPUTING_DOMINATOR_TREE, + individualsState.FETCHING, + individualsState.FETCHED, +]; + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + equal(getState().individuals, null, "no individuals state by default"); + + dispatch(changeView(viewState.CENSUS)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.census, [censusState.SAVED]); + + const root = getState().snapshots[0].census.report; + ok(root, "Should have a census"); + + const reportLeafIndex = findReportLeafIndex(root); + ok(reportLeafIndex, "Should get a reportLeafIndex"); + + const snapshotId = getState().snapshots[0].id; + ok(snapshotId, "Should have a snapshot id"); + + const breakdown = getState().snapshots[0].census.display.breakdown; + ok(breakdown, "Should have a breakdown"); + + dispatch( + fetchIndividuals(heapWorker, snapshotId, breakdown, reportLeafIndex) + ); + + // Wait for each expected state. + for (const state of EXPECTED_INDIVIDUAL_STATES) { + await waitUntilState(store, s => { + return ( + s.view.state === viewState.INDIVIDUALS && + s.individuals && + s.individuals.state === state + ); + }); + ok(true, `Reached state = ${state}`); + } + + ok(getState().individuals, "Should have individuals state"); + ok(getState().individuals.nodes, "Should have individuals nodes"); + ok( + !!getState().individuals.nodes.length, + "Should have a positive number of nodes" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_individuals_02.js b/devtools/client/memory/test/xpcshell/test_individuals_02.js new file mode 100644 index 0000000000..76d34187a0 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_individuals_02.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test switching to the individuals view when we are in the middle of computing +// a dominator tree. + +const { + censusState, + dominatorTreeState, + viewState, + individualsState, +} = require("resource://devtools/client/memory/constants.js"); +const { + fetchIndividuals, + takeSnapshotAndCensus, + computeDominatorTree, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const EXPECTED_INDIVIDUAL_STATES = [ + individualsState.COMPUTING_DOMINATOR_TREE, + individualsState.FETCHING, + individualsState.FETCHED, +]; + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + equal(getState().individuals, null, "no individuals state by default"); + + dispatch(changeView(viewState.CENSUS)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.census, [censusState.SAVED]); + + const root = getState().snapshots[0].census.report; + ok(root, "Should have a census"); + + const reportLeafIndex = findReportLeafIndex(root); + ok(reportLeafIndex, "Should get a reportLeafIndex"); + + const snapshotId = getState().snapshots[0].id; + ok(snapshotId, "Should have a snapshot id"); + + const breakdown = getState().snapshots[0].census.display.breakdown; + ok(breakdown, "Should have a breakdown"); + + // Start computing a dominator tree. + + dispatch(computeDominatorTree(heapWorker, snapshotId)); + equal( + getState().snapshots[0].dominatorTree.state, + dominatorTreeState.COMPUTING, + "Should be computing dominator tree" + ); + + // Fetch individuals in the middle of computing the dominator tree. + + dispatch( + fetchIndividuals(heapWorker, snapshotId, breakdown, reportLeafIndex) + ); + + // Wait for each expected state. + for (const state of EXPECTED_INDIVIDUAL_STATES) { + await waitUntilState(store, s => { + return ( + s.view.state === viewState.INDIVIDUALS && + s.individuals && + s.individuals.state === state + ); + }); + ok(true, `Reached state = ${state}`); + } + + ok(getState().individuals, "Should have individuals state"); + ok(getState().individuals.nodes, "Should have individuals nodes"); + ok( + !!getState().individuals.nodes.length, + "Should have a positive number of nodes" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_individuals_03.js b/devtools/client/memory/test/xpcshell/test_individuals_03.js new file mode 100644 index 0000000000..db31bcf347 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_individuals_03.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test switching to the individuals view when we are in the diffing view. + +const { + censusState, + diffingState, + viewState, + individualsState, +} = require("resource://devtools/client/memory/constants.js"); +const { + fetchIndividuals, + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, + popViewAndRefresh, +} = require("resource://devtools/client/memory/actions/view.js"); +const { + selectSnapshotForDiffingAndRefresh, +} = require("resource://devtools/client/memory/actions/diffing.js"); + +const EXPECTED_INDIVIDUAL_STATES = [ + individualsState.COMPUTING_DOMINATOR_TREE, + individualsState.FETCHING, + individualsState.FETCHED, +]; + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.CENSUS)); + + // Take two snapshots and diff them from each other. + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.census, [ + censusState.SAVED, + censusState.SAVED, + ]); + + dispatch(changeView(viewState.DIFFING)); + dispatch( + selectSnapshotForDiffingAndRefresh(heapWorker, getState().snapshots[0]) + ); + dispatch( + selectSnapshotForDiffingAndRefresh(heapWorker, getState().snapshots[1]) + ); + + await waitUntilState(store, state => { + return state.diffing && state.diffing.state === diffingState.TOOK_DIFF; + }); + ok(getState().diffing.census); + + // Fetch individuals. + + const root = getState().diffing.census.report; + ok(root, "Should have a census"); + + const reportLeafIndex = findReportLeafIndex(root); + ok(reportLeafIndex, "Should get a reportLeafIndex"); + + const snapshotId = getState().diffing.secondSnapshotId; + ok(snapshotId, "Should have a snapshot id"); + + const breakdown = getState().censusDisplay.breakdown; + ok(breakdown, "Should have a breakdown"); + + dispatch( + fetchIndividuals(heapWorker, snapshotId, breakdown, reportLeafIndex) + ); + + for (const state of EXPECTED_INDIVIDUAL_STATES) { + await waitUntilState(store, s => { + return ( + s.view.state === viewState.INDIVIDUALS && + s.individuals && + s.individuals.state === state + ); + }); + ok(true, `Reached state = ${state}`); + } + + ok(getState().individuals, "Should have individuals state"); + ok(getState().individuals.nodes, "Should have individuals nodes"); + ok( + !!getState().individuals.nodes.length, + "Should have a positive number of nodes" + ); + + // Pop the view back to the diffing. + + dispatch(popViewAndRefresh(heapWorker)); + + await waitUntilState(store, state => { + return state.diffing && state.diffing.state === diffingState.TOOK_DIFF; + }); + + ok( + getState().diffing.census.report, + "We have our census diff again after popping back to the last view" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_individuals_04.js b/devtools/client/memory/test/xpcshell/test_individuals_04.js new file mode 100644 index 0000000000..f2cd71d4b9 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_individuals_04.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test showing individual Array objects. + +const { + censusState, + viewState, + individualsState, +} = require("resource://devtools/client/memory/constants.js"); +const { + fetchIndividuals, + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); +const { + setFilterString, +} = require("resource://devtools/client/memory/actions/filter.js"); + +const EXPECTED_INDIVIDUAL_STATES = [ + individualsState.COMPUTING_DOMINATOR_TREE, + individualsState.FETCHING, + individualsState.FETCHED, +]; + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.CENSUS)); + dispatch(setFilterString("Array")); + + // Take a snapshot and wait for the census to finish. + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.census, [censusState.SAVED]); + + // Fetch individuals. + + const root = getState().snapshots[0].census.report; + ok(root, "Should have a census"); + + const reportLeafIndex = findReportLeafIndex(root, "Array"); + ok(reportLeafIndex, "Should get a reportLeafIndex for Array"); + + const snapshotId = getState().snapshots[0].id; + ok(snapshotId, "Should have a snapshot id"); + + const breakdown = getState().censusDisplay.breakdown; + ok(breakdown, "Should have a breakdown"); + + dispatch( + fetchIndividuals(heapWorker, snapshotId, breakdown, reportLeafIndex) + ); + + for (const state of EXPECTED_INDIVIDUAL_STATES) { + await waitUntilState(store, s => { + return ( + s.view.state === viewState.INDIVIDUALS && + s.individuals && + s.individuals.state === state + ); + }); + ok(true, `Reached state = ${state}`); + } + + ok(getState().individuals, "Should have individuals state"); + ok(getState().individuals.nodes, "Should have individuals nodes"); + ok( + !!getState().individuals.nodes.length, + "Should have a positive number of nodes" + ); + + // Assert that all the individuals are `Array`s. + + for (const node of getState().individuals.nodes) { + dumpn("Checking node: " + node.label.join(" > ")); + ok( + node.label.find(part => part === "Array"), + "The node should be an Array node" + ); + } + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_individuals_05.js b/devtools/client/memory/test/xpcshell/test_individuals_05.js new file mode 100644 index 0000000000..506751916a --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_individuals_05.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test showing individual objects that do not have allocation stacks. + +const { + censusState, + viewState, + individualsState, + censusDisplays, +} = require("resource://devtools/client/memory/constants.js"); +const { + fetchIndividuals, + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); +const { + setCensusDisplay, +} = require("resource://devtools/client/memory/actions/census-display.js"); + +const EXPECTED_INDIVIDUAL_STATES = [ + individualsState.COMPUTING_DOMINATOR_TREE, + individualsState.FETCHING, + individualsState.FETCHED, +]; + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.CENSUS)); + dispatch(setCensusDisplay(censusDisplays.invertedAllocationStack)); + + // Take a snapshot and wait for the census to finish. + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.census, [censusState.SAVED]); + + // Fetch individuals. + + const root = getState().snapshots[0].census.report; + ok(root, "Should have a census"); + + const reportLeafIndex = findReportLeafIndex(root, "noStack"); + ok(reportLeafIndex, "Should get a reportLeafIndex for noStack"); + + const snapshotId = getState().snapshots[0].id; + ok(snapshotId, "Should have a snapshot id"); + + const breakdown = getState().censusDisplay.breakdown; + ok(breakdown, "Should have a breakdown"); + + dispatch( + fetchIndividuals(heapWorker, snapshotId, breakdown, reportLeafIndex) + ); + + for (const state of EXPECTED_INDIVIDUAL_STATES) { + await waitUntilState(store, s => { + return ( + s.view.state === viewState.INDIVIDUALS && + s.individuals && + s.individuals.state === state + ); + }); + ok(true, `Reached state = ${state}`); + } + + ok(getState().individuals, "Should have individuals state"); + ok(getState().individuals.nodes, "Should have individuals nodes"); + ok( + !!getState().individuals.nodes.length, + "Should have a positive number of nodes" + ); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_individuals_06.js b/devtools/client/memory/test/xpcshell/test_individuals_06.js new file mode 100644 index 0000000000..54aa759464 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_individuals_06.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that clearing the current individuals' snapshot leaves the individuals +// view. + +const { + censusState, + viewState, + individualsState, +} = require("resource://devtools/client/memory/constants.js"); +const { + fetchIndividuals, + takeSnapshotAndCensus, + clearSnapshots, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +const EXPECTED_INDIVIDUAL_STATES = [ + individualsState.COMPUTING_DOMINATOR_TREE, + individualsState.FETCHING, + individualsState.FETCHED, +]; + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.CENSUS)); + + // Take a snapshot and wait for the census to finish. + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.census, [censusState.SAVED]); + + // Fetch individuals. + + const root = getState().snapshots[0].census.report; + ok(root, "Should have a census"); + + const reportLeafIndex = findReportLeafIndex(root); + ok(reportLeafIndex, "Should get a reportLeafIndex"); + + const snapshotId = getState().snapshots[0].id; + ok(snapshotId, "Should have a snapshot id"); + + const breakdown = getState().censusDisplay.breakdown; + ok(breakdown, "Should have a breakdown"); + + dispatch( + fetchIndividuals(heapWorker, snapshotId, breakdown, reportLeafIndex) + ); + + for (const state of EXPECTED_INDIVIDUAL_STATES) { + await waitUntilState(store, s => { + return ( + s.view.state === viewState.INDIVIDUALS && + s.individuals && + s.individuals.state === state + ); + }); + ok(true, `Reached state = ${state}`); + } + + ok(getState().individuals, "Should have individuals state"); + ok(getState().individuals.nodes, "Should have individuals nodes"); + ok( + !!getState().individuals.nodes.length, + "Should have a positive number of nodes" + ); + + dispatch(clearSnapshots(heapWorker)); + + equal(getState().view.state, viewState.CENSUS, "Went back to census view"); + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_pop_view_01.js b/devtools/client/memory/test/xpcshell/test_pop_view_01.js new file mode 100644 index 0000000000..da44d1d125 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_pop_view_01.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test popping views from each intermediate individuals model state. + +const { + censusState, + viewState, + individualsState, +} = require("resource://devtools/client/memory/constants.js"); +const { + fetchIndividuals, + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + changeView, + popViewAndRefresh, +} = require("resource://devtools/client/memory/actions/view.js"); + +const TEST_STATES = [ + individualsState.COMPUTING_DOMINATOR_TREE, + individualsState.FETCHING, + individualsState.FETCHED, +]; + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + equal(getState().individuals, null, "no individuals state by default"); + + dispatch(changeView(viewState.CENSUS)); + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.census, [censusState.SAVED]); + + const root = getState().snapshots[0].census.report; + ok(root, "Should have a census"); + + const reportLeafIndex = findReportLeafIndex(root); + ok(reportLeafIndex, "Should get a reportLeafIndex"); + + const snapshotId = getState().snapshots[0].id; + ok(snapshotId, "Should have a snapshot id"); + + const breakdown = getState().snapshots[0].census.display.breakdown; + ok(breakdown, "Should have a breakdown"); + + for (const state of TEST_STATES) { + dumpn(`Testing popping back to the old view from state = ${state}`); + + dispatch( + fetchIndividuals(heapWorker, snapshotId, breakdown, reportLeafIndex) + ); + + // Wait for the expected test state. + await waitUntilState(store, s => { + return ( + s.view.state === viewState.INDIVIDUALS && + s.individuals && + s.individuals.state === state + ); + }); + ok(true, `Reached state = ${state}`); + + // Pop back to the CENSUS state. + dispatch(popViewAndRefresh(heapWorker)); + await waitUntilState(store, s => { + return s.view.state === viewState.CENSUS; + }); + ok(!getState().individuals, "Should no longer have individuals"); + } + + heapWorker.destroy(); + await front.detach(); +}); diff --git a/devtools/client/memory/test/xpcshell/test_tree-map-01.js b/devtools/client/memory/test/xpcshell/test_tree-map-01.js new file mode 100644 index 0000000000..1e91a43ec9 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_tree-map-01.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + drawBox, +} = require("resource://devtools/client/memory/components/tree-map/draw.js"); + +add_task(async function () { + let fillRectValues, strokeRectValues; + const ctx = { + fillRect: (...args) => { + fillRectValues = args; + }, + strokeRect: (...args) => { + strokeRectValues = args; + }, + }; + const node = { + x: 20, + y: 30, + dx: 50, + dy: 70, + type: "other", + depth: 2, + }; + const padding = [10, 10]; + const borderWidth = () => 1; + const dragZoom = { + offsetX: 0, + offsetY: 0, + zoom: 0, + }; + drawBox(ctx, node, borderWidth, dragZoom, padding); + ok(true, JSON.stringify([ctx, fillRectValues, strokeRectValues])); + equal(ctx.fillStyle, "hsl(204,60%,70%)", "The fillStyle is set"); + equal(ctx.strokeStyle, "hsl(204,60%,35%)", "The strokeStyle is set"); + equal(ctx.lineWidth, 1, "The lineWidth is set"); + deepEqual(fillRectValues, [10.5, 20.5, 49, 69], "Draws a filled rectangle"); + deepEqual( + strokeRectValues, + [10.5, 20.5, 49, 69], + "Draws a stroked rectangle" + ); + + dragZoom.zoom = 0.5; + + drawBox(ctx, node, borderWidth, dragZoom, padding); + ok(true, JSON.stringify([ctx, fillRectValues, strokeRectValues])); + deepEqual( + fillRectValues, + [15.5, 30.5, 74, 104], + "Draws a zoomed filled rectangle" + ); + deepEqual( + strokeRectValues, + [15.5, 30.5, 74, 104], + "Draws a zoomed stroked rectangle" + ); + + dragZoom.offsetX = 110; + dragZoom.offsetY = 130; + + drawBox(ctx, node, borderWidth, dragZoom, padding); + deepEqual( + fillRectValues, + [-94.5, -99.5, 74, 104], + "Draws a zoomed and offset filled rectangle" + ); + deepEqual( + strokeRectValues, + [-94.5, -99.5, 74, 104], + "Draws a zoomed and offset stroked rectangle" + ); +}); diff --git a/devtools/client/memory/test/xpcshell/test_tree-map-02.js b/devtools/client/memory/test/xpcshell/test_tree-map-02.js new file mode 100644 index 0000000000..528c59dcfa --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_tree-map-02.js @@ -0,0 +1,105 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + drawText, +} = require("resource://devtools/client/memory/components/tree-map/draw.js"); + +add_task(async function () { + // Mock out the Canvas2dContext + const ctx = { + fillText: (...args) => fillTextValues.push(args), + measureText: text => { + const width = text ? text.length * 10 : 0; + return { width }; + }, + }; + const node = { + x: 20, + y: 30, + dx: 500, + dy: 70, + name: "Example Node", + totalBytes: 1200, + totalCount: 100, + }; + const ratio = 0; + const borderWidth = () => 1; + const dragZoom = { + offsetX: 0, + offsetY: 0, + zoom: 0, + }; + let fillTextValues = []; + const padding = [10, 10]; + + drawText(ctx, node, borderWidth, ratio, dragZoom, padding); + deepEqual( + fillTextValues[0], + ["Example Node", 11.5, 21.5], + "Fills in the full node name" + ); + deepEqual( + fillTextValues[1], + ["1KiB 100 count", 141.5, 21.5], + "Includes the full byte and count information" + ); + + fillTextValues = []; + node.dx = 250; + drawText(ctx, node, borderWidth, ratio, dragZoom, padding); + + deepEqual( + fillTextValues[0], + ["Example Node", 11.5, 21.5], + "Fills in the full node name" + ); + deepEqual( + fillTextValues[1], + undefined, + "Drops off the byte and count information if not enough room" + ); + + fillTextValues = []; + node.dx = 100; + drawText(ctx, node, borderWidth, ratio, dragZoom, padding); + + deepEqual( + fillTextValues[0], + ["Exampl...", 11.5, 21.5], + "Cuts the name with ellipsis" + ); + deepEqual( + fillTextValues[1], + undefined, + "Drops off the byte and count information if not enough room" + ); + + fillTextValues = []; + node.dx = 40; + drawText(ctx, node, borderWidth, ratio, dragZoom, padding); + + deepEqual( + fillTextValues[0], + ["...", 11.5, 21.5], + "Shows only ellipsis when smaller" + ); + deepEqual( + fillTextValues[1], + undefined, + "Drops off the byte and count information if not enough room" + ); + + fillTextValues = []; + node.dx = 20; + drawText(ctx, node, borderWidth, ratio, dragZoom, padding); + + deepEqual(fillTextValues[0], undefined, "Draw nothing when not enough room"); + deepEqual( + fillTextValues[1], + undefined, + "Drops off the byte and count information if not enough room" + ); +}); diff --git a/devtools/client/memory/test/xpcshell/test_utils-get-snapshot-totals.js b/devtools/client/memory/test/xpcshell/test_utils-get-snapshot-totals.js new file mode 100644 index 0000000000..13ad5958a7 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_utils-get-snapshot-totals.js @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that we use the correct snapshot aggregate value + * in `utils.getSnapshotTotals(snapshot)` + */ + +const { + censusDisplays, + viewState, + censusState, +} = require("resource://devtools/client/memory/constants.js"); +const { + getSnapshotTotals, +} = require("resource://devtools/client/memory/utils.js"); +const { + takeSnapshotAndCensus, +} = require("resource://devtools/client/memory/actions/snapshot.js"); +const { + setCensusDisplayAndRefresh, +} = require("resource://devtools/client/memory/actions/census-display.js"); +const { + changeView, +} = require("resource://devtools/client/memory/actions/view.js"); + +add_task(async function () { + const front = new StubbedMemoryFront(); + const heapWorker = new HeapAnalysesClient(); + await front.attach(); + const store = Store(); + const { getState, dispatch } = store; + + dispatch(changeView(viewState.CENSUS)); + + await dispatch( + setCensusDisplayAndRefresh(heapWorker, censusDisplays.allocationStack) + ); + + dispatch(takeSnapshotAndCensus(front, heapWorker)); + await waitUntilCensusState(store, s => s.census, [censusState.SAVED]); + + ok( + !getState().snapshots[0].census.display.inverted, + "Snapshot is not inverted" + ); + + const census = getState().snapshots[0].census; + let result = aggregate(census.report); + const totalBytes = result.bytes; + const totalCount = result.count; + + ok(totalBytes > 0, "counted up bytes in the census"); + ok(totalCount > 0, "counted up count in the census"); + + result = getSnapshotTotals(getState().snapshots[0].census); + equal( + totalBytes, + result.bytes, + "getSnapshotTotals reuslted in correct bytes" + ); + equal( + totalCount, + result.count, + "getSnapshotTotals reuslted in correct count" + ); + + dispatch( + setCensusDisplayAndRefresh( + heapWorker, + censusDisplays.invertedAllocationStack + ) + ); + + await waitUntilCensusState(store, s => s.census, [censusState.SAVING]); + await waitUntilCensusState(store, s => s.census, [censusState.SAVED]); + ok(getState().snapshots[0].census.display.inverted, "Snapshot is inverted"); + + result = getSnapshotTotals(getState().snapshots[0].census); + equal( + totalBytes, + result.bytes, + "getSnapshotTotals reuslted in correct bytes when inverted" + ); + equal( + totalCount, + result.count, + "getSnapshotTotals reuslted in correct count when inverted" + ); +}); + +function aggregate(report) { + let totalBytes = report.bytes; + let totalCount = report.count; + for (const child of report.children || []) { + const { bytes, count } = aggregate(child); + totalBytes += bytes; + totalCount += count; + } + return { bytes: totalBytes, count: totalCount }; +} diff --git a/devtools/client/memory/test/xpcshell/test_utils.js b/devtools/client/memory/test/xpcshell/test_utils.js new file mode 100644 index 0000000000..1d58552e56 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/test_utils.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the task creator `takeSnapshotAndCensus()` for the whole flow of + * taking a snapshot, and its sub-actions. Tests the formatNumber and + * formatPercent methods. + */ + +const utils = require("resource://devtools/client/memory/utils.js"); +const { + snapshotState: states, + viewState, +} = require("resource://devtools/client/memory/constants.js"); +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +add_task(async function () { + const s1 = utils.createSnapshot({ view: { state: viewState.CENSUS } }); + const s2 = utils.createSnapshot({ view: { state: viewState.CENSUS } }); + equal( + s1.state, + states.SAVING, + "utils.createSnapshot() creates snapshot in saving state" + ); + ok( + s1.id !== s2.id, + "utils.createSnapshot() creates snapshot with unique ids" + ); + + const custom = { by: "internalType", then: { by: "count", bytes: true } }; + Preferences.set( + "devtools.memory.custom-census-displays", + JSON.stringify({ "My Display": custom }) + ); + + equal( + utils.getCustomCensusDisplays()["My Display"].by, + custom.by, + "utils.getCustomCensusDisplays() returns custom displays" + ); + + ok(true, "test formatNumber util functions"); + equal(utils.formatNumber(12), "12", "formatNumber returns 12 for 12"); + + equal(utils.formatNumber(0), "0", "formatNumber returns 0 for 0"); + equal(utils.formatNumber(-0), "0", "formatNumber returns 0 for -0"); + equal(utils.formatNumber(+0), "0", "formatNumber returns 0 for +0"); + + equal( + utils.formatNumber(1234567), + "1 234 567", + "formatNumber adds a space every 3rd digit" + ); + equal( + utils.formatNumber(12345678), + "12 345 678", + "formatNumber adds a space every 3rd digit" + ); + equal( + utils.formatNumber(123456789), + "123 456 789", + "formatNumber adds a space every 3rd digit" + ); + + equal( + utils.formatNumber(12, true), + "+12", + "formatNumber can display number sign" + ); + equal( + utils.formatNumber(-12, true), + "-12", + "formatNumber can display number sign (negative)" + ); + + ok(true, "test formatPercent util functions"); + equal(utils.formatPercent(12), "12%", "formatPercent returns 12% for 12"); + equal( + utils.formatPercent(12345), + "12 345%", + "formatPercent returns 12 345% for 12345" + ); + + equal(utils.formatAbbreviatedBytes(12), "12B", "Formats bytes"); + equal(utils.formatAbbreviatedBytes(12345), "12KiB", "Formats kilobytes"); + equal(utils.formatAbbreviatedBytes(12345678), "11MiB", "Formats megabytes"); + equal( + utils.formatAbbreviatedBytes(12345678912), + "11GiB", + "Formats gigabytes" + ); + + equal( + utils.hslToStyle(0.5, 0.6, 0.7), + "hsl(180,60%,70%)", + "hslToStyle converts an array to a style string" + ); + equal( + utils.hslToStyle(0, 0, 0), + "hsl(0,0%,0%)", + "hslToStyle converts an array to a style string" + ); + equal( + utils.hslToStyle(1, 1, 1), + "hsl(360,100%,100%)", + "hslToStyle converts an array to a style string" + ); + + equal(utils.lerp(5, 7, 0), 5, "lerp return first number for 0"); + equal(utils.lerp(5, 7, 1), 7, "lerp return second number for 1"); + equal(utils.lerp(5, 7, 0.5), 6, "lerp interpolates the numbers for 0.5"); +}); diff --git a/devtools/client/memory/test/xpcshell/xpcshell.ini b/devtools/client/memory/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..a20ddb1462 --- /dev/null +++ b/devtools/client/memory/test/xpcshell/xpcshell.ini @@ -0,0 +1,58 @@ +[DEFAULT] +tags = devtools devtools-memory +head = ../../../shared/test/shared-head.js head.js +firefox-appdir = browser +skip-if = toolkit == 'android' + +[test_action_diffing_01.js] +[test_action_diffing_02.js] +[test_action_diffing_03.js] +[test_action_diffing_04.js] +[test_action_diffing_05.js] +[test_action-clear-snapshots_01.js] +[test_action-clear-snapshots_02.js] +[test_action-clear-snapshots_03.js] +[test_action-clear-snapshots_04.js] +[test_action-clear-snapshots_05.js] +[test_action-clear-snapshots_06.js] +[test_action-export-snapshot.js] +[test_action-filter-01.js] +[test_action-filter-02.js] +[test_action-filter-03.js] +[test_action-import-snapshot-and-census.js] +[test_action-import-snapshot-dominator-tree.js] +[test_action-select-snapshot.js] +[test_action-set-display.js] +[test_action-set-display-and-refresh-01.js] +[test_action-set-display-and-refresh-02.js] +[test_action-take-census.js] +skip-if = tsan # Unreasonably slow, bug 1612707 +[test_action-take-snapshot.js] +[test_action-take-snapshot-and-census.js] +[test_action-toggle-inverted.js] +[test_action-toggle-inverted-and-refresh-01.js] +[test_action-toggle-inverted-and-refresh-02.js] +[test_action-toggle-recording-allocations.js] +[test_dominator_trees_01.js] +[test_dominator_trees_02.js] +[test_dominator_trees_03.js] +[test_dominator_trees_04.js] +skip-if = tsan # Unreasonably slow, bug 1612707 +[test_dominator_trees_05.js] +[test_dominator_trees_06.js] +[test_dominator_trees_07.js] +[test_dominator_trees_08.js] +[test_dominator_trees_09.js] +[test_dominator_trees_10.js] +[test_individuals_01.js] +[test_individuals_02.js] +[test_individuals_03.js] +[test_individuals_04.js] +[test_individuals_05.js] +skip-if = tsan # Times out, bug 1612707 +[test_individuals_06.js] +[test_pop_view_01.js] +[test_tree-map-01.js] +[test_tree-map-02.js] +[test_utils.js] +[test_utils-get-snapshot-totals.js] diff --git a/devtools/client/memory/utils.js b/devtools/client/memory/utils.js new file mode 100644 index 0000000000..09f7f909c2 --- /dev/null +++ b/devtools/client/memory/utils.js @@ -0,0 +1,547 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const STRINGS_URI = "devtools/client/locales/memory.properties"; +const L10N = (exports.L10N = new LocalizationHelper(STRINGS_URI)); + +const { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); +const CUSTOM_CENSUS_DISPLAY_PREF = "devtools.memory.custom-census-displays"; +const CUSTOM_LABEL_DISPLAY_PREF = "devtools.memory.custom-label-displays"; +const CUSTOM_TREE_MAP_DISPLAY_PREF = "devtools.memory.custom-tree-map-displays"; +const BYTES = 1024; +const KILOBYTES = Math.pow(BYTES, 2); +const MEGABYTES = Math.pow(BYTES, 3); +const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +const { + snapshotState: states, + diffingState, + censusState, + treeMapState, + dominatorTreeState, + individualsState, +} = require("resource://devtools/client/memory/constants.js"); + +/** + * Takes a snapshot object and returns the localized form of its timestamp to be + * used as a title. + * + * @param {Snapshot} snapshot + * @return {String} + */ +exports.getSnapshotTitle = function (snapshot) { + if (!snapshot.creationTime) { + return L10N.getStr("snapshot-title.loading"); + } + + if (snapshot.imported) { + // Strip out the extension if it's the expected ".fxsnapshot" + return PathUtils.filename(snapshot.path.replace(/\.fxsnapshot$/, "")); + } + + const date = new Date(snapshot.creationTime / 1000); + return date.toLocaleTimeString(void 0, { + year: "2-digit", + month: "2-digit", + day: "2-digit", + hour12: false, + }); +}; + +function getCustomDisplaysHelper(pref) { + let customDisplays = Object.create(null); + try { + customDisplays = JSON.parse(Preferences.get(pref)) || Object.create(null); + } catch (e) { + DevToolsUtils.reportException( + `String stored in "${pref}" pref cannot be parsed by \`JSON.parse()\`.` + ); + } + return Object.freeze(customDisplays); +} + +/** + * Returns custom displays defined in `devtools.memory.custom-census-displays` + * pref. + * + * @return {Object} + */ +exports.getCustomCensusDisplays = function () { + return getCustomDisplaysHelper(CUSTOM_CENSUS_DISPLAY_PREF); +}; + +/** + * Returns custom displays defined in + * `devtools.memory.custom-label-displays` pref. + * + * @return {Object} + */ +exports.getCustomLabelDisplays = function () { + return getCustomDisplaysHelper(CUSTOM_LABEL_DISPLAY_PREF); +}; + +/** + * Returns custom displays defined in + * `devtools.memory.custom-tree-map-displays` pref. + * + * @return {Object} + */ +exports.getCustomTreeMapDisplays = function () { + return getCustomDisplaysHelper(CUSTOM_TREE_MAP_DISPLAY_PREF); +}; + +/** + * Returns a string representing a readable form of the snapshot's state. More + * concise than `getStatusTextFull`. + * + * @param {snapshotState | diffingState} state + * @return {String} + */ +// eslint-disable-next-line complexity +exports.getStatusText = function (state) { + assert(state, "Must have a state"); + + switch (state) { + case diffingState.ERROR: + return L10N.getStr("diffing.state.error"); + + case states.ERROR: + return L10N.getStr("snapshot.state.error"); + + case states.SAVING: + return L10N.getStr("snapshot.state.saving"); + + case states.IMPORTING: + return L10N.getStr("snapshot.state.importing"); + + case states.SAVED: + case states.READING: + return L10N.getStr("snapshot.state.reading"); + + case censusState.SAVING: + return L10N.getStr("snapshot.state.saving-census"); + + case treeMapState.SAVING: + return L10N.getStr("snapshot.state.saving-tree-map"); + + case diffingState.TAKING_DIFF: + return L10N.getStr("diffing.state.taking-diff"); + + case diffingState.SELECTING: + return L10N.getStr("diffing.state.selecting"); + + case dominatorTreeState.COMPUTING: + case individualsState.COMPUTING_DOMINATOR_TREE: + return L10N.getStr("dominatorTree.state.computing"); + + case dominatorTreeState.COMPUTED: + case dominatorTreeState.FETCHING: + return L10N.getStr("dominatorTree.state.fetching"); + + case dominatorTreeState.INCREMENTAL_FETCHING: + return L10N.getStr("dominatorTree.state.incrementalFetching"); + + case dominatorTreeState.ERROR: + return L10N.getStr("dominatorTree.state.error"); + + case individualsState.ERROR: + return L10N.getStr("individuals.state.error"); + + case individualsState.FETCHING: + return L10N.getStr("individuals.state.fetching"); + + // These states do not have any message to show as other content will be + // displayed. + case dominatorTreeState.LOADED: + case diffingState.TOOK_DIFF: + case states.READ: + case censusState.SAVED: + case treeMapState.SAVED: + case individualsState.FETCHED: + return ""; + + default: + assert(false, `Unexpected state: ${state}`); + return ""; + } +}; + +/** + * Returns a string representing a readable form of the snapshot's state; + * more verbose than `getStatusText`. + * + * @param {snapshotState | diffingState} state + * @return {String} + */ +// eslint-disable-next-line complexity +exports.getStatusTextFull = function (state) { + assert(!!state, "Must have a state"); + + switch (state) { + case diffingState.ERROR: + return L10N.getStr("diffing.state.error.full"); + + case states.ERROR: + return L10N.getStr("snapshot.state.error.full"); + + case states.SAVING: + return L10N.getStr("snapshot.state.saving.full"); + + case states.IMPORTING: + return L10N.getStr("snapshot.state.importing"); + + case states.SAVED: + case states.READING: + return L10N.getStr("snapshot.state.reading.full"); + + case censusState.SAVING: + return L10N.getStr("snapshot.state.saving-census.full"); + + case treeMapState.SAVING: + return L10N.getStr("snapshot.state.saving-tree-map.full"); + + case diffingState.TAKING_DIFF: + return L10N.getStr("diffing.state.taking-diff.full"); + + case diffingState.SELECTING: + return L10N.getStr("diffing.state.selecting.full"); + + case dominatorTreeState.COMPUTING: + case individualsState.COMPUTING_DOMINATOR_TREE: + return L10N.getStr("dominatorTree.state.computing.full"); + + case dominatorTreeState.COMPUTED: + case dominatorTreeState.FETCHING: + return L10N.getStr("dominatorTree.state.fetching.full"); + + case dominatorTreeState.INCREMENTAL_FETCHING: + return L10N.getStr("dominatorTree.state.incrementalFetching.full"); + + case dominatorTreeState.ERROR: + return L10N.getStr("dominatorTree.state.error.full"); + + case individualsState.ERROR: + return L10N.getStr("individuals.state.error.full"); + + case individualsState.FETCHING: + return L10N.getStr("individuals.state.fetching.full"); + + // These states do not have any full message to show as other content will + // be displayed. + case dominatorTreeState.LOADED: + case diffingState.TOOK_DIFF: + case states.READ: + case censusState.SAVED: + case treeMapState.SAVED: + case individualsState.FETCHED: + return ""; + + default: + assert(false, `Unexpected state: ${state}`); + return ""; + } +}; + +/** + * Return true if the snapshot is in a diffable state, false otherwise. + * + * @param {snapshotModel} snapshot + * @returns {Boolean} + */ +exports.snapshotIsDiffable = function snapshotIsDiffable(snapshot) { + return ( + (snapshot.census && snapshot.census.state === censusState.SAVED) || + (snapshot.census && snapshot.census.state === censusState.SAVING) || + snapshot.state === states.SAVED || + snapshot.state === states.READ + ); +}; + +/** + * Takes an array of snapshots and a snapshot and returns + * the snapshot instance in `snapshots` that matches + * the snapshot passed in. + * + * @param {appModel} state + * @param {snapshotId} id + * @return {snapshotModel|null} + */ +exports.getSnapshot = function getSnapshot(state, id) { + const found = state.snapshots.find(s => s.id === id); + assert(found, `No matching snapshot found with id = ${id}`); + return found; +}; + +/** + * Get the ID of the selected snapshot, if one is selected, null otherwise. + * + * @returns {SnapshotId|null} + */ +exports.findSelectedSnapshot = function (state) { + const found = state.snapshots.find(s => s.selected); + return found ? found.id : null; +}; + +/** + * Creates a new snapshot object. + * + * @param {appModel} state + * @return {Snapshot} + */ +let ID_COUNTER = 0; +exports.createSnapshot = function createSnapshot(state) { + let dominatorTree = null; + if (state.view.state === dominatorTreeState.DOMINATOR_TREE) { + dominatorTree = Object.freeze({ + dominatorTreeId: null, + root: null, + error: null, + state: dominatorTreeState.COMPUTING, + }); + } + + return Object.freeze({ + id: ++ID_COUNTER, + state: states.SAVING, + dominatorTree, + census: null, + treeMap: null, + path: null, + imported: false, + selected: false, + error: null, + }); +}; + +/** + * Return true if the census is up to date with regards to the current filtering + * and requested display, false otherwise. + * + * @param {String} filter + * @param {censusDisplayModel} display + * @param {censusModel} census + * + * @returns {Boolean} + */ +exports.censusIsUpToDate = function (filter, display, census) { + return ( + census && + // Filter could be null == undefined so use loose equality. + filter == census.filter && + display === census.display + ); +}; + +/** + * Check to see if the snapshot is in a state that it can take a census. + * + * @param {SnapshotModel} A snapshot to check. + * @param {Boolean} Assert that the snapshot must be in a ready state. + * @returns {Boolean} + */ +exports.canTakeCensus = function (snapshot) { + return ( + snapshot.state === states.READ && + (!snapshot.census || + snapshot.census.state === censusState.SAVED || + !snapshot.treeMap || + snapshot.treeMap.state === treeMapState.SAVED) + ); +}; + +/** + * Returns true if the given snapshot's dominator tree has been computed, false + * otherwise. + * + * @param {SnapshotModel} snapshot + * @returns {Boolean} + */ +exports.dominatorTreeIsComputed = function (snapshot) { + return ( + snapshot.dominatorTree && + (snapshot.dominatorTree.state === dominatorTreeState.COMPUTED || + snapshot.dominatorTree.state === dominatorTreeState.LOADED || + snapshot.dominatorTree.state === dominatorTreeState.INCREMENTAL_FETCHING) + ); +}; + +/** + * Find the first SAVED census, either from the tree map or the normal + * census. + * + * @param {SnapshotModel} snapshot + * @returns {Object|null} Either the census, or null if one hasn't completed + */ +exports.getSavedCensus = function (snapshot) { + if (snapshot.treeMap && snapshot.treeMap.state === treeMapState.SAVED) { + return snapshot.treeMap; + } + if (snapshot.census && snapshot.census.state === censusState.SAVED) { + return snapshot.census; + } + return null; +}; + +/** + * Takes a snapshot and returns the total bytes and total count that this + * snapshot represents. + * + * @param {CensusModel} census + * @return {Object} + */ +exports.getSnapshotTotals = function (census) { + let bytes = 0; + let count = 0; + + const report = census.report; + if (report) { + bytes = report.totalBytes; + count = report.totalCount; + } + + return { bytes, count }; +}; + +/** + * Takes some configurations and opens up a file picker and returns + * a promise to the chosen file if successful. + * + * @param {String} .title + * The title displayed in the file picker window. + * @param {Array<Array<String>>} .filters + * An array of filters to display in the file picker. Each filter in the array + * is a duple of two strings, one a name for the filter, and one the filter itself + * (like "*.json"). + * @param {String} .defaultName + * The default name chosen by the file picker window. + * @param {String} .mode + * The mode that this filepicker should open in. Can be "open" or "save". + * @return {Promise<?nsIFile>} + * The file selected by the user, or null, if cancelled. + */ +exports.openFilePicker = function ({ title, filters, defaultName, mode }) { + let fpMode; + if (mode === "save") { + fpMode = Ci.nsIFilePicker.modeSave; + } else if (mode === "open") { + fpMode = Ci.nsIFilePicker.modeOpen; + } else { + throw new Error("No valid mode specified for nsIFilePicker."); + } + + const fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(window, title, fpMode); + + for (const filter of filters || []) { + fp.appendFilter(filter[0], filter[1]); + } + fp.defaultString = defaultName; + + return new Promise(resolve => { + fp.open({ + done: result => { + if (result === Ci.nsIFilePicker.returnCancel) { + resolve(null); + return; + } + resolve(fp.file); + }, + }); + }); +}; + +/** + * Format the provided number with a space every 3 digits, and optionally + * prefixed by its sign. + * + * @param {Number} number + * @param {Boolean} showSign (defaults to false) + */ +exports.formatNumber = function (number, showSign = false) { + const rounded = Math.round(number); + // eslint-disable-next-line no-compare-neg-zero + if (rounded === 0 || rounded === -0) { + return "0"; + } + + const abs = String(Math.abs(rounded)); + // replace every digit followed by (sets of 3 digits) by (itself and a space) + const formatted = abs.replace(/(\d)(?=(\d{3})+$)/g, "$1 "); + + if (showSign) { + const sign = rounded < 0 ? "-" : "+"; + return sign + formatted; + } + return formatted; +}; + +/** + * Format the provided percentage following the same logic as formatNumber and + * an additional % suffix. + * + * @param {Number} percent + * @param {Boolean} showSign (defaults to false) + */ +exports.formatPercent = function (percent, showSign = false) { + return exports.L10N.getFormatStr( + "tree-item.percent2", + exports.formatNumber(percent, showSign) + ); +}; + +/** + * Change an HSL color array with values ranged 0-1 to a properly formatted + * ctx.fillStyle string. + * + * @param {Number} h + * hue values ranged between [0 - 1] + * @param {Number} s + * hue values ranged between [0 - 1] + * @param {Number} l + * hue values ranged between [0 - 1] + * @return {type} + */ +exports.hslToStyle = function (h, s, l) { + h = parseInt(h * 360, 10); + s = parseInt(s * 100, 10); + l = parseInt(l * 100, 10); + + return `hsl(${h},${s}%,${l}%)`; +}; + +/** + * Linearly interpolate between 2 numbers. + * + * @param {Number} a + * @param {Number} b + * @param {Number} t + * A value of 0 returns a, and 1 returns b + * @return {Number} + */ +exports.lerp = function (a, b, t) { + return a * (1 - t) + b * t; +}; + +/** + * Format a number of bytes as human readable, e.g. 13434 => '13KiB'. + * + * @param {Number} n + * Number of bytes + * @return {String} + */ +exports.formatAbbreviatedBytes = function (n) { + if (n < BYTES) { + return n + "B"; + } else if (n < KILOBYTES) { + return Math.floor(n / BYTES) + "KiB"; + } else if (n < MEGABYTES) { + return Math.floor(n / KILOBYTES) + "MiB"; + } + return Math.floor(n / MEGABYTES) + "GiB"; +}; |