summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/changes
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /devtools/client/inspector/changes
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/inspector/changes')
-rw-r--r--devtools/client/inspector/changes/ChangesContextMenu.js110
-rw-r--r--devtools/client/inspector/changes/ChangesView.js284
-rw-r--r--devtools/client/inspector/changes/actions/changes.js25
-rw-r--r--devtools/client/inspector/changes/actions/index.js18
-rw-r--r--devtools/client/inspector/changes/actions/moz.build10
-rw-r--r--devtools/client/inspector/changes/components/CSSDeclaration.js47
-rw-r--r--devtools/client/inspector/changes/components/ChangesApp.js241
-rw-r--r--devtools/client/inspector/changes/components/moz.build10
-rw-r--r--devtools/client/inspector/changes/moz.build24
-rw-r--r--devtools/client/inspector/changes/reducers/changes.js385
-rw-r--r--devtools/client/inspector/changes/reducers/moz.build9
-rw-r--r--devtools/client/inspector/changes/selectors/changes.js261
-rw-r--r--devtools/client/inspector/changes/selectors/moz.build9
-rw-r--r--devtools/client/inspector/changes/test/browser.toml45
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_background_tracking.js46
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_copy_all_changes.js53
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_copy_declaration.js67
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_copy_rule.js64
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_declaration_add_special_character.js78
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_declaration_disable.js48
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_declaration_duplicate.js107
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_declaration_edit_value.js170
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_declaration_identical_rules.js71
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_declaration_remove.js43
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_declaration_remove_ahead.js53
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_declaration_remove_disabled.js107
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_declaration_rename.js68
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_nested_rules.js189
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_rule_add.js64
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_rule_selector.js60
-rw-r--r--devtools/client/inspector/changes/test/head.js93
-rw-r--r--devtools/client/inspector/changes/test/xpcshell/.eslintrc.js6
-rw-r--r--devtools/client/inspector/changes/test/xpcshell/head.js8
-rw-r--r--devtools/client/inspector/changes/test/xpcshell/mocks.js67
-rw-r--r--devtools/client/inspector/changes/test/xpcshell/test_changes_stylesheet.js60
-rw-r--r--devtools/client/inspector/changes/test/xpcshell/xpcshell.toml7
-rw-r--r--devtools/client/inspector/changes/utils/changes-utils.js44
-rw-r--r--devtools/client/inspector/changes/utils/l10n.js15
-rw-r--r--devtools/client/inspector/changes/utils/moz.build10
39 files changed, 3076 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..7d504f13f5
--- /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.toml"]
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"]
+
+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..23e82a3ba7
--- /dev/null
+++ b/devtools/client/inspector/changes/reducers/changes.js
@@ -0,0 +1,385 @@
+/* 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) {
+ // Display the @type label if there's one
+ let selector = rule.typeName ? rule.typeName + " " : "";
+ selector +=
+ rule.conditionText ||
+ rule.name ||
+ rule.keyText ||
+ rule.selectorText;
+
+ rule.selectors = [selector];
+ }
+
+ 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.toml b/devtools/client/inspector/changes/test/browser.toml
new file mode 100644
index 0000000000..407a960e92
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser.toml
@@ -0,0 +1,45 @@
+[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_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"]
+skip-if = ["a11y_checks"] # Bugs 1858041 and 1849028 to investigate intermittent a11y_checks results (fails on Autoland, passes on Try)
+
+["browser_changes_declaration_remove.js"]
+
+["browser_changes_declaration_remove_ahead.js"]
+
+["browser_changes_declaration_remove_disabled.js"]
+
+["browser_changes_declaration_rename.js"]
+
+["browser_changes_nested_rules.js"]
+
+["browser_changes_rule_add.js"]
+
+["browser_changes_rule_selector.js"]
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..8ebac2a23a
--- /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]);
+ Assert.strictEqual(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..421d22cbae
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_declaration_remove_disabled.js
@@ -0,0 +1,107 @@
+/* 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 pushPref("devtools.inspector.rule-view.focusNextOnEnter", false);
+ 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_nested_rules.js b/devtools/client/inspector/changes/test/browser_changes_nested_rules.js
new file mode 100644
index 0000000000..789d88fdda
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_nested_rules.js
@@ -0,0 +1,189 @@
+/* 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 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 {
+// ------- & > span { … }
+// ------- .mySpan {
+// --------- &:not(:focus) {
+
+const spanNotFocusedRule = `&:not(:focus) {
+ text-decoration: underline;
+}`;
+
+const spanClassRule = `.mySpan {
+ font-weight: bold;
+ ${spanNotFocusedRule}
+}`;
+
+const spanRule = `& > span {
+ outline: 1px solid gold;
+}`;
+
+const divRule = `div {
+ color: tomato;
+ ${spanRule}
+ ${spanClassRule}
+}`;
+
+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 <span class="mySpan">world</span></div>
+`;
+
+const applyModificationAfterDivPropertyChange = ruleText =>
+ ruleText.replace("tomato", "cyan");
+
+const EXPECTED_AFTER_DIV_PROP_CHANGE = [
+ {
+ text: "@media screen and (height > 5px) {",
+ copyRuleClipboard: applyModificationAfterDivPropertyChange(mediaRule),
+ },
+ {
+ text: "@layer myLayer {",
+ copyRuleClipboard: applyModificationAfterDivPropertyChange(layerRule),
+ },
+ {
+ text: "@container myContainer (width > 10px) {",
+ copyRuleClipboard: applyModificationAfterDivPropertyChange(containerRule),
+ },
+ {
+ text: "div {",
+ copyRuleClipboard: applyModificationAfterDivPropertyChange(divRule),
+ },
+];
+
+const applyModificationAfterSpanPropertiesChange = ruleText =>
+ ruleText
+ .replace("1px solid gold", "4px solid gold")
+ .replace("bold", "bolder")
+ .replace("underline", "underline dotted");
+
+const EXPECTED_AFTER_SPAN_PROP_CHANGES = EXPECTED_AFTER_DIV_PROP_CHANGE.map(
+ expected => ({
+ ...expected,
+ copyRuleClipboard: applyModificationAfterSpanPropertiesChange(
+ expected.copyRuleClipboard
+ ),
+ })
+).concat([
+ {
+ text: ".mySpan {",
+ copyRuleClipboard:
+ applyModificationAfterSpanPropertiesChange(spanClassRule),
+ },
+ {
+ text: "&:not(:focus) {",
+ copyRuleClipboard:
+ applyModificationAfterSpanPropertiesChange(spanNotFocusedRule),
+ },
+ {
+ text: "& > span {",
+ copyRuleClipboard: applyModificationAfterSpanPropertiesChange(spanRule),
+ },
+]);
+
+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);
+ let onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ await updateDeclaration(ruleView, 1, { color: "tomato" }, { color: "cyan" });
+ await onTrackChange;
+
+ await assertSelectors(panel, EXPECTED_AFTER_DIV_PROP_CHANGE);
+
+ await selectNode(".mySpan", inspector);
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ await updateDeclaration(
+ ruleView,
+ 1,
+ { "text-decoration": "underline" },
+ { "text-decoration": "underline dotted" }
+ );
+ await onTrackChange;
+
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ await updateDeclaration(
+ ruleView,
+ 2,
+ { "font-weight": "bold" },
+ { "font-weight": "bolder" }
+ );
+ await onTrackChange;
+
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ await updateDeclaration(
+ ruleView,
+ 3,
+ { outline: "1px solid gold" },
+ { outline: "4px solid gold" }
+ );
+ await onTrackChange;
+
+ await assertSelectors(panel, EXPECTED_AFTER_SPAN_PROP_CHANGES);
+});
+
+async function assertSelectors(panel, expected) {
+ 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.innerText,
+ expectedItem.text,
+ `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_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.toml b/devtools/client/inspector/changes/test/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..2edc192d20
--- /dev/null
+++ b/devtools/client/inspector/changes/test/xpcshell/xpcshell.toml
@@ -0,0 +1,7 @@
+[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",
+)