summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/changes/test
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/inspector/changes/test')
-rw-r--r--devtools/client/inspector/changes/test/browser.ini28
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_at_rules.js98
-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.js106
-rw-r--r--devtools/client/inspector/changes/test/browser_changes_declaration_rename.js68
-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.ini8
23 files changed, 1466 insertions, 0 deletions
diff --git a/devtools/client/inspector/changes/test/browser.ini b/devtools/client/inspector/changes/test/browser.ini
new file mode 100644
index 0000000000..9b1b44d56e
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser.ini
@@ -0,0 +1,28 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+support-files =
+ head.js
+ !/devtools/client/inspector/test/head.js
+ !/devtools/client/inspector/test/shared-head.js
+ !/devtools/client/inspector/rules/test/head.js
+ !/devtools/client/shared/test/shared-head.js
+ !/devtools/client/shared/test/telemetry-test-helpers.js
+ !/devtools/client/shared/test/highlighter-test-actor.js
+
+[browser_changes_at_rules.js]
+[browser_changes_background_tracking.js]
+[browser_changes_copy_all_changes.js]
+[browser_changes_copy_declaration.js]
+[browser_changes_copy_rule.js]
+[browser_changes_declaration_add_special_character.js]
+[browser_changes_declaration_disable.js]
+[browser_changes_declaration_duplicate.js]
+[browser_changes_declaration_edit_value.js]
+[browser_changes_declaration_identical_rules.js]
+[browser_changes_declaration_remove_ahead.js]
+[browser_changes_declaration_remove_disabled.js]
+[browser_changes_declaration_remove.js]
+[browser_changes_declaration_rename.js]
+[browser_changes_rule_add.js]
+[browser_changes_rule_selector.js]
diff --git a/devtools/client/inspector/changes/test/browser_changes_at_rules.js b/devtools/client/inspector/changes/test/browser_changes_at_rules.js
new file mode 100644
index 0000000000..53551f9822
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_at_rules.js
@@ -0,0 +1,98 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the Changes panel works with nested at-rules.
+
+// Declare rule individually so we can use them for the assertions as well
+// In the end, we should have nested rule looking like:
+// - @media screen and (height > 5px) {
+// -- @layer myLayer {
+// --- @container myContainer (width > 10px) {
+// ----- div {
+
+const divRule = `div {
+ color: tomato;
+}`;
+const containerRule = `@container myContainer (width > 10px) {
+ /* in container */
+ ${divRule}
+}`;
+const layerRule = `@layer myLayer {
+ /* in layer */
+ ${containerRule}
+}`;
+const mediaRule = `@media screen and (height > 5px) {
+ /* in media */
+ ${layerRule}
+}`;
+
+const TEST_URI = `
+ <style>
+ body {
+ container: myContainer / inline-size
+ }
+ ${mediaRule}
+ </style>
+ <div>hello</div>
+`;
+
+const EXPECTED = [
+ {
+ text: "@media screen and (height > 5px) {",
+ copyRuleClipboard: mediaRule.replace("tomato", "cyan"),
+ },
+ {
+ text: "@layer myLayer {",
+ copyRuleClipboard: layerRule.replace("tomato", "cyan"),
+ },
+ {
+ text: "@container myContainer (width > 10px) {",
+ copyRuleClipboard: containerRule.replace("tomato", "cyan"),
+ },
+ { text: "div {", copyRuleClipboard: divRule.replace("tomato", "cyan") },
+];
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const changesView = selectChangesView(inspector);
+ const { document: panelDoc, store } = changesView;
+ const panel = panelDoc.querySelector("#sidebar-panel-changes");
+
+ await selectNode("div", inspector);
+ const onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ await updateDeclaration(ruleView, 1, { color: "tomato" }, { color: "cyan" });
+ await onTrackChange;
+
+ const selectorsEl = getSelectors(panel);
+
+ is(
+ selectorsEl.length,
+ EXPECTED.length,
+ "Got the expected number of selectors item"
+ );
+ for (let i = 0; i < EXPECTED.length; i++) {
+ const selectorEl = selectorsEl[i];
+ const expectedItem = EXPECTED[i];
+ is(
+ selectorEl.text,
+ expectedItem.innerText,
+ `Got expected selector text at index ${i}`
+ );
+ info(`Click the Copy Rule button for the "${expectedItem.text}" rule`);
+ const button = selectorEl
+ .closest(".changes__rule")
+ .querySelector(".changes__copy-rule-button");
+ await waitForClipboardPromise(
+ () => button.click(),
+ () => checkClipboardData(expectedItem.copyRuleClipboard)
+ );
+ }
+});
+
+function checkClipboardData(expected) {
+ const actual = SpecialPowers.getClipboardData("text/plain");
+ return actual.trim() === expected.trim();
+}
diff --git a/devtools/client/inspector/changes/test/browser_changes_background_tracking.js b/devtools/client/inspector/changes/test/browser_changes_background_tracking.js
new file mode 100644
index 0000000000..613e2d9062
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_background_tracking.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that CSS changes are collected in the background without the Changes panel visible
+
+const TEST_URI = `
+ <style type='text/css'>
+ div {
+ color: red;
+ }
+ </style>
+ <div></div>
+`;
+
+add_task(async function () {
+ info("Ensure Changes panel is NOT the default panel; use Computed panel");
+ await pushPref("devtools.inspector.activeSidebar", "computedview");
+
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+
+ await selectNode("div", inspector);
+ const prop = getTextProperty(ruleView, 1, { color: "red" });
+
+ info("Disable the first CSS declaration");
+ await togglePropStatus(ruleView, prop);
+
+ info("Select the Changes panel");
+ const { document: doc, store } = selectChangesView(inspector);
+ const onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ const onResetChanges = waitForDispatch(store, "RESET_CHANGES");
+
+ info("Wait for change to be tracked");
+ await onTrackChange;
+ const removedDeclarations = getRemovedDeclarations(doc);
+ is(removedDeclarations.length, 1, "One declaration was tracked as removed");
+
+ // Test for Bug 1656477. Check that changes are not cleared immediately afterwards.
+ info("Wait to see if the RESET_CHANGES action is dispatched unexpecteldy");
+ const sym = Symbol();
+ const onTimeout = wait(500).then(() => sym);
+ const raceResult = await Promise.any([onResetChanges, onTimeout]);
+ ok(raceResult === sym, "RESET_CHANGES has not been dispatched");
+});
diff --git a/devtools/client/inspector/changes/test/browser_changes_copy_all_changes.js b/devtools/client/inspector/changes/test/browser_changes_copy_all_changes.js
new file mode 100644
index 0000000000..119fe22585
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_copy_all_changes.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the Changes panel Copy All Changes button will populate the
+// clipboard with a summary of just the changed declarations.
+
+const TEST_URI = `
+ <style type='text/css'>
+ div {
+ color: red;
+ margin: 0;
+ }
+ </style>
+ <div></div>
+`;
+
+// Indentation is important. A strict check will be done against the clipboard content.
+const EXPECTED_CLIPBOARD = `
+/* Inline #0 | data:text/html;charset=utf-8,${TEST_URI} */
+
+div {
+ /* color: red; */
+ color: green;
+}
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const changesView = selectChangesView(inspector);
+ const { document: panelDoc, store } = changesView;
+
+ await selectNode("div", inspector);
+ const onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ await updateDeclaration(ruleView, 1, { color: "red" }, { color: "green" });
+ await onTrackChange;
+
+ info(
+ "Check that clicking the Copy All Changes button copies all changes to the clipboard."
+ );
+ const button = panelDoc.querySelector(".changes__copy-all-changes-button");
+ await waitForClipboardPromise(
+ () => button.click(),
+ () => checkClipboardData(EXPECTED_CLIPBOARD)
+ );
+});
+
+function checkClipboardData(expected) {
+ const actual = SpecialPowers.getClipboardData("text/plain");
+ return decodeURIComponent(actual).trim() === expected.trim();
+}
diff --git a/devtools/client/inspector/changes/test/browser_changes_copy_declaration.js b/devtools/client/inspector/changes/test/browser_changes_copy_declaration.js
new file mode 100644
index 0000000000..0f2c7f68e6
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_copy_declaration.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the Changes panel Copy Declaration context menu item will populate the
+// clipboard with the changed declaration.
+
+const TEST_URI = `
+ <style type='text/css'>
+ div {
+ color: red;
+ margin: 0;
+ }
+ </style>
+ <div></div>
+`;
+
+const EXPECTED_CLIPBOARD_REMOVED = `/* color: red; */`;
+const EXPECTED_CLIPBOARD_ADDED = `color: green;`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const changesView = selectChangesView(inspector);
+ const { document: panelDoc, store } = changesView;
+
+ await selectNode("div", inspector);
+ const onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ await updateDeclaration(ruleView, 1, { color: "red" }, { color: "green" });
+ await onTrackChange;
+
+ info(
+ "Click the Copy Declaration context menu item for the removed declaration"
+ );
+ const removeDecl = getRemovedDeclarations(panelDoc);
+ const addDecl = getAddedDeclarations(panelDoc);
+
+ let menu = await getChangesContextMenu(changesView, removeDecl[0].element);
+ let menuItem = menu.items.find(
+ item => item.id === "changes-contextmenu-copy-declaration"
+ );
+ await waitForClipboardPromise(
+ () => menuItem.click(),
+ () => checkClipboardData(EXPECTED_CLIPBOARD_REMOVED)
+ );
+
+ info("Hiding menu");
+ menu.hide(document);
+
+ info(
+ "Click the Copy Declaration context menu item for the added declaration"
+ );
+ menu = await getChangesContextMenu(changesView, addDecl[0].element);
+ menuItem = menu.items.find(
+ item => item.id === "changes-contextmenu-copy-declaration"
+ );
+ await waitForClipboardPromise(
+ () => menuItem.click(),
+ () => checkClipboardData(EXPECTED_CLIPBOARD_ADDED)
+ );
+});
+
+function checkClipboardData(expected) {
+ const actual = SpecialPowers.getClipboardData("text/plain");
+ return actual.trim() === expected.trim();
+}
diff --git a/devtools/client/inspector/changes/test/browser_changes_copy_rule.js b/devtools/client/inspector/changes/test/browser_changes_copy_rule.js
new file mode 100644
index 0000000000..4c2a347e8e
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_copy_rule.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the Changes panel Copy Rule button and context menu will populate the
+// clipboard with the entire contents of the changed rule, including unchanged properties.
+
+const TEST_URI = `
+ <style type='text/css'>
+ div {
+ color: red;
+ margin: 0;
+ }
+ </style>
+ <div></div>
+`;
+
+// Indentation is important. A strict check will be done against the clipboard content.
+const EXPECTED_CLIPBOARD = `
+ div {
+ color: green;
+ margin: 0;
+ }
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const changesView = selectChangesView(inspector);
+ const { document: panelDoc, store } = changesView;
+
+ await selectNode("div", inspector);
+ const onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ await updateDeclaration(ruleView, 1, { color: "red" }, { color: "green" });
+ await onTrackChange;
+
+ info("Click the Copy Rule button and expect the changed rule on clipboard");
+ const button = panelDoc.querySelector(".changes__copy-rule-button");
+ await waitForClipboardPromise(
+ () => button.click(),
+ () => checkClipboardData(EXPECTED_CLIPBOARD)
+ );
+
+ emptyClipboard();
+
+ info(
+ "Click the Copy Rule context menu item and expect the changed rule on the clipboard"
+ );
+ const addDecl = getAddedDeclarations(panelDoc);
+ const menu = await getChangesContextMenu(changesView, addDecl[0].element);
+ const menuItem = menu.items.find(
+ item => item.id === "changes-contextmenu-copy-rule"
+ );
+ await waitForClipboardPromise(
+ () => menuItem.click(),
+ () => checkClipboardData(EXPECTED_CLIPBOARD)
+ );
+});
+
+function checkClipboardData(expected) {
+ const actual = SpecialPowers.getClipboardData("text/plain");
+ return actual.trim() === expected.trim();
+}
diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_add_special_character.js b/devtools/client/inspector/changes/test/browser_changes_declaration_add_special_character.js
new file mode 100644
index 0000000000..65b0092b9c
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_declaration_add_special_character.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that adding new CSS properties with special characters in the property
+// name does note create duplicate entries.
+
+const PROPERTY_NAME = '"abc"';
+const INITIAL_VALUE = "foo";
+// For assertions the quotes in the property will be escaped.
+const EXPECTED_PROPERTY_NAME = '\\"abc\\"';
+
+const TEST_URI = `
+ <style type='text/css'>
+ div {
+ color: red;
+ }
+ </style>
+ <div>test</div>
+`;
+
+add_task(async function addWithSpecialCharacter() {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const { document: doc, store } = selectChangesView(inspector);
+
+ await selectNode("div", inspector);
+
+ const ruleEditor = getRuleViewRuleEditor(ruleView, 1);
+ const editor = await focusEditableField(ruleView, ruleEditor.closeBrace);
+
+ const input = editor.input;
+ input.value = `${PROPERTY_NAME}: ${INITIAL_VALUE};`;
+
+ let onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Pressing return to commit and focus the new value field");
+ const onModifications = ruleView.once("ruleview-changed");
+ EventUtils.synthesizeKey("VK_RETURN", {}, ruleView.styleWindow);
+ await onModifications;
+ await onTrackChange;
+ await assertAddedDeclaration(doc, EXPECTED_PROPERTY_NAME, INITIAL_VALUE);
+
+ let newValue = "def";
+ info(`Change the CSS declaration value to ${newValue}`);
+ const prop = getTextProperty(ruleView, 1, { [PROPERTY_NAME]: INITIAL_VALUE });
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ // flushCount needs to be set to 2 once when quotes are involved.
+ await setProperty(ruleView, prop, newValue, { flushCount: 2 });
+ await onTrackChange;
+ await assertAddedDeclaration(doc, EXPECTED_PROPERTY_NAME, newValue);
+
+ newValue = "123";
+ info(`Change the CSS declaration value to ${newValue}`);
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ // 2 preview requests to flush: one for the new value and one for the
+ // autocomplete popup suggestion (even if no suggestion is displayed here).
+ await setProperty(ruleView, prop, newValue, { flushCount: 2 });
+ await onTrackChange;
+ await assertAddedDeclaration(doc, EXPECTED_PROPERTY_NAME, newValue);
+});
+
+/**
+ * Check that we only received a single added declaration with the expected
+ * value.
+ */
+async function assertAddedDeclaration(doc, expectedName, expectedValue) {
+ await waitFor(() => {
+ const addDecl = getAddedDeclarations(doc);
+ return (
+ addDecl.length == 1 &&
+ addDecl[0].value == expectedValue &&
+ addDecl[0].property == expectedName
+ );
+ }, "Got the expected declaration");
+ is(getAddedDeclarations(doc).length, 1, "Only one added declaration");
+ is(getRemovedDeclarations(doc).length, 0, "No removed declaration");
+}
diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_disable.js b/devtools/client/inspector/changes/test/browser_changes_declaration_disable.js
new file mode 100644
index 0000000000..4c7141cdc6
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_declaration_disable.js
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that toggling a CSS declaration in the Rule view is tracked.
+
+const TEST_URI = `
+ <style type='text/css'>
+ div {
+ color: red;
+ }
+ </style>
+ <div></div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const { document: doc, store } = selectChangesView(inspector);
+
+ await selectNode("div", inspector);
+ const prop = getTextProperty(ruleView, 1, { color: "red" });
+
+ let onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Disable the first declaration");
+ await togglePropStatus(ruleView, prop);
+ info("Wait for change to be tracked");
+ await onTrackChange;
+
+ let removedDeclarations = getRemovedDeclarations(doc);
+ is(
+ removedDeclarations.length,
+ 1,
+ "Only one declaration was tracked as removed"
+ );
+
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Re-enable the first declaration");
+ await togglePropStatus(ruleView, prop);
+ info("Wait for change to be tracked");
+ await onTrackChange;
+
+ const addedDeclarations = getAddedDeclarations(doc);
+ removedDeclarations = getRemovedDeclarations(doc);
+ is(addedDeclarations.length, 0, "No declarations were tracked as added");
+ is(removedDeclarations.length, 0, "No declarations were tracked as removed");
+});
diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_duplicate.js b/devtools/client/inspector/changes/test/browser_changes_declaration_duplicate.js
new file mode 100644
index 0000000000..1d3423992e
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_declaration_duplicate.js
@@ -0,0 +1,107 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that adding duplicate declarations to the Rule view is shown in the Changes panel.
+
+const TEST_URI = `
+ <style type='text/css'>
+ div {
+ }
+ </style>
+ <div></div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const { document: doc, store } = selectChangesView(inspector);
+
+ await selectNode("div", inspector);
+ await testAddDuplicateDeclarations(ruleView, store, doc);
+ await testChangeDuplicateDeclarations(ruleView, store, doc);
+ await testRemoveDuplicateDeclarations(ruleView, store, doc);
+});
+
+async function testAddDuplicateDeclarations(ruleView, store, doc) {
+ info(`Test that adding declarations with the same property name and value
+ are both tracked.`);
+
+ let onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Add CSS declaration");
+ await addProperty(ruleView, 1, "color", "red");
+ info("Wait for the change to be tracked");
+ await onTrackChange;
+
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Add duplicate CSS declaration");
+ await addProperty(ruleView, 1, "color", "red");
+ info("Wait for the change to be tracked");
+ await onTrackChange;
+
+ await waitFor(() => {
+ const decls = getAddedDeclarations(doc);
+ return decls.length == 2 && decls[1].value == "red";
+ }, "Two declarations were tracked as added");
+ const addDecl = getAddedDeclarations(doc);
+ is(addDecl[0].value, "red", "First declaration has correct property value");
+ is(
+ addDecl[0].value,
+ addDecl[1].value,
+ "First and second declarations have identical property values"
+ );
+}
+
+async function testChangeDuplicateDeclarations(ruleView, store, doc) {
+ info(
+ "Test that changing one of the duplicate declarations won't change the other"
+ );
+ const prop = getTextProperty(ruleView, 1, { color: "red" });
+
+ info("Change the value of the first of the duplicate declarations");
+ const onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ await setProperty(ruleView, prop, "black");
+ info("Wait for the change to be tracked");
+ await onTrackChange;
+
+ await waitFor(
+ () => getAddedDeclarations(doc).length == 2,
+ "Two declarations were tracked as added"
+ );
+ const addDecl = getAddedDeclarations(doc);
+ is(addDecl[0].value, "black", "First declaration has changed property value");
+ is(
+ addDecl[1].value,
+ "red",
+ "Second declaration has not changed property value"
+ );
+}
+
+async function testRemoveDuplicateDeclarations(ruleView, store, doc) {
+ info(`Test that removing the first of the duplicate declarations
+ will not remove the second.`);
+
+ const prop = getTextProperty(ruleView, 1, { color: "black" });
+
+ info("Remove first declaration");
+ const onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ await removeProperty(ruleView, prop);
+ info("Wait for the change to be tracked");
+ await onTrackChange;
+
+ await waitFor(
+ () => getAddedDeclarations(doc).length == 1,
+ "One declaration was tracked as added"
+ );
+ const addDecl = getAddedDeclarations(doc);
+ const removeDecl = getRemovedDeclarations(doc);
+ // Expect no remove operation tracked because it cancels out the original add operation.
+ is(removeDecl.length, 0, "No declaration was tracked as removed");
+ is(addDecl.length, 1, "Just one declaration left tracked as added");
+ is(
+ addDecl[0].value,
+ "red",
+ "Leftover declaration has property value of the former second declaration"
+ );
+}
diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_edit_value.js b/devtools/client/inspector/changes/test/browser_changes_declaration_edit_value.js
new file mode 100644
index 0000000000..588513a274
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_declaration_edit_value.js
@@ -0,0 +1,170 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that editing the value of a CSS declaration in the Rule view is tracked.
+
+const TEST_URI = `
+ <style type='text/css'>
+ div {
+ color: red;
+ font-family: "courier";
+ }
+ </style>
+ <div>test</div>
+`;
+
+/*
+ This object contains iteration steps to modify various CSS properties of the
+ test element, keyed by property name,.
+ Each value is an array which will be iterated over in order and the `value`
+ property will be used to update the value of the property.
+ The `add` and `remove` objects hold the expected values of the tracked declarations
+ shown in the Changes panel. If `add` or `remove` are null, it means we don't expect
+ any corresponding tracked declaration to show up in the Changes panel.
+ */
+const ITERATIONS = {
+ color: [
+ // No changes should be tracked if the value did not actually change.
+ {
+ value: "red",
+ add: null,
+ remove: null,
+ },
+ // Changing the priority flag "!important" should be tracked.
+ {
+ value: "red !important",
+ add: { value: "red !important" },
+ remove: { value: "red" },
+ },
+ // Repeated changes should still show the original value as the one removed.
+ {
+ value: "blue",
+ add: { value: "blue" },
+ remove: { value: "red" },
+ },
+ // Restoring the original value should clear tracked changes.
+ {
+ value: "red",
+ add: null,
+ remove: null,
+ },
+ ],
+ "font-family": [
+ // Set a value with an opening quote, missing the closing one.
+ // The closing quote should still appear in the "add" value.
+ {
+ value: '"ar',
+ add: { value: '"ar"' },
+ remove: { value: '"courier"' },
+ // For some reason we need an additional flush the first time we set a
+ // value with a quote. Since the ruleview is manually flushed when opened
+ // openRuleView, we need to pass this information all the way down to the
+ // setProperty helper.
+ needsExtraFlush: true,
+ },
+ // Add an escaped character
+ {
+ value: '"ar\\i',
+ add: { value: '"ar\\i"' },
+ remove: { value: '"courier"' },
+ },
+ // Add some more text
+ {
+ value: '"ar\\ia',
+ add: { value: '"ar\\ia"' },
+ remove: { value: '"courier"' },
+ },
+ // Remove the backslash
+ {
+ value: '"aria',
+ add: { value: '"aria"' },
+ remove: { value: '"courier"' },
+ },
+ // Add the rest of the text, still no closing quote
+ {
+ value: '"arial',
+ add: { value: '"arial"' },
+ remove: { value: '"courier"' },
+ },
+ // Restoring the original value should clear tracked changes.
+ {
+ value: '"courier"',
+ add: null,
+ remove: null,
+ },
+ ],
+};
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const { document: doc, store } = selectChangesView(inspector);
+
+ await selectNode("div", inspector);
+
+ const colorProp = getTextProperty(ruleView, 1, { color: "red" });
+ await assertEditValue(ruleView, doc, store, colorProp, ITERATIONS.color);
+
+ const fontFamilyProp = getTextProperty(ruleView, 1, {
+ "font-family": '"courier"',
+ });
+ await assertEditValue(
+ ruleView,
+ doc,
+ store,
+ fontFamilyProp,
+ ITERATIONS["font-family"]
+ );
+});
+
+async function assertEditValue(ruleView, doc, store, prop, iterations) {
+ let onTrackChange;
+ for (const { value, add, needsExtraFlush, remove } of iterations) {
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+
+ info(`Change the CSS declaration value to ${value}`);
+ await setProperty(ruleView, prop, value, {
+ flushCount: needsExtraFlush ? 2 : 1,
+ });
+ info("Wait for the change to be tracked");
+ await onTrackChange;
+
+ if (add) {
+ await waitFor(() => {
+ const decl = getAddedDeclarations(doc);
+ return decl.length == 1 && decl[0].value == add.value;
+ }, "Only one declaration was tracked as added.");
+ const addDecl = getAddedDeclarations(doc);
+ is(
+ addDecl[0].value,
+ add.value,
+ `Added declaration has expected value: ${add.value}`
+ );
+ } else {
+ await waitFor(
+ () => !getAddedDeclarations(doc).length,
+ "Added declaration was cleared"
+ );
+ }
+
+ if (remove) {
+ await waitFor(
+ () => getRemovedDeclarations(doc).length == 1,
+ "Only one declaration was tracked as removed."
+ );
+ const removeDecl = getRemovedDeclarations(doc);
+ is(
+ removeDecl[0].value,
+ remove.value,
+ `Removed declaration has expected value: ${remove.value}`
+ );
+ } else {
+ await waitFor(
+ () => !getRemovedDeclarations(doc).length,
+ "Removed declaration was cleared"
+ );
+ }
+ }
+}
diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_identical_rules.js b/devtools/client/inspector/changes/test/browser_changes_declaration_identical_rules.js
new file mode 100644
index 0000000000..08ac6d173d
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_declaration_identical_rules.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test tracking changes to CSS declarations in different stylesheets but in rules
+// with identical selectors.
+
+const TEST_URI = `
+ <style type='text/css'>
+ div {
+ color: red;
+ }
+ </style>
+ <style type='text/css'>
+ div {
+ font-size: 1em;
+ }
+ </style>
+ <div></div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const { document: doc, store } = selectChangesView(inspector);
+
+ await selectNode("div", inspector);
+ const prop1 = getTextProperty(ruleView, 1, { "font-size": "1em" });
+ const prop2 = getTextProperty(ruleView, 2, { color: "red" });
+
+ let onTrackChange;
+
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Disable the declaration in the first rule");
+ await togglePropStatus(ruleView, prop1);
+ info("Wait for change to be tracked");
+ await onTrackChange;
+
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Disable the declaration in the second rule");
+ await togglePropStatus(ruleView, prop2);
+ info("Wait for change to be tracked");
+ await onTrackChange;
+
+ const removeDecl = getRemovedDeclarations(doc);
+ is(removeDecl.length, 2, "Two declarations tracked as removed");
+ // The last of the two matching rules shows up first in Rule view given that the
+ // specificity is the same. This is correct. If the properties were the same, the latest
+ // declaration would overwrite the first and thus show up on top.
+ is(
+ removeDecl[0].property,
+ "font-size",
+ "Correct property name for second declaration"
+ );
+ is(
+ removeDecl[0].value,
+ "1em",
+ "Correct property value for second declaration"
+ );
+ is(
+ removeDecl[1].property,
+ "color",
+ "Correct property name for first declaration"
+ );
+ is(
+ removeDecl[1].value,
+ "red",
+ "Correct property value for first declaration"
+ );
+});
diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_remove.js b/devtools/client/inspector/changes/test/browser_changes_declaration_remove.js
new file mode 100644
index 0000000000..60b61c3196
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_declaration_remove.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that removing a CSS declaration from a rule in the Rule view is tracked.
+
+const TEST_URI = `
+ <style type='text/css'>
+ div {
+ color: red;
+ }
+ </style>
+ <div></div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const { document: doc, store } = selectChangesView(inspector);
+
+ await selectNode("div", inspector);
+ const prop = getTextProperty(ruleView, 1, { color: "red" });
+
+ const onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Remove the first declaration");
+ await removeProperty(ruleView, prop);
+ info("Wait for change to be tracked");
+ await onTrackChange;
+
+ const removeDecl = getRemovedDeclarations(doc);
+ is(removeDecl.length, 1, "One declaration was tracked as removed");
+ is(
+ removeDecl[0].property,
+ "color",
+ "Correct declaration name was tracked as removed"
+ );
+ is(
+ removeDecl[0].value,
+ "red",
+ "Correct declaration value was tracked as removed"
+ );
+});
diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_remove_ahead.js b/devtools/client/inspector/changes/test/browser_changes_declaration_remove_ahead.js
new file mode 100644
index 0000000000..b249fc8198
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_declaration_remove_ahead.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the correct declaration is identified and changed after removing a
+// declaration positioned ahead of it in the same CSS rule.
+
+const TEST_URI = `
+ <style type='text/css'>
+ div {
+ color: red;
+ display: block;
+ }
+ </style>
+ <div></div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const { document: doc, store } = selectChangesView(inspector);
+
+ await selectNode("div", inspector);
+ const prop1 = getTextProperty(ruleView, 1, { color: "red" });
+ const prop2 = getTextProperty(ruleView, 1, { display: "block" });
+
+ let onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Change the second declaration");
+ await setProperty(ruleView, prop2, "grid");
+ await onTrackChange;
+
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Remove the first declaration");
+ await removeProperty(ruleView, prop1);
+ await onTrackChange;
+
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Change the second declaration again");
+ await setProperty(ruleView, prop2, "flex");
+ info("Wait for change to be tracked");
+ await onTrackChange;
+
+ // Ensure changes to the second declaration were tracked after removing the first one.
+ await waitFor(
+ () => getRemovedDeclarations(doc).length == 2,
+ "Two declarations should have been tracked as removed"
+ );
+ await waitFor(() => {
+ const addDecl = getAddedDeclarations(doc);
+ return addDecl.length == 1 && addDecl[0].value == "flex";
+ }, "One declaration should have been tracked as added, and the added declaration to have updated property value");
+});
diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_remove_disabled.js b/devtools/client/inspector/changes/test/browser_changes_declaration_remove_disabled.js
new file mode 100644
index 0000000000..258c3100d3
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_declaration_remove_disabled.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that disabling a CSS declaration and then removing it from the Rule view
+// is tracked as removed only once. Toggling leftover declarations should not introduce
+// duplicate changes.
+
+const TEST_URI = `
+ <style type='text/css'>
+ div {
+ color: red;
+ background: black;
+ }
+ </style>
+ <div></div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const { document: doc, store } = selectChangesView(inspector);
+
+ await selectNode("div", inspector);
+ const prop1 = getTextProperty(ruleView, 1, { color: "red" });
+ const prop2 = getTextProperty(ruleView, 1, { background: "black" });
+
+ info("Using the second declaration");
+ await testRemoveValue(ruleView, store, doc, prop2);
+ info("Using the first declaration");
+ await testToggle(ruleView, store, doc, prop1);
+ info("Using the first declaration");
+ await testRemoveName(ruleView, store, doc, prop1);
+});
+
+async function testRemoveValue(ruleView, store, doc, prop) {
+ info("Test removing disabled declaration by clearing its property value.");
+ let onTrackChange;
+
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Disable the declaration");
+ await togglePropStatus(ruleView, prop);
+ info("Wait for change to be tracked");
+ await onTrackChange;
+
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Remove the disabled declaration by clearing its value");
+ await setProperty(ruleView, prop, null);
+ await onTrackChange;
+
+ const removeDecl = getRemovedDeclarations(doc);
+ is(removeDecl.length, 1, "Only one declaration tracked as removed");
+}
+
+async function testToggle(ruleView, store, doc, prop) {
+ info(
+ "Test toggling leftover declaration off and on will not track extra changes."
+ );
+ let onTrackChange;
+
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Disable the declaration");
+ await togglePropStatus(ruleView, prop);
+ await onTrackChange;
+
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Re-enable the declaration");
+ await togglePropStatus(ruleView, prop);
+ await onTrackChange;
+
+ await waitFor(
+ () => getRemovedDeclarations(doc).length == 1,
+ "Still just one declaration tracked as removed"
+ );
+}
+
+async function testRemoveName(ruleView, store, doc, prop) {
+ info("Test removing disabled declaration by clearing its property name.");
+ let onTrackChange;
+
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Disable the declaration");
+ await togglePropStatus(ruleView, prop);
+ await onTrackChange;
+
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Remove the disabled declaration by clearing its name");
+ await removeProperty(ruleView, prop);
+ await onTrackChange;
+
+ info(`Expecting two declarations removed:
+ - one removed by its value in the other test
+ - one removed by its name from this test
+ `);
+
+ await waitFor(
+ () => getRemovedDeclarations(doc).length == 2,
+ "Two declarations tracked as removed"
+ );
+ const removeDecl = getRemovedDeclarations(doc);
+ is(removeDecl[0].property, "background", "First declaration name correct");
+ is(removeDecl[0].value, "black", "First declaration value correct");
+ is(removeDecl[1].property, "color", "Second declaration name correct");
+ is(removeDecl[1].value, "red", "Second declaration value correct");
+}
diff --git a/devtools/client/inspector/changes/test/browser_changes_declaration_rename.js b/devtools/client/inspector/changes/test/browser_changes_declaration_rename.js
new file mode 100644
index 0000000000..245ce50121
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_declaration_rename.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that renaming the property of a CSS declaration in the Rule view is tracked.
+
+const TEST_URI = `
+ <style type='text/css'>
+ div {
+ color: red;
+ }
+ </style>
+ <div></div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const { document: doc, store } = selectChangesView(inspector);
+
+ await selectNode("div", inspector);
+ const prop = getTextProperty(ruleView, 1, { color: "red" });
+
+ let onTrackChange;
+
+ const oldPropertyName = "color";
+ const newPropertyName = "background-color";
+
+ info(`Rename the CSS declaration name to ${newPropertyName}`);
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ await renameProperty(ruleView, prop, newPropertyName);
+ info("Wait for the change to be tracked");
+ await onTrackChange;
+
+ const removeDecl = getRemovedDeclarations(doc);
+ const addDecl = getAddedDeclarations(doc);
+
+ is(removeDecl.length, 1, "One declaration tracked as removed");
+ is(
+ removeDecl[0].property,
+ oldPropertyName,
+ `Removed declaration has old property name: ${oldPropertyName}`
+ );
+ is(addDecl.length, 1, "One declaration tracked as added");
+ is(
+ addDecl[0].property,
+ newPropertyName,
+ `Added declaration has new property name: ${newPropertyName}`
+ );
+
+ info(
+ `Reverting the CSS declaration name to ${oldPropertyName} should clear changes.`
+ );
+ onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ await renameProperty(ruleView, prop, oldPropertyName);
+ info("Wait for the change to be tracked");
+ await onTrackChange;
+
+ await waitFor(
+ () => !getRemovedDeclarations(doc).length,
+ "No declaration tracked as removed"
+ );
+ await waitFor(
+ () => !getAddedDeclarations(doc).length,
+ "No declaration tracked as added"
+ );
+});
diff --git a/devtools/client/inspector/changes/test/browser_changes_rule_add.js b/devtools/client/inspector/changes/test/browser_changes_rule_add.js
new file mode 100644
index 0000000000..215f1f3605
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_rule_add.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that adding a new CSS rule in the Rules view is tracked in the Changes panel.
+// Renaming the selector of the new rule should overwrite the tracked selector.
+
+const TEST_URI = `
+ <div></div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const { document: doc, store } = selectChangesView(inspector);
+ const panel = doc.querySelector("#sidebar-panel-changes");
+
+ await selectNode("div", inspector);
+ await testTrackAddNewRule(store, inspector, ruleView, panel);
+ await testTrackRenameNewRule(store, inspector, ruleView, panel);
+});
+
+async function testTrackAddNewRule(store, inspector, ruleView, panel) {
+ const onTrackChange = waitForDispatch(store, "TRACK_CHANGE");
+ info("Adding a new CSS rule in the Rule view");
+ await addNewRule(inspector, ruleView);
+ info("Pressing escape to leave the editor");
+ EventUtils.synthesizeKey("KEY_Escape");
+ info("Waiting for changes to be tracked");
+ await onTrackChange;
+
+ const addedSelectors = getAddedSelectors(panel);
+ const removedSelectors = getRemovedSelectors(panel);
+ is(addedSelectors.length, 1, "One selector was tracked as added");
+ is(addedSelectors.item(0).title, "div", "New rule's has DIV selector");
+ is(removedSelectors.length, 0, "No selectors tracked as removed");
+}
+
+async function testTrackRenameNewRule(store, inspector, ruleView, panel) {
+ info("Focusing the first rule's selector name in the Rule view");
+ const ruleEditor = getRuleViewRuleEditor(ruleView, 1);
+ const editor = await focusEditableField(ruleView, ruleEditor.selectorText);
+ info("Entering a new selector name");
+ editor.input.value = ".test";
+
+ // Expect two "TRACK_CHANGE" actions: one for removal, one for addition.
+ const onTrackChange = waitForDispatch(store, "TRACK_CHANGE", 2);
+ const onRuleViewChanged = once(ruleView, "ruleview-changed");
+ EventUtils.synthesizeKey("KEY_Enter");
+ await onRuleViewChanged;
+ info("Waiting for changes to be tracked");
+ await onTrackChange;
+
+ const addedSelectors = getAddedSelectors(panel);
+ const removedSelectors = getRemovedSelectors(panel);
+ is(addedSelectors.length, 1, "One selector was tracked as added");
+ is(
+ addedSelectors.item(0).title,
+ ".test",
+ "New rule's selector was updated in place."
+ );
+ is(removedSelectors.length, 0, "No selectors tracked as removed");
+}
diff --git a/devtools/client/inspector/changes/test/browser_changes_rule_selector.js b/devtools/client/inspector/changes/test/browser_changes_rule_selector.js
new file mode 100644
index 0000000000..20d3fba654
--- /dev/null
+++ b/devtools/client/inspector/changes/test/browser_changes_rule_selector.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that renaming the selector of a CSS rule is tracked.
+// Expect a selector removal followed by a selector addition and no changed declarations
+
+const TEST_URI = `
+ <style type='text/css'>
+ div {
+ color: red;
+ }
+ </style>
+ <div></div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view: ruleView } = await openRuleView();
+ const { document: doc, store } = selectChangesView(inspector);
+ const panel = doc.querySelector("#sidebar-panel-changes");
+
+ await selectNode("div", inspector);
+ const ruleEditor = getRuleViewRuleEditor(ruleView, 1);
+
+ info("Focusing the first rule's selector name in the Rule view");
+ const editor = await focusEditableField(ruleView, ruleEditor.selectorText);
+ info("Entering a new selector name");
+ editor.input.value = ".test";
+
+ // Expect two "TRACK_CHANGE" actions: one for removal, one for addition.
+ const onTrackChange = waitForDispatch(store, "TRACK_CHANGE", 2);
+ const onRuleViewChanged = once(ruleView, "ruleview-changed");
+ info("Pressing Enter key to commit the change");
+ EventUtils.synthesizeKey("KEY_Enter");
+ info("Waiting for rule view to update");
+ await onRuleViewChanged;
+ info("Wait for the change to be tracked");
+ await onTrackChange;
+
+ const rules = panel.querySelectorAll(".changes__rule");
+ is(rules.length, 1, "One rule was tracked as changed");
+
+ info("Expect old selector to be removed and new selector to be added");
+ const addedSelectors = getAddedSelectors(panel);
+ const removedSelectors = getRemovedSelectors(panel);
+ is(addedSelectors.length, 1, "One new selector was tracked as added");
+ is(addedSelectors.item(0).title, ".test", "New selector is correct");
+ is(removedSelectors.length, 1, "One old selector was tracked as removed");
+ is(removedSelectors.item(0).title, "div", "Old selector is correct");
+
+ info(
+ "Expect no declarations to have been added or removed during selector change"
+ );
+ const removeDecl = getRemovedDeclarations(doc, rules.item(0));
+ is(removeDecl.length, 0, "No declarations removed");
+ const addDecl = getAddedDeclarations(doc, rules.item(0));
+ is(addDecl.length, 0, "No declarations added");
+});
diff --git a/devtools/client/inspector/changes/test/head.js b/devtools/client/inspector/changes/test/head.js
new file mode 100644
index 0000000000..b45af3ba47
--- /dev/null
+++ b/devtools/client/inspector/changes/test/head.js
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint no-unused-vars: [2, {"vars": "local"}] */
+
+"use strict";
+
+// Load the Rule view's test/head.js to make use of its helpers.
+// It loads inspector/test/head.js which itself loads inspector/test/shared-head.js
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/inspector/rules/test/head.js",
+ this
+);
+
+// Ensure the three-pane mode is enabled before running the tests.
+Services.prefs.setBoolPref("devtools.inspector.three-pane-enabled", true);
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("devtools.inspector.three-pane-enabled");
+});
+
+/**
+ * Get an array of objects with property/value pairs of the CSS declarations rendered
+ * in the Changes panel.
+ *
+ * @param {Document} panelDoc
+ * Host document of the Changes panel.
+ * @param {String} selector
+ * Optional selector to filter rendered declaration DOM elements.
+ * One of ".diff-remove" or ".diff-add".
+ * If omitted, all declarations will be returned.
+ * @param {DOMNode} containerNode
+ * Optional element to restrict results to declaration DOM elements which are
+ * descendants of this container node.
+ * If omitted, all declarations will be returned
+ * @return {Array}
+ */
+function getDeclarations(panelDoc, selector = "", containerNode = null) {
+ const els = panelDoc.querySelectorAll(`.changes__declaration${selector}`);
+
+ return [...els]
+ .filter(el => {
+ return containerNode ? containerNode.contains(el) : true;
+ })
+ .map(el => {
+ return {
+ property: el.querySelector(".changes__declaration-name").textContent,
+ value: el.querySelector(".changes__declaration-value").textContent,
+ element: el,
+ };
+ });
+}
+
+function getAddedDeclarations(panelDoc, containerNode) {
+ return getDeclarations(panelDoc, ".diff-add", containerNode);
+}
+
+function getRemovedDeclarations(panelDoc, containerNode) {
+ return getDeclarations(panelDoc, ".diff-remove", containerNode);
+}
+
+/**
+ * Get an array of DOM elements for the CSS selectors rendered in the Changes panel.
+ *
+ * @param {Document} panelDoc
+ * Host document of the Changes panel.
+ * @param {String} selector
+ * Optional selector to filter rendered selector DOM elements.
+ * One of ".diff-remove" or ".diff-add".
+ * If omitted, all selectors will be returned.
+ * @return {Array}
+ */
+function getSelectors(panelDoc, selector = "") {
+ return panelDoc.querySelectorAll(`.changes__selector${selector}`);
+}
+
+function getAddedSelectors(panelDoc) {
+ return getSelectors(panelDoc, ".diff-add");
+}
+
+function getRemovedSelectors(panelDoc) {
+ return getSelectors(panelDoc, ".diff-remove");
+}
+
+async function getChangesContextMenu(changesView, element) {
+ const onContextMenu = changesView.contextMenu.once("open");
+ info(`Trigger context menu for element: ${element}`);
+ synthesizeContextMenuEvent(element);
+ info(`Wait for context menu to show`);
+ await onContextMenu;
+
+ return changesView.contextMenu;
+}
diff --git a/devtools/client/inspector/changes/test/xpcshell/.eslintrc.js b/devtools/client/inspector/changes/test/xpcshell/.eslintrc.js
new file mode 100644
index 0000000000..86bd54c245
--- /dev/null
+++ b/devtools/client/inspector/changes/test/xpcshell/.eslintrc.js
@@ -0,0 +1,6 @@
+"use strict";
+
+module.exports = {
+ // Extend from the common devtools xpcshell eslintrc config.
+ extends: "../../../../../.eslintrc.xpcshell.js",
+};
diff --git a/devtools/client/inspector/changes/test/xpcshell/head.js b/devtools/client/inspector/changes/test/xpcshell/head.js
new file mode 100644
index 0000000000..f08a79dd71
--- /dev/null
+++ b/devtools/client/inspector/changes/test/xpcshell/head.js
@@ -0,0 +1,8 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+);
diff --git a/devtools/client/inspector/changes/test/xpcshell/mocks.js b/devtools/client/inspector/changes/test/xpcshell/mocks.js
new file mode 100644
index 0000000000..52f175beb8
--- /dev/null
+++ b/devtools/client/inspector/changes/test/xpcshell/mocks.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable comma-dangle */
+
+"use strict";
+
+/**
+ * Snapshot of the Redux state for the Changes panel.
+ *
+ * It corresponds to the tracking of a single property value change (background-color)
+ * within a deeply nested CSS at-rule structure from an inline stylesheet:
+ *
+ * @media (min-width: 50em) {
+ * @supports (display: grid) {
+ * body {
+ * - background-color: royalblue;
+ * + background-color: red;
+ * }
+ * }
+ * }
+ */
+module.exports.CHANGES_STATE = {
+ source1: {
+ type: "inline",
+ href: "http://localhost:5000/at-rules-nested.html",
+ id: "source1",
+ index: 0,
+ isFramed: false,
+ rules: {
+ rule1: {
+ selectors: ["@media (min-width: 50em)"],
+ ruleId: "rule1",
+ add: [],
+ remove: [],
+ children: ["rule2"],
+ },
+ rule2: {
+ selectors: ["@supports (display: grid)"],
+ ruleId: "rule2",
+ add: [],
+ remove: [],
+ children: ["rule3"],
+ parent: "rule1",
+ },
+ rule3: {
+ selectors: ["body"],
+ ruleId: "rule3",
+ add: [
+ {
+ property: "background-color",
+ value: "red",
+ index: 0,
+ },
+ ],
+ remove: [
+ {
+ property: "background-color",
+ value: "royalblue",
+ index: 0,
+ },
+ ],
+ children: [],
+ parent: "rule2",
+ },
+ },
+ },
+};
diff --git a/devtools/client/inspector/changes/test/xpcshell/test_changes_stylesheet.js b/devtools/client/inspector/changes/test/xpcshell/test_changes_stylesheet.js
new file mode 100644
index 0000000000..33a64cfbcb
--- /dev/null
+++ b/devtools/client/inspector/changes/test/xpcshell/test_changes_stylesheet.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that getChangesStylesheet() serializes tracked changes from nested CSS rules
+// into the expected stylesheet format.
+
+const {
+ getChangesStylesheet,
+} = require("resource://devtools/client/inspector/changes/selectors/changes.js");
+
+const { CHANGES_STATE } = require("resource://test/mocks");
+
+// Wrap multi-line string in backticks to ensure exact check in test, including new lines.
+const STYLESHEET_FOR_ANCESTOR = `
+/* Inline #0 | http://localhost:5000/at-rules-nested.html */
+
+@media (min-width: 50em) {
+ @supports (display: grid) {
+ body {
+ /* background-color: royalblue; */
+ background-color: red;
+ }
+ }
+}
+`;
+
+// Wrap multi-line string in backticks to ensure exact check in test, including new lines.
+const STYLESHEET_FOR_DESCENDANT = `
+/* Inline #0 | http://localhost:5000/at-rules-nested.html */
+
+body {
+ /* background-color: royalblue; */
+ background-color: red;
+}
+`;
+
+add_test(() => {
+ info(
+ "Check stylesheet generated for the first ancestor in the CSS rule tree."
+ );
+ equal(
+ getChangesStylesheet(CHANGES_STATE),
+ STYLESHEET_FOR_ANCESTOR,
+ "Stylesheet includes all ancestors."
+ );
+
+ info(
+ "Check stylesheet generated for the last descendant in the CSS rule tree."
+ );
+ const filter = { sourceIds: ["source1"], ruleIds: ["rule3"] };
+ equal(
+ getChangesStylesheet(CHANGES_STATE, filter),
+ STYLESHEET_FOR_DESCENDANT,
+ "Stylesheet includes just descendant."
+ );
+
+ run_next_test();
+});
diff --git a/devtools/client/inspector/changes/test/xpcshell/xpcshell.ini b/devtools/client/inspector/changes/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..886645a708
--- /dev/null
+++ b/devtools/client/inspector/changes/test/xpcshell/xpcshell.ini
@@ -0,0 +1,8 @@
+[DEFAULT]
+tags = devtools
+firefox-appdir = browser
+head = head.js
+support-files =
+ ./mocks.js
+
+[test_changes_stylesheet.js]