diff options
Diffstat (limited to 'devtools/client/inspector/changes')
39 files changed, 2964 insertions, 0 deletions
diff --git a/devtools/client/inspector/changes/ChangesContextMenu.js b/devtools/client/inspector/changes/ChangesContextMenu.js new file mode 100644 index 0000000000..40257ec898 --- /dev/null +++ b/devtools/client/inspector/changes/ChangesContextMenu.js @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Menu = require("resource://devtools/client/framework/menu.js"); +loader.lazyRequireGetter( + this, + "MenuItem", + "resource://devtools/client/framework/menu-item.js" +); + +const { + getStr, +} = require("resource://devtools/client/inspector/changes/utils/l10n.js"); + +/** + * Context menu for the Changes panel with options to select, copy and export CSS changes. + */ +class ChangesContextMenu extends Menu { + constructor(config = {}) { + super(config); + this.onCopy = config.onCopy; + this.onCopyAllChanges = config.onCopyAllChanges; + this.onCopyDeclaration = config.onCopyDeclaration; + this.onCopyRule = config.onCopyRule; + this.onSelectAll = config.onSelectAll; + this.toolboxDocument = config.toolboxDocument; + this.window = config.window; + } + + show(event) { + this._openMenu({ + target: event.target, + screenX: event.screenX, + screenY: event.screenY, + }); + } + + _openMenu({ target, screenX = 0, screenY = 0 } = {}) { + this.window.focus(); + // Remove existing menu items. + this.clear(); + + // Copy option + const menuitemCopy = new MenuItem({ + id: "changes-contextmenu-copy", + label: getStr("changes.contextmenu.copy"), + accesskey: getStr("changes.contextmenu.copy.accessKey"), + click: this.onCopy, + disabled: !this._hasTextSelected(), + }); + this.append(menuitemCopy); + + const declEl = target.closest(".changes__declaration"); + const ruleEl = target.closest("[data-rule-id]"); + const ruleId = ruleEl ? ruleEl.dataset.ruleId : null; + + if (ruleId || declEl) { + // Copy Rule option + this.append( + new MenuItem({ + id: "changes-contextmenu-copy-rule", + label: getStr("changes.contextmenu.copyRule"), + click: () => this.onCopyRule(ruleId, true), + }) + ); + + // Copy Declaration option. Visible only if there is a declaration element target. + this.append( + new MenuItem({ + id: "changes-contextmenu-copy-declaration", + label: getStr("changes.contextmenu.copyDeclaration"), + click: () => this.onCopyDeclaration(declEl), + visible: !!declEl, + }) + ); + + this.append( + new MenuItem({ + type: "separator", + }) + ); + } + + // Select All option + const menuitemSelectAll = new MenuItem({ + id: "changes-contextmenu-select-all", + label: getStr("changes.contextmenu.selectAll"), + accesskey: getStr("changes.contextmenu.selectAll.accessKey"), + click: this.onSelectAll, + }); + this.append(menuitemSelectAll); + + this.popup(screenX, screenY, this.toolboxDocument); + } + + _hasTextSelected() { + const selection = this.window.getSelection(); + return selection.toString() && !selection.isCollapsed; + } + + destroy() { + this.window = null; + this.toolboxDocument = null; + } +} + +module.exports = ChangesContextMenu; diff --git a/devtools/client/inspector/changes/ChangesView.js b/devtools/client/inspector/changes/ChangesView.js new file mode 100644 index 0000000000..20c70137a7 --- /dev/null +++ b/devtools/client/inspector/changes/ChangesView.js @@ -0,0 +1,284 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + createFactory, + createElement, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + Provider, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +loader.lazyRequireGetter( + this, + "ChangesContextMenu", + "resource://devtools/client/inspector/changes/ChangesContextMenu.js" +); +loader.lazyRequireGetter( + this, + "clipboardHelper", + "resource://devtools/shared/platform/clipboard.js" +); + +const changesReducer = require("resource://devtools/client/inspector/changes/reducers/changes.js"); +const { + getChangesStylesheet, +} = require("resource://devtools/client/inspector/changes/selectors/changes.js"); +const { + resetChanges, + trackChange, +} = require("resource://devtools/client/inspector/changes/actions/changes.js"); + +const ChangesApp = createFactory( + require("resource://devtools/client/inspector/changes/components/ChangesApp.js") +); + +class ChangesView { + constructor(inspector, window) { + this.document = window.document; + this.inspector = inspector; + this.store = this.inspector.store; + this.telemetry = this.inspector.telemetry; + this.window = window; + + this.store.injectReducer("changes", changesReducer); + + this.onAddChange = this.onAddChange.bind(this); + this.onContextMenu = this.onContextMenu.bind(this); + this.onCopy = this.onCopy.bind(this); + this.onCopyAllChanges = this.copyAllChanges.bind(this); + this.onCopyDeclaration = this.copyDeclaration.bind(this); + this.onCopyRule = this.copyRule.bind(this); + this.onClearChanges = this.onClearChanges.bind(this); + this.onSelectAll = this.onSelectAll.bind(this); + this.onResourceAvailable = this.onResourceAvailable.bind(this); + + this.destroy = this.destroy.bind(this); + + this.init(); + } + + get contextMenu() { + if (!this._contextMenu) { + this._contextMenu = new ChangesContextMenu({ + onCopy: this.onCopy, + onCopyAllChanges: this.onCopyAllChanges, + onCopyDeclaration: this.onCopyDeclaration, + onCopyRule: this.onCopyRule, + onSelectAll: this.onSelectAll, + toolboxDocument: this.inspector.toolbox.doc, + window: this.window, + }); + } + + return this._contextMenu; + } + + get resourceCommand() { + return this.inspector.toolbox.resourceCommand; + } + + init() { + const changesApp = ChangesApp({ + onContextMenu: this.onContextMenu, + onCopyAllChanges: this.onCopyAllChanges, + onCopyRule: this.onCopyRule, + }); + + // Expose the provider to let inspector.js use it in setupSidebar. + this.provider = createElement( + Provider, + { + id: "changesview", + key: "changesview", + store: this.store, + }, + changesApp + ); + + this.watchResources(); + } + + async watchResources() { + await this.resourceCommand.watchResources( + [this.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this.onResourceAvailable, + // Ignore any DOCUMENT_EVENT resources that have occured in the past + // and are cached by the resource command, otherwise the Changes panel will + // react to them erroneously and interpret that the document is reloading *now* + // which leads to clearing all stored changes. + ignoreExistingResources: true, + } + ); + + await this.resourceCommand.watchResources( + [this.resourceCommand.TYPES.CSS_CHANGE], + { onAvailable: this.onResourceAvailable } + ); + } + + onResourceAvailable(resources) { + for (const resource of resources) { + if (resource.resourceType === this.resourceCommand.TYPES.CSS_CHANGE) { + this.onAddChange(resource); + continue; + } + + if (resource.name === "dom-loading" && resource.targetFront.isTopLevel) { + // will-navigate doesn't work when we navigate to a new process, + // and for now, onTargetAvailable/onTargetDestroyed doesn't fire on navigation and + // only when navigating to another process. + // So we fallback on DOCUMENT_EVENTS to be notified when we navigate. When we + // navigate within the same process as well as when we navigate to a new process. + // (We would probably revisit that in bug 1632141) + this.onClearChanges(); + } + } + } + + /** + * Handler for the "Copy All Changes" button. Simple wrapper that just calls + * |this.copyChanges()| with no filters in order to trigger default operation. + */ + copyAllChanges() { + this.copyChanges(); + } + + /** + * Handler for the "Copy Changes" option from the context menu. + * Builds a CSS text with the aggregated changes and copies it to the clipboard. + * + * Optional rule and source ids can be used to filter the scope of the operation: + * - if both a rule id and source id are provided, copy only the changes to the + * matching rule within the matching source. + * - if only a source id is provided, copy the changes to all rules within the + * matching source. + * - if neither rule id nor source id are provided, copy the changes too all rules + * within all sources. + * + * @param {String|null} ruleId + * Optional rule id. + * @param {String|null} sourceId + * Optional source id. + */ + copyChanges(ruleId, sourceId) { + const state = this.store.getState().changes || {}; + const filter = {}; + if (ruleId) { + filter.ruleIds = [ruleId]; + } + if (sourceId) { + filter.sourceIds = [sourceId]; + } + + const text = getChangesStylesheet(state, filter); + clipboardHelper.copyString(text); + } + + /** + * Handler for the "Copy Declaration" option from the context menu. + * Builds a CSS declaration string with the property name and value, and copies it + * to the clipboard. The declaration is commented out if it is marked as removed. + * + * @param {DOMElement} element + * Host element of a CSS declaration rendered the Changes panel. + */ + copyDeclaration(element) { + const name = element.querySelector( + ".changes__declaration-name" + ).textContent; + const value = element.querySelector( + ".changes__declaration-value" + ).textContent; + const isRemoved = element.classList.contains("diff-remove"); + const text = isRemoved ? `/* ${name}: ${value}; */` : `${name}: ${value};`; + clipboardHelper.copyString(text); + } + + /** + * Handler for the "Copy Rule" option from the context menu and "Copy Rule" button. + * Gets the full content of the target CSS rule (including any changes applied) + * and copies it to the clipboard. + * + * @param {String} ruleId + * Rule id of the target CSS rule. + */ + async copyRule(ruleId) { + const inspectorFronts = await this.inspector.getAllInspectorFronts(); + + for (const inspectorFront of inspectorFronts) { + const rule = await inspectorFront.pageStyle.getRule(ruleId); + + if (rule) { + const text = await rule.getRuleText(); + clipboardHelper.copyString(text); + break; + } + } + } + + /** + * Handler for the "Copy" option from the context menu. + * Copies the current text selection to the clipboard. + */ + onCopy() { + clipboardHelper.copyString(this.window.getSelection().toString()); + } + + onAddChange(change) { + // Turn data into a suitable change to send to the store. + this.store.dispatch(trackChange(change)); + } + + onClearChanges() { + this.store.dispatch(resetChanges()); + } + + /** + * Select all text. + */ + onSelectAll() { + const selection = this.window.getSelection(); + selection.selectAllChildren( + this.document.getElementById("sidebar-panel-changes") + ); + } + + /** + * Event handler for the "contextmenu" event fired when the context menu is requested. + * @param {Event} e + */ + onContextMenu(e) { + this.contextMenu.show(e); + } + + /** + * Destruction function called when the inspector is destroyed. + */ + destroy() { + this.resourceCommand.unwatchResources( + [ + this.resourceCommand.TYPES.CSS_CHANGE, + this.resourceCommand.TYPES.DOCUMENT_EVENT, + ], + { onAvailable: this.onResourceAvailable } + ); + + this.store.dispatch(resetChanges()); + + this.document = null; + this.inspector = null; + this.store = null; + + if (this._contextMenu) { + this._contextMenu.destroy(); + this._contextMenu = null; + } + } +} + +module.exports = ChangesView; diff --git a/devtools/client/inspector/changes/actions/changes.js b/devtools/client/inspector/changes/actions/changes.js new file mode 100644 index 0000000000..b3b02cfa2a --- /dev/null +++ b/devtools/client/inspector/changes/actions/changes.js @@ -0,0 +1,25 @@ +/* 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 { + RESET_CHANGES, + TRACK_CHANGE, +} = require("resource://devtools/client/inspector/changes/actions/index.js"); + +module.exports = { + resetChanges() { + return { + type: RESET_CHANGES, + }; + }, + + trackChange(change) { + return { + type: TRACK_CHANGE, + change, + }; + }, +}; diff --git a/devtools/client/inspector/changes/actions/index.js b/devtools/client/inspector/changes/actions/index.js new file mode 100644 index 0000000000..3ae33883f2 --- /dev/null +++ b/devtools/client/inspector/changes/actions/index.js @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { createEnum } = require("resource://devtools/client/shared/enum.js"); + +createEnum( + [ + // Remove all changes + "RESET_CHANGES", + + // Track a style change + "TRACK_CHANGE", + ], + module.exports +); diff --git a/devtools/client/inspector/changes/actions/moz.build b/devtools/client/inspector/changes/actions/moz.build new file mode 100644 index 0000000000..06c5314a9e --- /dev/null +++ b/devtools/client/inspector/changes/actions/moz.build @@ -0,0 +1,10 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "changes.js", + "index.js", +) diff --git a/devtools/client/inspector/changes/components/CSSDeclaration.js b/devtools/client/inspector/changes/components/CSSDeclaration.js new file mode 100644 index 0000000000..c25bf6833f --- /dev/null +++ b/devtools/client/inspector/changes/components/CSSDeclaration.js @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +class CSSDeclaration extends PureComponent { + static get propTypes() { + return { + className: PropTypes.string, + property: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + }; + } + + static get defaultProps() { + return { + className: "", + }; + } + + render() { + const { className, property, value } = this.props; + + return dom.div( + { className: `changes__declaration ${className}` }, + dom.span( + { className: "changes__declaration-name theme-fg-color3" }, + property + ), + ": ", + dom.span( + { className: "changes__declaration-value theme-fg-color1" }, + value + ), + ";" + ); + } +} + +module.exports = CSSDeclaration; diff --git a/devtools/client/inspector/changes/components/ChangesApp.js b/devtools/client/inspector/changes/components/ChangesApp.js new file mode 100644 index 0000000000..9953109e19 --- /dev/null +++ b/devtools/client/inspector/changes/components/ChangesApp.js @@ -0,0 +1,241 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const CSSDeclaration = createFactory( + require("resource://devtools/client/inspector/changes/components/CSSDeclaration.js") +); +const { + getChangesTree, +} = require("resource://devtools/client/inspector/changes/selectors/changes.js"); +const { + getSourceForDisplay, +} = require("resource://devtools/client/inspector/changes/utils/changes-utils.js"); +const { + getStr, +} = require("resource://devtools/client/inspector/changes/utils/l10n.js"); + +class ChangesApp extends PureComponent { + static get propTypes() { + return { + // Nested CSS rule tree structure of CSS changes grouped by source (stylesheet) + changesTree: PropTypes.object.isRequired, + // Event handler for "contextmenu" event + onContextMenu: PropTypes.func.isRequired, + // Event handler for click on "Copy All Changes" button + onCopyAllChanges: PropTypes.func.isRequired, + // Event handler for click on "Copy Rule" button + onCopyRule: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + } + + renderCopyAllChangesButton() { + const button = dom.button( + { + className: "changes__copy-all-changes-button", + onClick: e => { + e.stopPropagation(); + this.props.onCopyAllChanges(); + }, + title: getStr("changes.contextmenu.copyAllChangesDescription"), + }, + getStr("changes.contextmenu.copyAllChanges") + ); + + return dom.div({ className: "changes__toolbar" }, button); + } + + renderCopyButton(ruleId) { + return dom.button( + { + className: "changes__copy-rule-button", + onClick: e => { + e.stopPropagation(); + this.props.onCopyRule(ruleId); + }, + title: getStr("changes.contextmenu.copyRuleDescription"), + }, + getStr("changes.contextmenu.copyRule") + ); + } + + renderDeclarations(remove = [], add = []) { + const removals = remove + // Sorting changed declarations in the order they appear in the Rules view. + .sort((a, b) => a.index > b.index) + .map(({ property, value, index }) => { + return CSSDeclaration({ + key: "remove-" + property + index, + className: "level diff-remove", + property, + value, + }); + }); + + const additions = add + // Sorting changed declarations in the order they appear in the Rules view. + .sort((a, b) => a.index > b.index) + .map(({ property, value, index }) => { + return CSSDeclaration({ + key: "add-" + property + index, + className: "level diff-add", + property, + value, + }); + }); + + return [removals, additions]; + } + + renderRule(ruleId, rule, level = 0) { + const diffClass = rule.isNew ? "diff-add" : ""; + return dom.div( + { + key: ruleId, + className: "changes__rule devtools-monospace", + "data-rule-id": ruleId, + style: { + "--diff-level": level, + }, + }, + this.renderSelectors(rule.selectors, rule.isNew), + this.renderCopyButton(ruleId), + // Render any nested child rules if they exist. + rule.children.map(childRule => { + return this.renderRule(childRule.ruleId, childRule, level + 1); + }), + // Render any changed CSS declarations. + this.renderDeclarations(rule.remove, rule.add), + // Render the closing bracket with a diff marker if necessary. + dom.div({ className: `level ${diffClass}` }, "}") + ); + } + + /** + * Return an array of React elements for the rule's selector. + * + * @param {Array} selectors + * List of strings as versions of this rule's selector over time. + * @param {Boolean} isNewRule + * Whether the rule was created at runtime. + * @return {Array} + */ + renderSelectors(selectors, isNewRule) { + const selectorDiffClassMap = new Map(); + + // The selectors array has just one item if it hasn't changed. Render it as-is. + // If the rule was created at runtime, mark the single selector as added. + // If it has two or more items, the first item was the original selector (mark as + // removed) and the last item is the current selector (mark as added). + if (selectors.length === 1) { + selectorDiffClassMap.set(selectors[0], isNewRule ? "diff-add" : ""); + } else if (selectors.length >= 2) { + selectorDiffClassMap.set(selectors[0], "diff-remove"); + selectorDiffClassMap.set(selectors[selectors.length - 1], "diff-add"); + } + + const elements = []; + + for (const [selector, diffClass] of selectorDiffClassMap) { + elements.push( + dom.div( + { + key: selector, + className: `level changes__selector ${diffClass}`, + title: selector, + }, + selector, + dom.span({}, " {") + ) + ); + } + + return elements; + } + + renderDiff(changes = {}) { + // Render groups of style sources: stylesheets and element style attributes. + return Object.entries(changes).map(([sourceId, source]) => { + const path = getSourceForDisplay(source); + const { href, rules, isFramed } = source; + + return dom.div( + { + key: sourceId, + "data-source-id": sourceId, + className: "source", + }, + dom.div( + { + className: "href", + title: href, + }, + dom.span({}, path), + isFramed && this.renderFrameBadge(href) + ), + // Render changed rules within this source. + Object.entries(rules).map(([ruleId, rule]) => { + return this.renderRule(ruleId, rule); + }) + ); + }); + } + + renderFrameBadge(href = "") { + return dom.span( + { + className: "inspector-badge", + title: href, + }, + getStr("changes.iframeLabel") + ); + } + + renderEmptyState() { + return dom.div( + { className: "devtools-sidepanel-no-result" }, + dom.p({}, getStr("changes.noChanges")), + dom.p({}, getStr("changes.noChangesDescription")) + ); + } + + render() { + const hasChanges = !!Object.keys(this.props.changesTree).length; + return dom.div( + { + className: "theme-sidebar inspector-tabpanel", + id: "sidebar-panel-changes", + role: "document", + tabIndex: "0", + onContextMenu: this.props.onContextMenu, + }, + !hasChanges && this.renderEmptyState(), + hasChanges && this.renderCopyAllChangesButton(), + hasChanges && this.renderDiff(this.props.changesTree) + ); + } +} + +const mapStateToProps = state => { + return { + changesTree: getChangesTree(state.changes), + }; +}; + +module.exports = connect(mapStateToProps)(ChangesApp); diff --git a/devtools/client/inspector/changes/components/moz.build b/devtools/client/inspector/changes/components/moz.build new file mode 100644 index 0000000000..e8fba36fb8 --- /dev/null +++ b/devtools/client/inspector/changes/components/moz.build @@ -0,0 +1,10 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "ChangesApp.js", + "CSSDeclaration.js", +) diff --git a/devtools/client/inspector/changes/moz.build b/devtools/client/inspector/changes/moz.build new file mode 100644 index 0000000000..10c4d4a8cf --- /dev/null +++ b/devtools/client/inspector/changes/moz.build @@ -0,0 +1,24 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "actions", + "components", + "reducers", + "selectors", + "utils", +] + +DevToolsModules( + "ChangesContextMenu.js", + "ChangesView.js", +) + +BROWSER_CHROME_MANIFESTS += ["test/browser.ini"] +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.ini"] + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Inspector: Changes") diff --git a/devtools/client/inspector/changes/reducers/changes.js b/devtools/client/inspector/changes/reducers/changes.js new file mode 100644 index 0000000000..206983f512 --- /dev/null +++ b/devtools/client/inspector/changes/reducers/changes.js @@ -0,0 +1,381 @@ +/* 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 { + RESET_CHANGES, + TRACK_CHANGE, +} = require("resource://devtools/client/inspector/changes/actions/index.js"); + +/** + * Return a deep clone of the given state object. + * + * @param {Object} state + * @return {Object} + */ +function cloneState(state = {}) { + return Object.entries(state).reduce((sources, [sourceId, source]) => { + sources[sourceId] = { + ...source, + rules: Object.entries(source.rules).reduce((rules, [ruleId, rule]) => { + rules[ruleId] = { + ...rule, + selectors: rule.selectors.slice(0), + children: rule.children.slice(0), + add: rule.add.slice(0), + remove: rule.remove.slice(0), + }; + + return rules; + }, {}), + }; + + return sources; + }, {}); +} + +/** + * Given information about a CSS rule and its ancestor rules (@media, @supports, etc), + * create entries in the given rules collection for each rule and assign parent/child + * dependencies. + * + * @param {Object} ruleData + * Information about a CSS rule: + * { + * id: {String} + * Unique rule id. + * selectors: {Array} + * Array of CSS selector text + * ancestors: {Array} + * Flattened CSS rule tree of the rule's ancestors with the root rule + * at the beginning of the array and the leaf rule at the end. + * ruleIndex: {Array} + * Indexes of each ancestor rule within its parent rule. + * } + * + * @param {Object} rules + * Collection of rules to be mutated. + * This is a reference to the corresponding `rules` object from the state. + * + * @return {Object} + * Entry for the CSS rule created the given collection of rules. + */ +function createRule(ruleData, rules) { + // Append the rule data to the flattened CSS rule tree with its ancestors. + const ruleAncestry = [...ruleData.ancestors, { ...ruleData }]; + + return ( + ruleAncestry + .map((rule, index) => { + // Ensure each rule has ancestors excluding itself (expand the flattened rule tree). + rule.ancestors = ruleAncestry.slice(0, index); + // Ensure each rule has a selector text. + // For the purpose of displaying in the UI, we treat at-rules as selectors. + if (!rule.selectors || !rule.selectors.length) { + rule.selectors = [ + `${rule.typeName} ${ + rule.conditionText || rule.name || rule.keyText + }`, + ]; + } + + return rule.id; + }) + // Then, create new entries in the rules collection and assign dependencies. + .map((ruleId, index, array) => { + const { selectors } = ruleAncestry[index]; + const prevRuleId = array[index - 1]; + const nextRuleId = array[index + 1]; + + // Create an entry for this ruleId if one does not exist. + if (!rules[ruleId]) { + rules[ruleId] = { + ruleId, + isNew: false, + selectors, + add: [], + remove: [], + children: [], + parent: null, + }; + } + + // The next ruleId is lower in the rule tree, therefore it's a child of this rule. + if (nextRuleId && !rules[ruleId].children.includes(nextRuleId)) { + rules[ruleId].children.push(nextRuleId); + } + + // The previous ruleId is higher in the rule tree, therefore it's the parent. + if (prevRuleId) { + rules[ruleId].parent = prevRuleId; + } + + return rules[ruleId]; + }) + // Finally, return the last rule in the array which is the rule we set out to create. + .pop() + ); +} + +function removeRule(ruleId, rules) { + const rule = rules[ruleId]; + + // First, remove this rule's id from its parent's list of children + if (rule.parent && rules[rule.parent]) { + rules[rule.parent].children = rules[rule.parent].children.filter( + childRuleId => { + return childRuleId !== ruleId; + } + ); + + // Remove the parent rule if it has no children left. + if (!rules[rule.parent].children.length) { + removeRule(rule.parent, rules); + } + } + + delete rules[ruleId]; +} + +/** + * Aggregated changes grouped by sources (stylesheet/element), which contain rules, + * which contain collections of added and removed CSS declarations. + * + * Structure: + * <sourceId>: { + * type: // {String} One of: "stylesheet", "inline" or "element" + * href: // {String|null} Stylesheet or document URL; null for inline stylesheets + * rules: { + * <ruleId>: { + * ruleId: // {String} <ruleId> of this rule + * isNew: // {Boolean} Whether the tracked rule was created at runtime, + * // meaning it didn't originally exist in the source. + * selectors: // {Array} of CSS selectors or CSS at-rule text. + * // The array has just one item if the selector is never + * // changed. When the rule's selector is changed, the new + * // selector is pushed onto this array. + * children: [] // {Array} of <ruleId> for child rules of this rule + * parent: // {String} <ruleId> of the parent rule + * add: [ // {Array} of objects with CSS declarations + * { + * property: // {String} CSS property name + * value: // {String} CSS property value + * index: // {Number} Position of the declaration within its CSS rule + * } + * ... // more declarations + * ], + * remove: [] // {Array} of objects with CSS declarations + * } + * ... // more rules + * } + * } + * ... // more sources + */ +const INITIAL_STATE = {}; + +const reducers = { + /** + * CSS changes are collected on the server by the ChangesActor which dispatches them to + * the client as atomic operations: a rule/declaration updated, added or removed. + * + * By design, the ChangesActor has no big-picture context of all the collected changes. + * It only holds the stack of atomic changes. This makes it roboust for many use cases: + * building a diff-view, supporting undo/redo, offline persistence, etc. Consumers, + * like the Changes panel, get to massage the data for their particular purposes. + * + * Here in the reducer, we aggregate incoming changes to build a human-readable diff + * shown in the Changes panel. + * - added / removed declarations are grouped by CSS rule. Rules are grouped by their + * parent rules (@media, @supports, @keyframes, etc.); Rules belong to sources + * (stylesheets, inline styles) + * - declarations have an index corresponding to their position in the CSS rule. This + * allows tracking of multiple declarations with the same property name. + * - repeated changes a declaration will show only the original removal and the latest + * addition; + * - when a declaration is removed, we update the indices of other tracked declarations + * in the same rule which may have changed position in the rule as a result; + * - changes which cancel each other out (i.e. return to original) are both removed + * from the store; + * - when changes cancel each other out leaving the rule unchanged, the rule is removed + * from the store. Its parent rule is removed as well if it too ends up unchanged. + */ + // eslint-disable-next-line complexity + [TRACK_CHANGE](state, { change }) { + const defaults = { + selector: null, + source: {}, + ancestors: [], + add: [], + remove: [], + }; + + change = { ...defaults, ...change }; + state = cloneState(state); + + const { selector, ancestors, ruleIndex } = change; + const sourceId = change.source.id; + const ruleId = change.id; + + // Copy or create object identifying the source (styelsheet/element) for this change. + const source = Object.assign({}, state[sourceId], change.source); + // Copy or create collection of all rules ever changed in this source. + const rules = Object.assign({}, source.rules); + // Reference or create object identifying the rule for this change. + const rule = rules[ruleId] + ? rules[ruleId] + : createRule( + { id: change.id, selectors: [selector], ancestors, ruleIndex }, + rules + ); + + // Mark the rule if it was created at runtime as a result of an "Add Rule" action. + if (change.type === "rule-add") { + rule.isNew = true; + } + + // If the first selector tracked for this rule is identical to the incoming selector, + // reduce the selectors array to a single one. This handles the case for renaming a + // selector back to its original name. It has no side effects for other changes which + // preserve the selector. + // If the rule was created at runtime, always reduce the selectors array to one item. + // Changes to the new rule's selector always overwrite the original selector. + // If the selectors are different, push the incoming one to the end of the array to + // signify that the rule has changed selector. The last item is the current selector. + if (rule.selectors[0] === selector || rule.isNew) { + rule.selectors = [selector]; + } else { + rule.selectors.push(selector); + } + + if (change.remove?.length) { + for (const decl of change.remove) { + // Find the position of any added declaration which matches the incoming + // declaration to be removed. + const addIndex = rule.add.findIndex(addDecl => { + return ( + addDecl.index === decl.index && + addDecl.property === decl.property && + addDecl.value === decl.value + ); + }); + + // Find the position of any removed declaration which matches the incoming + // declaration to be removed. It's possible to get duplicate remove operations + // when, for example, disabling a declaration then deleting it. + const removeIndex = rule.remove.findIndex(removeDecl => { + return ( + removeDecl.index === decl.index && + removeDecl.property === decl.property && + removeDecl.value === decl.value + ); + }); + + // Track the remove operation only if the property was not previously introduced + // by an add operation. This ensures repeated changes of the same property + // register as a single remove operation of its original value. Avoid tracking the + // remove declaration if already tracked (happens on disable followed by delete). + if (addIndex < 0 && removeIndex < 0) { + rule.remove.push(decl); + } + + // Delete any previous add operation which would be canceled out by this remove. + if (rule.add[addIndex]) { + rule.add.splice(addIndex, 1); + } + + // Update the indexes of previously tracked declarations which follow this removed + // one so future tracking continues to point to the right declarations. + if (change.type === "declaration-remove") { + rule.add = rule.add.map(addDecl => { + if (addDecl.index > decl.index) { + addDecl.index--; + } + + return addDecl; + }); + + rule.remove = rule.remove.map(removeDecl => { + if (removeDecl.index > decl.index) { + removeDecl.index--; + } + + return removeDecl; + }); + } + } + } + + if (change.add?.length) { + for (const decl of change.add) { + // Find the position of any removed declaration which matches the incoming + // declaration to be added. + const removeIndex = rule.remove.findIndex(removeDecl => { + return ( + removeDecl.index === decl.index && + removeDecl.value === decl.value && + removeDecl.property === decl.property + ); + }); + + // Find the position of any added declaration which matches the incoming + // declaration to be added in case we need to replace it. + const addIndex = rule.add.findIndex(addDecl => { + return ( + addDecl.index === decl.index && addDecl.property === decl.property + ); + }); + + if (rule.remove[removeIndex]) { + // Delete any previous remove operation which would be canceled out by this add. + rule.remove.splice(removeIndex, 1); + } else if (rule.add[addIndex]) { + // Replace previous add operation for declaration at this index. + rule.add.splice(addIndex, 1, decl); + } else { + // Track new add operation. + rule.add.push(decl); + } + } + } + + // Remove the rule if none of its declarations or selector have changed, + // but skip cleanup if the selector is in process of being renamed (there are two + // changes happening in quick succession: selector-remove + selector-add) or if the + // rule was created at runtime (allow empty new rules to persist). + if ( + !rule.add.length && + !rule.remove.length && + rule.selectors.length === 1 && + !change.type.startsWith("selector-") && + !rule.isNew + ) { + removeRule(ruleId, rules); + source.rules = { ...rules }; + } else { + source.rules = { ...rules, [ruleId]: rule }; + } + + // Remove information about the source if none of its rules changed. + if (!Object.keys(source.rules).length) { + delete state[sourceId]; + } else { + state[sourceId] = source; + } + + return state; + }, + + [RESET_CHANGES](state) { + return INITIAL_STATE; + }, +}; + +module.exports = function (state = INITIAL_STATE, action) { + const reducer = reducers[action.type]; + if (!reducer) { + return state; + } + return reducer(state, action); +}; diff --git a/devtools/client/inspector/changes/reducers/moz.build b/devtools/client/inspector/changes/reducers/moz.build new file mode 100644 index 0000000000..f3ea9a1bfc --- /dev/null +++ b/devtools/client/inspector/changes/reducers/moz.build @@ -0,0 +1,9 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "changes.js", +) diff --git a/devtools/client/inspector/changes/selectors/changes.js b/devtools/client/inspector/changes/selectors/changes.js new file mode 100644 index 0000000000..a6b99e4579 --- /dev/null +++ b/devtools/client/inspector/changes/selectors/changes.js @@ -0,0 +1,261 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +loader.lazyRequireGetter( + this, + "getTabPrefs", + "resource://devtools/shared/indentation.js", + true +); + +const { + getSourceForDisplay, +} = require("resource://devtools/client/inspector/changes/utils/changes-utils.js"); + +/** + * In the Redux state, changed CSS rules are grouped by source (stylesheet) and stored in + * a single level array, regardless of nesting. + * This method returns a nested tree structure of the changed CSS rules so the React + * consumer components can traverse it easier when rendering the nested CSS rules view. + * Keeping this interface updated allows the Redux state structure to change without + * affecting the consumer components. + * + * @param {Object} state + * Redux slice for tracked changes. + * @param {Object} filter + * Object with optional filters to use. Has the following properties: + * - sourceIds: {Array} + * Use only subtrees of sources matching source ids from this array. + * - ruleIds: {Array} + * Use only rules matching rule ids from this array. If the array includes ids + * of ancestor rules (@media, @supports), their nested rules will be included. + * @return {Object} + */ +function getChangesTree(state, filter = {}) { + // Use or assign defaults of sourceId and ruleId arrays by which to filter the tree. + const { sourceIds: sourceIdsFilter = [], ruleIds: rulesIdsFilter = [] } = + filter; + /** + * Recursively replace a rule's array of child rule ids with the referenced child rules. + * Mark visited rules so as not to handle them (and their children) again. + * + * Returns the rule object with expanded children or null if previously visited. + * + * @param {String} ruleId + * @param {Object} rule + * @param {Array} rules + * @param {Set} visitedRules + * @return {Object|null} + */ + function expandRuleChildren(ruleId, rule, rules, visitedRules) { + if (visitedRules.has(ruleId)) { + return null; + } + + visitedRules.add(ruleId); + + return { + ...rule, + children: rule.children.map(childRuleId => + expandRuleChildren(childRuleId, rules[childRuleId], rules, visitedRules) + ), + }; + } + + return Object.entries(state) + .filter(([sourceId, source]) => { + // Use only matching sources if an array to filter by was provided. + if (sourceIdsFilter.length) { + return sourceIdsFilter.includes(sourceId); + } + + return true; + }) + .reduce((sourcesObj, [sourceId, source]) => { + const { rules } = source; + // Log of visited rules in this source. Helps avoid duplication when traversing the + // descendant rule tree. This Set is unique per source. It will be passed down to + // be populated with ids of rules once visited. This ensures that only visited rules + // unique to this source will be skipped and prevents skipping identical rules from + // other sources (ex: rules with the same selector and the same index). + const visitedRules = new Set(); + + // Build a new collection of sources keyed by source id. + sourcesObj[sourceId] = { + ...source, + // Build a new collection of rules keyed by rule id. + rules: Object.entries(rules) + .filter(([ruleId, rule]) => { + // Use only matching rules if an array to filter by was provided. + if (rulesIdsFilter.length) { + return rulesIdsFilter.includes(ruleId); + } + + return true; + }) + .reduce((rulesObj, [ruleId, rule]) => { + // Expand the rule's array of child rule ids with the referenced child rules. + // Skip exposing null values which mean the rule was previously visited + // as part of an ancestor descendant tree. + const expandedRule = expandRuleChildren( + ruleId, + rule, + rules, + visitedRules + ); + if (expandedRule !== null) { + rulesObj[ruleId] = expandedRule; + } + + return rulesObj; + }, {}), + }; + + return sourcesObj; + }, {}); +} + +/** + * Build the CSS text of a stylesheet with the changes aggregated in the Redux state. + * If filters for rule id or source id are provided, restrict the changes to the matching + * sources and rules. + * + * Code comments with the source origin are put above of the CSS rule (or group of + * rules). Removed CSS declarations are written commented out. Added CSS declarations are + * written as-is. + * + * @param {Object} state + * Redux slice for tracked changes. + * @param {Object} filter + * Object with optional source and rule filters. See getChangesTree() + * @return {String} + * CSS stylesheet text. + */ + +// For stylesheet sources, the stylesheet filename and full path are used: +// +// /* styles.css | https://example.com/styles.css */ +// +// .selector { +// /* property: oldvalue; */ +// property: value; +// } + +// For inline stylesheet sources, the stylesheet index and host document URL are used: +// +// /* Inline #1 | https://example.com */ +// +// .selector { +// /* property: oldvalue; */ +// property: value; +// } + +// For element style attribute sources, the unique selector generated for the element +// and the host document URL are used: +// +// /* Element (div) | https://example.com */ +// +// div:nth-child(1) { +// /* property: oldvalue; */ +// property: value; +// } +function getChangesStylesheet(state, filter) { + const changeTree = getChangesTree(state, filter); + // Get user prefs about indentation style. + const { indentUnit, indentWithTabs } = getTabPrefs(); + const indentChar = indentWithTabs + ? "\t".repeat(indentUnit) + : " ".repeat(indentUnit); + + /** + * If the rule has just one item in its array of selector versions, return it as-is. + * If it has more than one, build a string using the first selector commented-out + * and the last selector as-is. This indicates that a rule's selector has changed. + * + * @param {Array} selectors + * History of selector versions if changed over time. + * Array with a single item (the original selector) if never changed. + * @param {Number} level + * Level of nesting within a CSS rule tree. + * @return {String} + */ + function writeSelector(selectors = [], level) { + const indent = indentChar.repeat(level); + let selectorText; + switch (selectors.length) { + case 0: + selectorText = ""; + break; + case 1: + selectorText = `${indent}${selectors[0]}`; + break; + default: + selectorText = + `${indent}/* ${selectors[0]} { */\n` + + `${indent}${selectors[selectors.length - 1]}`; + } + + return selectorText; + } + + function writeRule(ruleId, rule, level) { + // Write nested rules, if any. + let ruleBody = rule.children.reduce((str, childRule) => { + str += writeRule(childRule.ruleId, childRule, level + 1); + return str; + }, ""); + + // Write changed CSS declarations. + ruleBody += writeDeclarations(rule.remove, rule.add, level + 1); + + const indent = indentChar.repeat(level); + const selectorText = writeSelector(rule.selectors, level); + return `\n${selectorText} {${ruleBody}\n${indent}}`; + } + + function writeDeclarations(remove = [], add = [], level) { + const indent = indentChar.repeat(level); + const removals = remove + // Sort declarations in the order in which they exist in the original CSS rule. + .sort((a, b) => a.index > b.index) + .reduce((str, { property, value }) => { + str += `\n${indent}/* ${property}: ${value}; */`; + return str; + }, ""); + + const additions = add + // Sort declarations in the order in which they exist in the original CSS rule. + .sort((a, b) => a.index > b.index) + .reduce((str, { property, value }) => { + str += `\n${indent}${property}: ${value};`; + return str; + }, ""); + + return removals + additions; + } + + // Iterate through all sources in the change tree and build a CSS stylesheet string. + return Object.entries(changeTree).reduce( + (stylesheetText, [sourceId, source]) => { + const { href, rules } = source; + // Write code comment with source origin + stylesheetText += `\n/* ${getSourceForDisplay(source)} | ${href} */\n`; + // Write CSS rules + stylesheetText += Object.entries(rules).reduce((str, [ruleId, rule]) => { + // Add a new like only after top-level rules (level == 0) + str += writeRule(ruleId, rule, 0) + "\n"; + return str; + }, ""); + + return stylesheetText; + }, + "" + ); +} + +module.exports = { + getChangesTree, + getChangesStylesheet, +}; diff --git a/devtools/client/inspector/changes/selectors/moz.build b/devtools/client/inspector/changes/selectors/moz.build new file mode 100644 index 0000000000..f3ea9a1bfc --- /dev/null +++ b/devtools/client/inspector/changes/selectors/moz.build @@ -0,0 +1,9 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "changes.js", +) diff --git a/devtools/client/inspector/changes/test/browser.ini b/devtools/client/inspector/changes/test/browser.ini new file mode 100644 index 0000000000..9b1b44d56e --- /dev/null +++ b/devtools/client/inspector/changes/test/browser.ini @@ -0,0 +1,28 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + head.js + !/devtools/client/inspector/test/head.js + !/devtools/client/inspector/test/shared-head.js + !/devtools/client/inspector/rules/test/head.js + !/devtools/client/shared/test/shared-head.js + !/devtools/client/shared/test/telemetry-test-helpers.js + !/devtools/client/shared/test/highlighter-test-actor.js + +[browser_changes_at_rules.js] +[browser_changes_background_tracking.js] +[browser_changes_copy_all_changes.js] +[browser_changes_copy_declaration.js] +[browser_changes_copy_rule.js] +[browser_changes_declaration_add_special_character.js] +[browser_changes_declaration_disable.js] +[browser_changes_declaration_duplicate.js] +[browser_changes_declaration_edit_value.js] +[browser_changes_declaration_identical_rules.js] +[browser_changes_declaration_remove_ahead.js] +[browser_changes_declaration_remove_disabled.js] +[browser_changes_declaration_remove.js] +[browser_changes_declaration_rename.js] +[browser_changes_rule_add.js] +[browser_changes_rule_selector.js] diff --git a/devtools/client/inspector/changes/test/browser_changes_at_rules.js b/devtools/client/inspector/changes/test/browser_changes_at_rules.js new file mode 100644 index 0000000000..53551f9822 --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_at_rules.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the Changes panel works with nested at-rules. + +// Declare rule individually so we can use them for the assertions as well +// In the end, we should have nested rule looking like: +// - @media screen and (height > 5px) { +// -- @layer myLayer { +// --- @container myContainer (width > 10px) { +// ----- div { + +const divRule = `div { + color: tomato; +}`; +const containerRule = `@container myContainer (width > 10px) { + /* in container */ + ${divRule} +}`; +const layerRule = `@layer myLayer { + /* in layer */ + ${containerRule} +}`; +const mediaRule = `@media screen and (height > 5px) { + /* in media */ + ${layerRule} +}`; + +const TEST_URI = ` + <style> + body { + container: myContainer / inline-size + } + ${mediaRule} + </style> + <div>hello</div> +`; + +const EXPECTED = [ + { + text: "@media screen and (height > 5px) {", + copyRuleClipboard: mediaRule.replace("tomato", "cyan"), + }, + { + text: "@layer myLayer {", + copyRuleClipboard: layerRule.replace("tomato", "cyan"), + }, + { + text: "@container myContainer (width > 10px) {", + copyRuleClipboard: containerRule.replace("tomato", "cyan"), + }, + { text: "div {", copyRuleClipboard: divRule.replace("tomato", "cyan") }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const changesView = selectChangesView(inspector); + const { document: panelDoc, store } = changesView; + const panel = panelDoc.querySelector("#sidebar-panel-changes"); + + await selectNode("div", inspector); + const onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + await updateDeclaration(ruleView, 1, { color: "tomato" }, { color: "cyan" }); + await onTrackChange; + + const selectorsEl = getSelectors(panel); + + is( + selectorsEl.length, + EXPECTED.length, + "Got the expected number of selectors item" + ); + for (let i = 0; i < EXPECTED.length; i++) { + const selectorEl = selectorsEl[i]; + const expectedItem = EXPECTED[i]; + is( + selectorEl.text, + expectedItem.innerText, + `Got expected selector text at index ${i}` + ); + info(`Click the Copy Rule button for the "${expectedItem.text}" rule`); + const button = selectorEl + .closest(".changes__rule") + .querySelector(".changes__copy-rule-button"); + await waitForClipboardPromise( + () => button.click(), + () => checkClipboardData(expectedItem.copyRuleClipboard) + ); + } +}); + +function checkClipboardData(expected) { + const actual = SpecialPowers.getClipboardData("text/plain"); + return actual.trim() === expected.trim(); +} diff --git a/devtools/client/inspector/changes/test/browser_changes_background_tracking.js b/devtools/client/inspector/changes/test/browser_changes_background_tracking.js new file mode 100644 index 0000000000..613e2d9062 --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_background_tracking.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that CSS changes are collected in the background without the Changes panel visible + +const TEST_URI = ` + <style type='text/css'> + div { + color: red; + } + </style> + <div></div> +`; + +add_task(async function () { + info("Ensure Changes panel is NOT the default panel; use Computed panel"); + await pushPref("devtools.inspector.activeSidebar", "computedview"); + + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + + await selectNode("div", inspector); + const prop = getTextProperty(ruleView, 1, { color: "red" }); + + info("Disable the first CSS declaration"); + await togglePropStatus(ruleView, prop); + + info("Select the Changes panel"); + const { document: doc, store } = selectChangesView(inspector); + const onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + const onResetChanges = waitForDispatch(store, "RESET_CHANGES"); + + info("Wait for change to be tracked"); + await onTrackChange; + const removedDeclarations = getRemovedDeclarations(doc); + is(removedDeclarations.length, 1, "One declaration was tracked as removed"); + + // Test for Bug 1656477. Check that changes are not cleared immediately afterwards. + info("Wait to see if the RESET_CHANGES action is dispatched unexpecteldy"); + const sym = Symbol(); + const onTimeout = wait(500).then(() => sym); + const raceResult = await Promise.any([onResetChanges, onTimeout]); + ok(raceResult === sym, "RESET_CHANGES has not been dispatched"); +}); diff --git a/devtools/client/inspector/changes/test/browser_changes_copy_all_changes.js b/devtools/client/inspector/changes/test/browser_changes_copy_all_changes.js new file mode 100644 index 0000000000..119fe22585 --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_copy_all_changes.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the Changes panel Copy All Changes button will populate the +// clipboard with a summary of just the changed declarations. + +const TEST_URI = ` + <style type='text/css'> + div { + color: red; + margin: 0; + } + </style> + <div></div> +`; + +// Indentation is important. A strict check will be done against the clipboard content. +const EXPECTED_CLIPBOARD = ` +/* Inline #0 | data:text/html;charset=utf-8,${TEST_URI} */ + +div { + /* color: red; */ + color: green; +} +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const changesView = selectChangesView(inspector); + const { document: panelDoc, store } = changesView; + + await selectNode("div", inspector); + const onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + await updateDeclaration(ruleView, 1, { color: "red" }, { color: "green" }); + await onTrackChange; + + info( + "Check that clicking the Copy All Changes button copies all changes to the clipboard." + ); + const button = panelDoc.querySelector(".changes__copy-all-changes-button"); + await waitForClipboardPromise( + () => button.click(), + () => checkClipboardData(EXPECTED_CLIPBOARD) + ); +}); + +function checkClipboardData(expected) { + const actual = SpecialPowers.getClipboardData("text/plain"); + return decodeURIComponent(actual).trim() === expected.trim(); +} diff --git a/devtools/client/inspector/changes/test/browser_changes_copy_declaration.js b/devtools/client/inspector/changes/test/browser_changes_copy_declaration.js new file mode 100644 index 0000000000..0f2c7f68e6 --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_copy_declaration.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the Changes panel Copy Declaration context menu item will populate the +// clipboard with the changed declaration. + +const TEST_URI = ` + <style type='text/css'> + div { + color: red; + margin: 0; + } + </style> + <div></div> +`; + +const EXPECTED_CLIPBOARD_REMOVED = `/* color: red; */`; +const EXPECTED_CLIPBOARD_ADDED = `color: green;`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const changesView = selectChangesView(inspector); + const { document: panelDoc, store } = changesView; + + await selectNode("div", inspector); + const onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + await updateDeclaration(ruleView, 1, { color: "red" }, { color: "green" }); + await onTrackChange; + + info( + "Click the Copy Declaration context menu item for the removed declaration" + ); + const removeDecl = getRemovedDeclarations(panelDoc); + const addDecl = getAddedDeclarations(panelDoc); + + let menu = await getChangesContextMenu(changesView, removeDecl[0].element); + let menuItem = menu.items.find( + item => item.id === "changes-contextmenu-copy-declaration" + ); + await waitForClipboardPromise( + () => menuItem.click(), + () => checkClipboardData(EXPECTED_CLIPBOARD_REMOVED) + ); + + info("Hiding menu"); + menu.hide(document); + + info( + "Click the Copy Declaration context menu item for the added declaration" + ); + menu = await getChangesContextMenu(changesView, addDecl[0].element); + menuItem = menu.items.find( + item => item.id === "changes-contextmenu-copy-declaration" + ); + await waitForClipboardPromise( + () => menuItem.click(), + () => checkClipboardData(EXPECTED_CLIPBOARD_ADDED) + ); +}); + +function checkClipboardData(expected) { + const actual = SpecialPowers.getClipboardData("text/plain"); + return actual.trim() === expected.trim(); +} diff --git a/devtools/client/inspector/changes/test/browser_changes_copy_rule.js b/devtools/client/inspector/changes/test/browser_changes_copy_rule.js new file mode 100644 index 0000000000..4c2a347e8e --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_copy_rule.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the Changes panel Copy Rule button and context menu will populate the +// clipboard with the entire contents of the changed rule, including unchanged properties. + +const TEST_URI = ` + <style type='text/css'> + div { + color: red; + margin: 0; + } + </style> + <div></div> +`; + +// Indentation is important. A strict check will be done against the clipboard content. +const EXPECTED_CLIPBOARD = ` + div { + color: green; + margin: 0; + } +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const changesView = selectChangesView(inspector); + const { document: panelDoc, store } = changesView; + + await selectNode("div", inspector); + const onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + await updateDeclaration(ruleView, 1, { color: "red" }, { color: "green" }); + await onTrackChange; + + info("Click the Copy Rule button and expect the changed rule on clipboard"); + const button = panelDoc.querySelector(".changes__copy-rule-button"); + await waitForClipboardPromise( + () => button.click(), + () => checkClipboardData(EXPECTED_CLIPBOARD) + ); + + emptyClipboard(); + + info( + "Click the Copy Rule context menu item and expect the changed rule on the clipboard" + ); + const addDecl = getAddedDeclarations(panelDoc); + const menu = await getChangesContextMenu(changesView, addDecl[0].element); + const menuItem = menu.items.find( + item => item.id === "changes-contextmenu-copy-rule" + ); + await waitForClipboardPromise( + () => menuItem.click(), + () => checkClipboardData(EXPECTED_CLIPBOARD) + ); +}); + +function checkClipboardData(expected) { + const actual = SpecialPowers.getClipboardData("text/plain"); + return actual.trim() === expected.trim(); +} diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_add_special_character.js b/devtools/client/inspector/changes/test/browser_changes_declaration_add_special_character.js new file mode 100644 index 0000000000..65b0092b9c --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_declaration_add_special_character.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that adding new CSS properties with special characters in the property +// name does note create duplicate entries. + +const PROPERTY_NAME = '"abc"'; +const INITIAL_VALUE = "foo"; +// For assertions the quotes in the property will be escaped. +const EXPECTED_PROPERTY_NAME = '\\"abc\\"'; + +const TEST_URI = ` + <style type='text/css'> + div { + color: red; + } + </style> + <div>test</div> +`; + +add_task(async function addWithSpecialCharacter() { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const { document: doc, store } = selectChangesView(inspector); + + await selectNode("div", inspector); + + const ruleEditor = getRuleViewRuleEditor(ruleView, 1); + const editor = await focusEditableField(ruleView, ruleEditor.closeBrace); + + const input = editor.input; + input.value = `${PROPERTY_NAME}: ${INITIAL_VALUE};`; + + let onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Pressing return to commit and focus the new value field"); + const onModifications = ruleView.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_RETURN", {}, ruleView.styleWindow); + await onModifications; + await onTrackChange; + await assertAddedDeclaration(doc, EXPECTED_PROPERTY_NAME, INITIAL_VALUE); + + let newValue = "def"; + info(`Change the CSS declaration value to ${newValue}`); + const prop = getTextProperty(ruleView, 1, { [PROPERTY_NAME]: INITIAL_VALUE }); + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + // flushCount needs to be set to 2 once when quotes are involved. + await setProperty(ruleView, prop, newValue, { flushCount: 2 }); + await onTrackChange; + await assertAddedDeclaration(doc, EXPECTED_PROPERTY_NAME, newValue); + + newValue = "123"; + info(`Change the CSS declaration value to ${newValue}`); + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + // 2 preview requests to flush: one for the new value and one for the + // autocomplete popup suggestion (even if no suggestion is displayed here). + await setProperty(ruleView, prop, newValue, { flushCount: 2 }); + await onTrackChange; + await assertAddedDeclaration(doc, EXPECTED_PROPERTY_NAME, newValue); +}); + +/** + * Check that we only received a single added declaration with the expected + * value. + */ +async function assertAddedDeclaration(doc, expectedName, expectedValue) { + await waitFor(() => { + const addDecl = getAddedDeclarations(doc); + return ( + addDecl.length == 1 && + addDecl[0].value == expectedValue && + addDecl[0].property == expectedName + ); + }, "Got the expected declaration"); + is(getAddedDeclarations(doc).length, 1, "Only one added declaration"); + is(getRemovedDeclarations(doc).length, 0, "No removed declaration"); +} diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_disable.js b/devtools/client/inspector/changes/test/browser_changes_declaration_disable.js new file mode 100644 index 0000000000..4c7141cdc6 --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_declaration_disable.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that toggling a CSS declaration in the Rule view is tracked. + +const TEST_URI = ` + <style type='text/css'> + div { + color: red; + } + </style> + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const { document: doc, store } = selectChangesView(inspector); + + await selectNode("div", inspector); + const prop = getTextProperty(ruleView, 1, { color: "red" }); + + let onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Disable the first declaration"); + await togglePropStatus(ruleView, prop); + info("Wait for change to be tracked"); + await onTrackChange; + + let removedDeclarations = getRemovedDeclarations(doc); + is( + removedDeclarations.length, + 1, + "Only one declaration was tracked as removed" + ); + + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Re-enable the first declaration"); + await togglePropStatus(ruleView, prop); + info("Wait for change to be tracked"); + await onTrackChange; + + const addedDeclarations = getAddedDeclarations(doc); + removedDeclarations = getRemovedDeclarations(doc); + is(addedDeclarations.length, 0, "No declarations were tracked as added"); + is(removedDeclarations.length, 0, "No declarations were tracked as removed"); +}); diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_duplicate.js b/devtools/client/inspector/changes/test/browser_changes_declaration_duplicate.js new file mode 100644 index 0000000000..1d3423992e --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_declaration_duplicate.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that adding duplicate declarations to the Rule view is shown in the Changes panel. + +const TEST_URI = ` + <style type='text/css'> + div { + } + </style> + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const { document: doc, store } = selectChangesView(inspector); + + await selectNode("div", inspector); + await testAddDuplicateDeclarations(ruleView, store, doc); + await testChangeDuplicateDeclarations(ruleView, store, doc); + await testRemoveDuplicateDeclarations(ruleView, store, doc); +}); + +async function testAddDuplicateDeclarations(ruleView, store, doc) { + info(`Test that adding declarations with the same property name and value + are both tracked.`); + + let onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Add CSS declaration"); + await addProperty(ruleView, 1, "color", "red"); + info("Wait for the change to be tracked"); + await onTrackChange; + + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Add duplicate CSS declaration"); + await addProperty(ruleView, 1, "color", "red"); + info("Wait for the change to be tracked"); + await onTrackChange; + + await waitFor(() => { + const decls = getAddedDeclarations(doc); + return decls.length == 2 && decls[1].value == "red"; + }, "Two declarations were tracked as added"); + const addDecl = getAddedDeclarations(doc); + is(addDecl[0].value, "red", "First declaration has correct property value"); + is( + addDecl[0].value, + addDecl[1].value, + "First and second declarations have identical property values" + ); +} + +async function testChangeDuplicateDeclarations(ruleView, store, doc) { + info( + "Test that changing one of the duplicate declarations won't change the other" + ); + const prop = getTextProperty(ruleView, 1, { color: "red" }); + + info("Change the value of the first of the duplicate declarations"); + const onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + await setProperty(ruleView, prop, "black"); + info("Wait for the change to be tracked"); + await onTrackChange; + + await waitFor( + () => getAddedDeclarations(doc).length == 2, + "Two declarations were tracked as added" + ); + const addDecl = getAddedDeclarations(doc); + is(addDecl[0].value, "black", "First declaration has changed property value"); + is( + addDecl[1].value, + "red", + "Second declaration has not changed property value" + ); +} + +async function testRemoveDuplicateDeclarations(ruleView, store, doc) { + info(`Test that removing the first of the duplicate declarations + will not remove the second.`); + + const prop = getTextProperty(ruleView, 1, { color: "black" }); + + info("Remove first declaration"); + const onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + await removeProperty(ruleView, prop); + info("Wait for the change to be tracked"); + await onTrackChange; + + await waitFor( + () => getAddedDeclarations(doc).length == 1, + "One declaration was tracked as added" + ); + const addDecl = getAddedDeclarations(doc); + const removeDecl = getRemovedDeclarations(doc); + // Expect no remove operation tracked because it cancels out the original add operation. + is(removeDecl.length, 0, "No declaration was tracked as removed"); + is(addDecl.length, 1, "Just one declaration left tracked as added"); + is( + addDecl[0].value, + "red", + "Leftover declaration has property value of the former second declaration" + ); +} diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_edit_value.js b/devtools/client/inspector/changes/test/browser_changes_declaration_edit_value.js new file mode 100644 index 0000000000..588513a274 --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_declaration_edit_value.js @@ -0,0 +1,170 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that editing the value of a CSS declaration in the Rule view is tracked. + +const TEST_URI = ` + <style type='text/css'> + div { + color: red; + font-family: "courier"; + } + </style> + <div>test</div> +`; + +/* + This object contains iteration steps to modify various CSS properties of the + test element, keyed by property name,. + Each value is an array which will be iterated over in order and the `value` + property will be used to update the value of the property. + The `add` and `remove` objects hold the expected values of the tracked declarations + shown in the Changes panel. If `add` or `remove` are null, it means we don't expect + any corresponding tracked declaration to show up in the Changes panel. + */ +const ITERATIONS = { + color: [ + // No changes should be tracked if the value did not actually change. + { + value: "red", + add: null, + remove: null, + }, + // Changing the priority flag "!important" should be tracked. + { + value: "red !important", + add: { value: "red !important" }, + remove: { value: "red" }, + }, + // Repeated changes should still show the original value as the one removed. + { + value: "blue", + add: { value: "blue" }, + remove: { value: "red" }, + }, + // Restoring the original value should clear tracked changes. + { + value: "red", + add: null, + remove: null, + }, + ], + "font-family": [ + // Set a value with an opening quote, missing the closing one. + // The closing quote should still appear in the "add" value. + { + value: '"ar', + add: { value: '"ar"' }, + remove: { value: '"courier"' }, + // For some reason we need an additional flush the first time we set a + // value with a quote. Since the ruleview is manually flushed when opened + // openRuleView, we need to pass this information all the way down to the + // setProperty helper. + needsExtraFlush: true, + }, + // Add an escaped character + { + value: '"ar\\i', + add: { value: '"ar\\i"' }, + remove: { value: '"courier"' }, + }, + // Add some more text + { + value: '"ar\\ia', + add: { value: '"ar\\ia"' }, + remove: { value: '"courier"' }, + }, + // Remove the backslash + { + value: '"aria', + add: { value: '"aria"' }, + remove: { value: '"courier"' }, + }, + // Add the rest of the text, still no closing quote + { + value: '"arial', + add: { value: '"arial"' }, + remove: { value: '"courier"' }, + }, + // Restoring the original value should clear tracked changes. + { + value: '"courier"', + add: null, + remove: null, + }, + ], +}; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const { document: doc, store } = selectChangesView(inspector); + + await selectNode("div", inspector); + + const colorProp = getTextProperty(ruleView, 1, { color: "red" }); + await assertEditValue(ruleView, doc, store, colorProp, ITERATIONS.color); + + const fontFamilyProp = getTextProperty(ruleView, 1, { + "font-family": '"courier"', + }); + await assertEditValue( + ruleView, + doc, + store, + fontFamilyProp, + ITERATIONS["font-family"] + ); +}); + +async function assertEditValue(ruleView, doc, store, prop, iterations) { + let onTrackChange; + for (const { value, add, needsExtraFlush, remove } of iterations) { + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + + info(`Change the CSS declaration value to ${value}`); + await setProperty(ruleView, prop, value, { + flushCount: needsExtraFlush ? 2 : 1, + }); + info("Wait for the change to be tracked"); + await onTrackChange; + + if (add) { + await waitFor(() => { + const decl = getAddedDeclarations(doc); + return decl.length == 1 && decl[0].value == add.value; + }, "Only one declaration was tracked as added."); + const addDecl = getAddedDeclarations(doc); + is( + addDecl[0].value, + add.value, + `Added declaration has expected value: ${add.value}` + ); + } else { + await waitFor( + () => !getAddedDeclarations(doc).length, + "Added declaration was cleared" + ); + } + + if (remove) { + await waitFor( + () => getRemovedDeclarations(doc).length == 1, + "Only one declaration was tracked as removed." + ); + const removeDecl = getRemovedDeclarations(doc); + is( + removeDecl[0].value, + remove.value, + `Removed declaration has expected value: ${remove.value}` + ); + } else { + await waitFor( + () => !getRemovedDeclarations(doc).length, + "Removed declaration was cleared" + ); + } + } +} diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_identical_rules.js b/devtools/client/inspector/changes/test/browser_changes_declaration_identical_rules.js new file mode 100644 index 0000000000..08ac6d173d --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_declaration_identical_rules.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test tracking changes to CSS declarations in different stylesheets but in rules +// with identical selectors. + +const TEST_URI = ` + <style type='text/css'> + div { + color: red; + } + </style> + <style type='text/css'> + div { + font-size: 1em; + } + </style> + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const { document: doc, store } = selectChangesView(inspector); + + await selectNode("div", inspector); + const prop1 = getTextProperty(ruleView, 1, { "font-size": "1em" }); + const prop2 = getTextProperty(ruleView, 2, { color: "red" }); + + let onTrackChange; + + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Disable the declaration in the first rule"); + await togglePropStatus(ruleView, prop1); + info("Wait for change to be tracked"); + await onTrackChange; + + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Disable the declaration in the second rule"); + await togglePropStatus(ruleView, prop2); + info("Wait for change to be tracked"); + await onTrackChange; + + const removeDecl = getRemovedDeclarations(doc); + is(removeDecl.length, 2, "Two declarations tracked as removed"); + // The last of the two matching rules shows up first in Rule view given that the + // specificity is the same. This is correct. If the properties were the same, the latest + // declaration would overwrite the first and thus show up on top. + is( + removeDecl[0].property, + "font-size", + "Correct property name for second declaration" + ); + is( + removeDecl[0].value, + "1em", + "Correct property value for second declaration" + ); + is( + removeDecl[1].property, + "color", + "Correct property name for first declaration" + ); + is( + removeDecl[1].value, + "red", + "Correct property value for first declaration" + ); +}); diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_remove.js b/devtools/client/inspector/changes/test/browser_changes_declaration_remove.js new file mode 100644 index 0000000000..60b61c3196 --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_declaration_remove.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that removing a CSS declaration from a rule in the Rule view is tracked. + +const TEST_URI = ` + <style type='text/css'> + div { + color: red; + } + </style> + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const { document: doc, store } = selectChangesView(inspector); + + await selectNode("div", inspector); + const prop = getTextProperty(ruleView, 1, { color: "red" }); + + const onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Remove the first declaration"); + await removeProperty(ruleView, prop); + info("Wait for change to be tracked"); + await onTrackChange; + + const removeDecl = getRemovedDeclarations(doc); + is(removeDecl.length, 1, "One declaration was tracked as removed"); + is( + removeDecl[0].property, + "color", + "Correct declaration name was tracked as removed" + ); + is( + removeDecl[0].value, + "red", + "Correct declaration value was tracked as removed" + ); +}); diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_remove_ahead.js b/devtools/client/inspector/changes/test/browser_changes_declaration_remove_ahead.js new file mode 100644 index 0000000000..b249fc8198 --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_declaration_remove_ahead.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the correct declaration is identified and changed after removing a +// declaration positioned ahead of it in the same CSS rule. + +const TEST_URI = ` + <style type='text/css'> + div { + color: red; + display: block; + } + </style> + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const { document: doc, store } = selectChangesView(inspector); + + await selectNode("div", inspector); + const prop1 = getTextProperty(ruleView, 1, { color: "red" }); + const prop2 = getTextProperty(ruleView, 1, { display: "block" }); + + let onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Change the second declaration"); + await setProperty(ruleView, prop2, "grid"); + await onTrackChange; + + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Remove the first declaration"); + await removeProperty(ruleView, prop1); + await onTrackChange; + + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Change the second declaration again"); + await setProperty(ruleView, prop2, "flex"); + info("Wait for change to be tracked"); + await onTrackChange; + + // Ensure changes to the second declaration were tracked after removing the first one. + await waitFor( + () => getRemovedDeclarations(doc).length == 2, + "Two declarations should have been tracked as removed" + ); + await waitFor(() => { + const addDecl = getAddedDeclarations(doc); + return addDecl.length == 1 && addDecl[0].value == "flex"; + }, "One declaration should have been tracked as added, and the added declaration to have updated property value"); +}); diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_remove_disabled.js b/devtools/client/inspector/changes/test/browser_changes_declaration_remove_disabled.js new file mode 100644 index 0000000000..258c3100d3 --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_declaration_remove_disabled.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that disabling a CSS declaration and then removing it from the Rule view +// is tracked as removed only once. Toggling leftover declarations should not introduce +// duplicate changes. + +const TEST_URI = ` + <style type='text/css'> + div { + color: red; + background: black; + } + </style> + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const { document: doc, store } = selectChangesView(inspector); + + await selectNode("div", inspector); + const prop1 = getTextProperty(ruleView, 1, { color: "red" }); + const prop2 = getTextProperty(ruleView, 1, { background: "black" }); + + info("Using the second declaration"); + await testRemoveValue(ruleView, store, doc, prop2); + info("Using the first declaration"); + await testToggle(ruleView, store, doc, prop1); + info("Using the first declaration"); + await testRemoveName(ruleView, store, doc, prop1); +}); + +async function testRemoveValue(ruleView, store, doc, prop) { + info("Test removing disabled declaration by clearing its property value."); + let onTrackChange; + + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Disable the declaration"); + await togglePropStatus(ruleView, prop); + info("Wait for change to be tracked"); + await onTrackChange; + + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Remove the disabled declaration by clearing its value"); + await setProperty(ruleView, prop, null); + await onTrackChange; + + const removeDecl = getRemovedDeclarations(doc); + is(removeDecl.length, 1, "Only one declaration tracked as removed"); +} + +async function testToggle(ruleView, store, doc, prop) { + info( + "Test toggling leftover declaration off and on will not track extra changes." + ); + let onTrackChange; + + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Disable the declaration"); + await togglePropStatus(ruleView, prop); + await onTrackChange; + + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Re-enable the declaration"); + await togglePropStatus(ruleView, prop); + await onTrackChange; + + await waitFor( + () => getRemovedDeclarations(doc).length == 1, + "Still just one declaration tracked as removed" + ); +} + +async function testRemoveName(ruleView, store, doc, prop) { + info("Test removing disabled declaration by clearing its property name."); + let onTrackChange; + + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Disable the declaration"); + await togglePropStatus(ruleView, prop); + await onTrackChange; + + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Remove the disabled declaration by clearing its name"); + await removeProperty(ruleView, prop); + await onTrackChange; + + info(`Expecting two declarations removed: + - one removed by its value in the other test + - one removed by its name from this test + `); + + await waitFor( + () => getRemovedDeclarations(doc).length == 2, + "Two declarations tracked as removed" + ); + const removeDecl = getRemovedDeclarations(doc); + is(removeDecl[0].property, "background", "First declaration name correct"); + is(removeDecl[0].value, "black", "First declaration value correct"); + is(removeDecl[1].property, "color", "Second declaration name correct"); + is(removeDecl[1].value, "red", "Second declaration value correct"); +} diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_rename.js b/devtools/client/inspector/changes/test/browser_changes_declaration_rename.js new file mode 100644 index 0000000000..245ce50121 --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_declaration_rename.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that renaming the property of a CSS declaration in the Rule view is tracked. + +const TEST_URI = ` + <style type='text/css'> + div { + color: red; + } + </style> + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const { document: doc, store } = selectChangesView(inspector); + + await selectNode("div", inspector); + const prop = getTextProperty(ruleView, 1, { color: "red" }); + + let onTrackChange; + + const oldPropertyName = "color"; + const newPropertyName = "background-color"; + + info(`Rename the CSS declaration name to ${newPropertyName}`); + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + await renameProperty(ruleView, prop, newPropertyName); + info("Wait for the change to be tracked"); + await onTrackChange; + + const removeDecl = getRemovedDeclarations(doc); + const addDecl = getAddedDeclarations(doc); + + is(removeDecl.length, 1, "One declaration tracked as removed"); + is( + removeDecl[0].property, + oldPropertyName, + `Removed declaration has old property name: ${oldPropertyName}` + ); + is(addDecl.length, 1, "One declaration tracked as added"); + is( + addDecl[0].property, + newPropertyName, + `Added declaration has new property name: ${newPropertyName}` + ); + + info( + `Reverting the CSS declaration name to ${oldPropertyName} should clear changes.` + ); + onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + await renameProperty(ruleView, prop, oldPropertyName); + info("Wait for the change to be tracked"); + await onTrackChange; + + await waitFor( + () => !getRemovedDeclarations(doc).length, + "No declaration tracked as removed" + ); + await waitFor( + () => !getAddedDeclarations(doc).length, + "No declaration tracked as added" + ); +}); diff --git a/devtools/client/inspector/changes/test/browser_changes_rule_add.js b/devtools/client/inspector/changes/test/browser_changes_rule_add.js new file mode 100644 index 0000000000..215f1f3605 --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_rule_add.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that adding a new CSS rule in the Rules view is tracked in the Changes panel. +// Renaming the selector of the new rule should overwrite the tracked selector. + +const TEST_URI = ` + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const { document: doc, store } = selectChangesView(inspector); + const panel = doc.querySelector("#sidebar-panel-changes"); + + await selectNode("div", inspector); + await testTrackAddNewRule(store, inspector, ruleView, panel); + await testTrackRenameNewRule(store, inspector, ruleView, panel); +}); + +async function testTrackAddNewRule(store, inspector, ruleView, panel) { + const onTrackChange = waitForDispatch(store, "TRACK_CHANGE"); + info("Adding a new CSS rule in the Rule view"); + await addNewRule(inspector, ruleView); + info("Pressing escape to leave the editor"); + EventUtils.synthesizeKey("KEY_Escape"); + info("Waiting for changes to be tracked"); + await onTrackChange; + + const addedSelectors = getAddedSelectors(panel); + const removedSelectors = getRemovedSelectors(panel); + is(addedSelectors.length, 1, "One selector was tracked as added"); + is(addedSelectors.item(0).title, "div", "New rule's has DIV selector"); + is(removedSelectors.length, 0, "No selectors tracked as removed"); +} + +async function testTrackRenameNewRule(store, inspector, ruleView, panel) { + info("Focusing the first rule's selector name in the Rule view"); + const ruleEditor = getRuleViewRuleEditor(ruleView, 1); + const editor = await focusEditableField(ruleView, ruleEditor.selectorText); + info("Entering a new selector name"); + editor.input.value = ".test"; + + // Expect two "TRACK_CHANGE" actions: one for removal, one for addition. + const onTrackChange = waitForDispatch(store, "TRACK_CHANGE", 2); + const onRuleViewChanged = once(ruleView, "ruleview-changed"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + info("Waiting for changes to be tracked"); + await onTrackChange; + + const addedSelectors = getAddedSelectors(panel); + const removedSelectors = getRemovedSelectors(panel); + is(addedSelectors.length, 1, "One selector was tracked as added"); + is( + addedSelectors.item(0).title, + ".test", + "New rule's selector was updated in place." + ); + is(removedSelectors.length, 0, "No selectors tracked as removed"); +} diff --git a/devtools/client/inspector/changes/test/browser_changes_rule_selector.js b/devtools/client/inspector/changes/test/browser_changes_rule_selector.js new file mode 100644 index 0000000000..20d3fba654 --- /dev/null +++ b/devtools/client/inspector/changes/test/browser_changes_rule_selector.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that renaming the selector of a CSS rule is tracked. +// Expect a selector removal followed by a selector addition and no changed declarations + +const TEST_URI = ` + <style type='text/css'> + div { + color: red; + } + </style> + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + const { document: doc, store } = selectChangesView(inspector); + const panel = doc.querySelector("#sidebar-panel-changes"); + + await selectNode("div", inspector); + const ruleEditor = getRuleViewRuleEditor(ruleView, 1); + + info("Focusing the first rule's selector name in the Rule view"); + const editor = await focusEditableField(ruleView, ruleEditor.selectorText); + info("Entering a new selector name"); + editor.input.value = ".test"; + + // Expect two "TRACK_CHANGE" actions: one for removal, one for addition. + const onTrackChange = waitForDispatch(store, "TRACK_CHANGE", 2); + const onRuleViewChanged = once(ruleView, "ruleview-changed"); + info("Pressing Enter key to commit the change"); + EventUtils.synthesizeKey("KEY_Enter"); + info("Waiting for rule view to update"); + await onRuleViewChanged; + info("Wait for the change to be tracked"); + await onTrackChange; + + const rules = panel.querySelectorAll(".changes__rule"); + is(rules.length, 1, "One rule was tracked as changed"); + + info("Expect old selector to be removed and new selector to be added"); + const addedSelectors = getAddedSelectors(panel); + const removedSelectors = getRemovedSelectors(panel); + is(addedSelectors.length, 1, "One new selector was tracked as added"); + is(addedSelectors.item(0).title, ".test", "New selector is correct"); + is(removedSelectors.length, 1, "One old selector was tracked as removed"); + is(removedSelectors.item(0).title, "div", "Old selector is correct"); + + info( + "Expect no declarations to have been added or removed during selector change" + ); + const removeDecl = getRemovedDeclarations(doc, rules.item(0)); + is(removeDecl.length, 0, "No declarations removed"); + const addDecl = getAddedDeclarations(doc, rules.item(0)); + is(addDecl.length, 0, "No declarations added"); +}); diff --git a/devtools/client/inspector/changes/test/head.js b/devtools/client/inspector/changes/test/head.js new file mode 100644 index 0000000000..b45af3ba47 --- /dev/null +++ b/devtools/client/inspector/changes/test/head.js @@ -0,0 +1,93 @@ +/* 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 no-unused-vars: [2, {"vars": "local"}] */ + +"use strict"; + +// Load the Rule view's test/head.js to make use of its helpers. +// It loads inspector/test/head.js which itself loads inspector/test/shared-head.js +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/rules/test/head.js", + this +); + +// Ensure the three-pane mode is enabled before running the tests. +Services.prefs.setBoolPref("devtools.inspector.three-pane-enabled", true); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.inspector.three-pane-enabled"); +}); + +/** + * Get an array of objects with property/value pairs of the CSS declarations rendered + * in the Changes panel. + * + * @param {Document} panelDoc + * Host document of the Changes panel. + * @param {String} selector + * Optional selector to filter rendered declaration DOM elements. + * One of ".diff-remove" or ".diff-add". + * If omitted, all declarations will be returned. + * @param {DOMNode} containerNode + * Optional element to restrict results to declaration DOM elements which are + * descendants of this container node. + * If omitted, all declarations will be returned + * @return {Array} + */ +function getDeclarations(panelDoc, selector = "", containerNode = null) { + const els = panelDoc.querySelectorAll(`.changes__declaration${selector}`); + + return [...els] + .filter(el => { + return containerNode ? containerNode.contains(el) : true; + }) + .map(el => { + return { + property: el.querySelector(".changes__declaration-name").textContent, + value: el.querySelector(".changes__declaration-value").textContent, + element: el, + }; + }); +} + +function getAddedDeclarations(panelDoc, containerNode) { + return getDeclarations(panelDoc, ".diff-add", containerNode); +} + +function getRemovedDeclarations(panelDoc, containerNode) { + return getDeclarations(panelDoc, ".diff-remove", containerNode); +} + +/** + * Get an array of DOM elements for the CSS selectors rendered in the Changes panel. + * + * @param {Document} panelDoc + * Host document of the Changes panel. + * @param {String} selector + * Optional selector to filter rendered selector DOM elements. + * One of ".diff-remove" or ".diff-add". + * If omitted, all selectors will be returned. + * @return {Array} + */ +function getSelectors(panelDoc, selector = "") { + return panelDoc.querySelectorAll(`.changes__selector${selector}`); +} + +function getAddedSelectors(panelDoc) { + return getSelectors(panelDoc, ".diff-add"); +} + +function getRemovedSelectors(panelDoc) { + return getSelectors(panelDoc, ".diff-remove"); +} + +async function getChangesContextMenu(changesView, element) { + const onContextMenu = changesView.contextMenu.once("open"); + info(`Trigger context menu for element: ${element}`); + synthesizeContextMenuEvent(element); + info(`Wait for context menu to show`); + await onContextMenu; + + return changesView.contextMenu; +} diff --git a/devtools/client/inspector/changes/test/xpcshell/.eslintrc.js b/devtools/client/inspector/changes/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..86bd54c245 --- /dev/null +++ b/devtools/client/inspector/changes/test/xpcshell/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the common devtools xpcshell eslintrc config. + extends: "../../../../../.eslintrc.xpcshell.js", +}; diff --git a/devtools/client/inspector/changes/test/xpcshell/head.js b/devtools/client/inspector/changes/test/xpcshell/head.js new file mode 100644 index 0000000000..f08a79dd71 --- /dev/null +++ b/devtools/client/inspector/changes/test/xpcshell/head.js @@ -0,0 +1,8 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); diff --git a/devtools/client/inspector/changes/test/xpcshell/mocks.js b/devtools/client/inspector/changes/test/xpcshell/mocks.js new file mode 100644 index 0000000000..52f175beb8 --- /dev/null +++ b/devtools/client/inspector/changes/test/xpcshell/mocks.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable comma-dangle */ + +"use strict"; + +/** + * Snapshot of the Redux state for the Changes panel. + * + * It corresponds to the tracking of a single property value change (background-color) + * within a deeply nested CSS at-rule structure from an inline stylesheet: + * + * @media (min-width: 50em) { + * @supports (display: grid) { + * body { + * - background-color: royalblue; + * + background-color: red; + * } + * } + * } + */ +module.exports.CHANGES_STATE = { + source1: { + type: "inline", + href: "http://localhost:5000/at-rules-nested.html", + id: "source1", + index: 0, + isFramed: false, + rules: { + rule1: { + selectors: ["@media (min-width: 50em)"], + ruleId: "rule1", + add: [], + remove: [], + children: ["rule2"], + }, + rule2: { + selectors: ["@supports (display: grid)"], + ruleId: "rule2", + add: [], + remove: [], + children: ["rule3"], + parent: "rule1", + }, + rule3: { + selectors: ["body"], + ruleId: "rule3", + add: [ + { + property: "background-color", + value: "red", + index: 0, + }, + ], + remove: [ + { + property: "background-color", + value: "royalblue", + index: 0, + }, + ], + children: [], + parent: "rule2", + }, + }, + }, +}; diff --git a/devtools/client/inspector/changes/test/xpcshell/test_changes_stylesheet.js b/devtools/client/inspector/changes/test/xpcshell/test_changes_stylesheet.js new file mode 100644 index 0000000000..33a64cfbcb --- /dev/null +++ b/devtools/client/inspector/changes/test/xpcshell/test_changes_stylesheet.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that getChangesStylesheet() serializes tracked changes from nested CSS rules +// into the expected stylesheet format. + +const { + getChangesStylesheet, +} = require("resource://devtools/client/inspector/changes/selectors/changes.js"); + +const { CHANGES_STATE } = require("resource://test/mocks"); + +// Wrap multi-line string in backticks to ensure exact check in test, including new lines. +const STYLESHEET_FOR_ANCESTOR = ` +/* Inline #0 | http://localhost:5000/at-rules-nested.html */ + +@media (min-width: 50em) { + @supports (display: grid) { + body { + /* background-color: royalblue; */ + background-color: red; + } + } +} +`; + +// Wrap multi-line string in backticks to ensure exact check in test, including new lines. +const STYLESHEET_FOR_DESCENDANT = ` +/* Inline #0 | http://localhost:5000/at-rules-nested.html */ + +body { + /* background-color: royalblue; */ + background-color: red; +} +`; + +add_test(() => { + info( + "Check stylesheet generated for the first ancestor in the CSS rule tree." + ); + equal( + getChangesStylesheet(CHANGES_STATE), + STYLESHEET_FOR_ANCESTOR, + "Stylesheet includes all ancestors." + ); + + info( + "Check stylesheet generated for the last descendant in the CSS rule tree." + ); + const filter = { sourceIds: ["source1"], ruleIds: ["rule3"] }; + equal( + getChangesStylesheet(CHANGES_STATE, filter), + STYLESHEET_FOR_DESCENDANT, + "Stylesheet includes just descendant." + ); + + run_next_test(); +}); diff --git a/devtools/client/inspector/changes/test/xpcshell/xpcshell.ini b/devtools/client/inspector/changes/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..886645a708 --- /dev/null +++ b/devtools/client/inspector/changes/test/xpcshell/xpcshell.ini @@ -0,0 +1,8 @@ +[DEFAULT] +tags = devtools +firefox-appdir = browser +head = head.js +support-files = + ./mocks.js + +[test_changes_stylesheet.js] diff --git a/devtools/client/inspector/changes/utils/changes-utils.js b/devtools/client/inspector/changes/utils/changes-utils.js new file mode 100644 index 0000000000..3e8505dc17 --- /dev/null +++ b/devtools/client/inspector/changes/utils/changes-utils.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + getFormatStr, + getStr, +} = require("resource://devtools/client/inspector/changes/utils/l10n.js"); + +/** + * Get a human-friendly style source path to display in the Changes panel. + * For element inline styles, return a string indicating that. + * For inline stylesheets, return a string indicating that plus the stylesheet's index. + * For URLs, return just the stylesheet filename. + * + * @param {Object} source + * Information about the style source. Contains: + * - type: {String} One of "element" or "stylesheet" + * - href: {String|null} Stylesheet URL or document URL for elmeent inline styles + * - index: {Number} Position of the stylesheet in its document's stylesheet list. + * @return {String} + */ +function getSourceForDisplay(source) { + let href; + + switch (source.type) { + case "element": + href = getStr("changes.elementStyleLabel"); + break; + case "inline": + href = getFormatStr("changes.inlineStyleSheetLabel", `#${source.index}`); + break; + case "stylesheet": + const url = new URL(source.href); + href = url.pathname.substring(url.pathname.lastIndexOf("/") + 1); + break; + } + + return href; +} + +module.exports.getSourceForDisplay = getSourceForDisplay; diff --git a/devtools/client/inspector/changes/utils/l10n.js b/devtools/client/inspector/changes/utils/l10n.js new file mode 100644 index 0000000000..693a1ec3cf --- /dev/null +++ b/devtools/client/inspector/changes/utils/l10n.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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/changes.properties" +); + +module.exports = { + getStr: (...args) => L10N.getStr(...args), + getFormatStr: (...args) => L10N.getFormatStr(...args), +}; diff --git a/devtools/client/inspector/changes/utils/moz.build b/devtools/client/inspector/changes/utils/moz.build new file mode 100644 index 0000000000..155752e0d8 --- /dev/null +++ b/devtools/client/inspector/changes/utils/moz.build @@ -0,0 +1,10 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "changes-utils.js", + "l10n.js", +) |