diff options
Diffstat (limited to 'devtools/client/inspector/rules/test/head.js')
-rw-r--r-- | devtools/client/inspector/rules/test/head.js | 1284 |
1 files changed, 1284 insertions, 0 deletions
diff --git a/devtools/client/inspector/rules/test/head.js b/devtools/client/inspector/rules/test/head.js new file mode 100644 index 0000000000..40c7bbe3d5 --- /dev/null +++ b/devtools/client/inspector/rules/test/head.js @@ -0,0 +1,1284 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +"use strict"; + +// Import the inspector's head.js first (which itself imports shared-head.js). +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/head.js", + this +); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.defaultColorUnit"); +}); + +var { + getInplaceEditorForSpan: inplaceEditor, +} = require("resource://devtools/client/shared/inplace-editor.js"); + +const { + COMPATIBILITY_TOOLTIP_MESSAGE, +} = require("resource://devtools/client/inspector/rules/constants.js"); + +const ROOT_TEST_DIR = getRootDirectory(gTestPath); + +const STYLE_INSPECTOR_L10N = new LocalizationHelper( + "devtools/shared/locales/styleinspector.properties" +); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.defaultColorUnit"); +}); + +/** + * When a tooltip is closed, this ends up "commiting" the value changed within + * the tooltip (e.g. the color in case of a colorpicker) which, in turn, ends up + * setting the value of the corresponding css property in the rule-view. + * Use this function to close the tooltip and make sure the test waits for the + * ruleview-changed event. + * @param {SwatchBasedEditorTooltip} editorTooltip + * @param {CSSRuleView} view + */ +async function hideTooltipAndWaitForRuleViewChanged(editorTooltip, view) { + const onModified = view.once("ruleview-changed"); + const onHidden = editorTooltip.tooltip.once("hidden"); + editorTooltip.hide(); + await onModified; + await onHidden; +} + +/** + * Polls a given generator function waiting for it to return true. + * + * @param {Function} validatorFn + * A validator generator function that returns a boolean. + * This is called every few milliseconds to check if the result is true. + * When it is true, the promise resolves. + * @param {String} name + * Optional name of the test. This is used to generate + * the success and failure messages. + * @return a promise that resolves when the function returned true or rejects + * if the timeout is reached + */ +var waitForSuccess = async function (validatorFn, desc = "untitled") { + let i = 0; + while (true) { + info("Checking: " + desc); + if (await validatorFn()) { + ok(true, "Success: " + desc); + break; + } + i++; + if (i > 10) { + ok(false, "Failure: " + desc); + break; + } + await new Promise(r => setTimeout(r, 200)); + } +}; + +/** + * Simulate a color change in a given color picker tooltip, and optionally wait + * for a given element in the page to have its style changed as a result. + * Note that this function assumes that the colorpicker popup is already open + * and it won't close it after having selected the new color. + * + * @param {RuleView} ruleView + * The related rule view instance + * @param {SwatchColorPickerTooltip} colorPicker + * @param {Array} newRgba + * The new color to be set [r, g, b, a] + * @param {Object} expectedChange + * Optional object that needs the following props: + * - {String} selector The selector to the element in the page that + * will have its style changed. + * - {String} name The style name that will be changed + * - {String} value The expected style value + * The style will be checked like so: getComputedStyle(element)[name] === value + */ +var simulateColorPickerChange = async function ( + ruleView, + colorPicker, + newRgba, + expectedChange +) { + let onComputedStyleChanged; + if (expectedChange) { + const { selector, name, value } = expectedChange; + onComputedStyleChanged = waitForComputedStyleProperty( + selector, + null, + name, + value + ); + } + const onRuleViewChanged = ruleView.once("ruleview-changed"); + info("Getting the spectrum colorpicker object"); + const spectrum = colorPicker.spectrum; + info("Setting the new color"); + spectrum.rgb = newRgba; + info("Applying the change"); + spectrum.updateUI(); + spectrum.onChange(); + info("Waiting for rule-view to update"); + await onRuleViewChanged; + + if (expectedChange) { + info("Waiting for the style to be applied on the page"); + await onComputedStyleChanged; + } +}; + +/** + * Open the color picker popup for a given property in a given rule and + * simulate a color change. Optionally wait for a given element in the page to + * have its style changed as a result. + * + * @param {RuleView} view + * The related rule view instance + * @param {Number} ruleIndex + * Which rule to target in the rule view + * @param {Number} propIndex + * Which property to target in the rule + * @param {Array} newRgba + * The new color to be set [r, g, b, a] + * @param {Object} expectedChange + * Optional object that needs the following props: + * - {String} selector The selector to the element in the page that + * will have its style changed. + * - {String} name The style name that will be changed + * - {String} value The expected style value + * The style will be checked like so: getComputedStyle(element)[name] === value + */ +var openColorPickerAndSelectColor = async function ( + view, + ruleIndex, + propIndex, + newRgba, + expectedChange +) { + const ruleEditor = getRuleViewRuleEditor(view, ruleIndex); + const propEditor = ruleEditor.rule.textProps[propIndex].editor; + const swatch = propEditor.valueSpan.querySelector(".ruleview-colorswatch"); + const cPicker = view.tooltips.getTooltip("colorPicker"); + + info("Opening the colorpicker by clicking the color swatch"); + const onColorPickerReady = cPicker.once("ready"); + swatch.click(); + await onColorPickerReady; + + await simulateColorPickerChange(view, cPicker, newRgba, expectedChange); + + return { propEditor, swatch, cPicker }; +}; + +/** + * Open the cubicbezier popup for a given property in a given rule and + * simulate a curve change. Optionally wait for a given element in the page to + * have its style changed as a result. + * + * @param {RuleView} view + * The related rule view instance + * @param {Number} ruleIndex + * Which rule to target in the rule view + * @param {Number} propIndex + * Which property to target in the rule + * @param {Array} coords + * The new coordinates to be used, e.g. [0.1, 2, 0.9, -1] + * @param {Object} expectedChange + * Optional object that needs the following props: + * - {String} selector The selector to the element in the page that + * will have its style changed. + * - {String} name The style name that will be changed + * - {String} value The expected style value + * The style will be checked like so: getComputedStyle(element)[name] === value + */ +var openCubicBezierAndChangeCoords = async function ( + view, + ruleIndex, + propIndex, + coords, + expectedChange +) { + const ruleEditor = getRuleViewRuleEditor(view, ruleIndex); + const propEditor = ruleEditor.rule.textProps[propIndex].editor; + const swatch = propEditor.valueSpan.querySelector(".ruleview-bezierswatch"); + const bezierTooltip = view.tooltips.getTooltip("cubicBezier"); + + info("Opening the cubicBezier by clicking the swatch"); + const onBezierWidgetReady = bezierTooltip.once("ready"); + swatch.click(); + await onBezierWidgetReady; + + const widget = await bezierTooltip.widget; + + info("Simulating a change of curve in the widget"); + const onRuleViewChanged = view.once("ruleview-changed"); + widget.coordinates = coords; + await onRuleViewChanged; + + if (expectedChange) { + info("Waiting for the style to be applied on the page"); + const { selector, name, value } = expectedChange; + await waitForComputedStyleProperty(selector, null, name, value); + } + + return { propEditor, swatch, bezierTooltip }; +}; + +/** + * Simulate adding a new property in an existing rule in the rule-view. + * + * @param {CssRuleView} view + * The instance of the rule-view panel + * @param {Number} ruleIndex + * The index of the rule to use. + * @param {String} name + * The name for the new property + * @param {String} value + * The value for the new property + * @param {Object=} options + * @param {String=} options.commitValueWith + * Which key should be used to commit the new value. VK_RETURN is used by + * default, but tests might want to use another key to test cancelling + * for exemple. + * @param {Boolean=} options.blurNewProperty + * After the new value has been added, a new property would have been + * focused. This parameter is true by default, and that causes the new + * property to be blurred. Set to false if you don't want this. + * @return {TextProperty} The instance of the TextProperty that was added + */ +var addProperty = async function ( + view, + ruleIndex, + name, + value, + { commitValueWith = "VK_RETURN", blurNewProperty = true } = {} +) { + info("Adding new property " + name + ":" + value + " to rule " + ruleIndex); + + const ruleEditor = getRuleViewRuleEditor(view, ruleIndex); + let editor = await focusNewRuleViewProperty(ruleEditor); + const numOfProps = ruleEditor.rule.textProps.length; + + const onMutations = new Promise(r => { + // If the rule index is 0, then we are updating the rule for the "element" + // selector in the rule view. + // This rule is actually updating the style attribute of the element, and + // therefore we can expect mutations. + // For any other rule index, no mutation should be created, we can resolve + // immediately. + if (ruleIndex !== 0) { + r(); + } + + // Use CSS.escape for the name in order to match the logic at + // devtools/client/fronts/inspector/rule-rewriter.js + // This leads to odd values in the style attribute and might change in the + // future. See https://bugzilla.mozilla.org/show_bug.cgi?id=1765943 + const expectedAttributeValue = `${CSS.escape(name)}: ${value}`; + view.inspector.walker.on( + "mutations", + function onWalkerMutations(mutations) { + // Wait until we receive a mutation which updates the style attribute + // with the expected value. + const receivedLastMutation = mutations.some( + mut => + mut.attributeName === "style" && + mut.newValue.includes(expectedAttributeValue) + ); + if (receivedLastMutation) { + view.inspector.walker.off("mutations", onWalkerMutations); + r(); + } + } + ); + }); + + info("Adding name " + name); + editor.input.value = name; + const onNameAdded = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + await onNameAdded; + + // Focus has moved to the value inplace-editor automatically. + editor = inplaceEditor(view.styleDocument.activeElement); + const textProps = ruleEditor.rule.textProps; + const textProp = textProps[textProps.length - 1]; + + is( + ruleEditor.rule.textProps.length, + numOfProps + 1, + "A new test property was added" + ); + is( + editor, + inplaceEditor(textProp.editor.valueSpan), + "The inplace editor appeared for the value" + ); + + info("Adding value " + value); + // Setting the input value schedules a preview to be shown in 10ms which + // triggers a ruleview-changed event (see bug 1209295). + const onPreview = view.once("ruleview-changed"); + editor.input.value = value; + view.debounce.flush(); + await onPreview; + + const onValueAdded = view.once("ruleview-changed"); + EventUtils.synthesizeKey(commitValueWith, {}, view.styleWindow); + await onValueAdded; + + info( + "Waiting for DOM mutations in case the property was added to the element style" + ); + await onMutations; + + if (blurNewProperty) { + view.styleDocument.activeElement.blur(); + } + + return textProp; +}; + +/** + * Simulate changing the value of a property in a rule in the rule-view. + * + * @param {CssRuleView} view + * The instance of the rule-view panel + * @param {TextProperty} textProp + * The instance of the TextProperty to be changed + * @param {String} value + * The new value to be used. If null is passed, then the value will be + * deleted + * @param {Object} options + * @param {Boolean} options.blurNewProperty + * After the value has been changed, a new property would have been + * focused. This parameter is true by default, and that causes the new + * property to be blurred. Set to false if you don't want this. + * @param {number} options.flushCount + * The ruleview uses a manual flush for tests only, and some properties are + * only updated after several flush. Allow tests to trigger several flushes + * if necessary. Defaults to 1. + */ +var setProperty = async function ( + view, + textProp, + value, + { blurNewProperty = true, flushCount = 1 } = {} +) { + info("Set property to: " + value); + await focusEditableField(view, textProp.editor.valueSpan); + + // Because of the manual flush approach used for tests, we might have an + // unknown number of debounced "preview" requests . Each preview should + // synchronously emit "start-preview-property-value". + // Listen to both this event and "ruleview-changed" which is emitted at the + // end of a preview and make sure each preview completes successfully. + let previewStartedCounter = 0; + const onStartPreview = () => previewStartedCounter++; + view.on("start-preview-property-value", onStartPreview); + + let previewCounter = 0; + const onPreviewApplied = () => previewCounter++; + view.on("ruleview-changed", onPreviewApplied); + + if (value === null) { + const onPopupOpened = once(view.popup, "popup-opened"); + EventUtils.synthesizeKey("VK_DELETE", {}, view.styleWindow); + await onPopupOpened; + } else { + EventUtils.sendString(value, view.styleWindow); + } + + info(`Flush debounced ruleview methods (remaining: ${flushCount})`); + view.debounce.flush(); + await waitFor(() => previewCounter >= previewStartedCounter); + + flushCount--; + + while (flushCount > 0) { + // Wait for some time before triggering a new flush to let new debounced + // functions queue in-between. + await wait(100); + + info(`Flush debounced ruleview methods (remaining: ${flushCount})`); + view.debounce.flush(); + await waitFor(() => previewCounter >= previewStartedCounter); + + flushCount--; + } + + view.off("start-preview-property-value", onStartPreview); + view.off("ruleview-changed", onPreviewApplied); + + const onValueDone = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + + info("Waiting for another ruleview-changed after setting property"); + await onValueDone; + + if (blurNewProperty) { + info("Force blur on the active element"); + view.styleDocument.activeElement.blur(); + } +}; + +/** + * Change the name of a property in a rule in the rule-view. + * + * @param {CssRuleView} view + * The instance of the rule-view panel. + * @param {TextProperty} textProp + * The instance of the TextProperty to be changed. + * @param {String} name + * The new property name. + */ +var renameProperty = async function (view, textProp, name) { + await focusEditableField(view, textProp.editor.nameSpan); + + const onNameDone = view.once("ruleview-changed"); + info(`Rename the property to ${name}`); + EventUtils.sendString(name, view.styleWindow); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + info("Wait for property name."); + await onNameDone; + // Renaming the property auto-advances the focus to the value input. Exiting without + // committing will still fire a change event. @see TextPropertyEditor._onValueDone(). + // Wait for that event too before proceeding. + const onValueDone = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_ESCAPE", {}, view.styleWindow); + info("Wait for property value."); + await onValueDone; +}; + +/** + * Simulate removing a property from an existing rule in the rule-view. + * + * @param {CssRuleView} view + * The instance of the rule-view panel + * @param {TextProperty} textProp + * The instance of the TextProperty to be removed + * @param {Boolean} blurNewProperty + * After the property has been removed, a new property would have been + * focused. This parameter is true by default, and that causes the new + * property to be blurred. Set to false if you don't want this. + */ +var removeProperty = async function (view, textProp, blurNewProperty = true) { + await focusEditableField(view, textProp.editor.nameSpan); + + const onModifications = view.once("ruleview-changed"); + info("Deleting the property name now"); + EventUtils.synthesizeKey("VK_DELETE", {}, view.styleWindow); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + await onModifications; + + if (blurNewProperty) { + view.styleDocument.activeElement.blur(); + } +}; + +/** + * Simulate clicking the enable/disable checkbox next to a property in a rule. + * + * @param {CssRuleView} view + * The instance of the rule-view panel + * @param {TextProperty} textProp + * The instance of the TextProperty to be enabled/disabled + */ +var togglePropStatus = async function (view, textProp) { + const onRuleViewRefreshed = view.once("ruleview-changed"); + textProp.editor.enable.click(); + await onRuleViewRefreshed; +}; + +/** + * Create a new rule by clicking on the "add rule" button. + * This will leave the selector inplace-editor active. + * + * @param {InspectorPanel} inspector + * The instance of InspectorPanel currently loaded in the toolbox + * @param {CssRuleView} view + * The instance of the rule-view panel + * @return a promise that resolves after the rule has been added + */ +async function addNewRule(inspector, view) { + info("Adding the new rule using the button"); + view.addRuleButton.click(); + + info("Waiting for rule view to change"); + await view.once("ruleview-changed"); +} + +/** + * Create a new rule by clicking on the "add rule" button, dismiss the editor field and + * verify that the selector is correct. + * + * @param {InspectorPanel} inspector + * The instance of InspectorPanel currently loaded in the toolbox + * @param {CssRuleView} view + * The instance of the rule-view panel + * @param {String} expectedSelector + * The value we expect the selector to have + * @param {Number} expectedIndex + * The index we expect the rule to have in the rule-view + * @return a promise that resolves after the rule has been added + */ +async function addNewRuleAndDismissEditor( + inspector, + view, + expectedSelector, + expectedIndex +) { + await addNewRule(inspector, view); + + info("Getting the new rule at index " + expectedIndex); + const ruleEditor = getRuleViewRuleEditor(view, expectedIndex); + const editor = ruleEditor.selectorText.ownerDocument.activeElement; + is( + editor.value, + expectedSelector, + "The editor for the new selector has the correct value: " + expectedSelector + ); + + info("Pressing escape to leave the editor"); + EventUtils.synthesizeKey("KEY_Escape"); + + is( + ruleEditor.selectorText.textContent, + expectedSelector, + "The new selector has the correct text: " + expectedSelector + ); +} + +/** + * Simulate a sequence of non-character keys (return, escape, tab) and wait for + * a given element to receive the focus. + * + * @param {CssRuleView} view + * The instance of the rule-view panel + * @param {DOMNode} element + * The element that should be focused + * @param {Array} keys + * Array of non-character keys, the part that comes after "DOM_VK_" eg. + * "RETURN", "ESCAPE" + * @return a promise that resolves after the element received the focus + */ +async function sendKeysAndWaitForFocus(view, element, keys) { + const onFocus = once(element, "focus", true); + for (const key of keys) { + EventUtils.sendKey(key, view.styleWindow); + } + await onFocus; +} + +/** + * Wait for a markupmutation event on the inspector that is for a style modification. + * @param {InspectorPanel} inspector + * @return {Promise} + */ +function waitForStyleModification(inspector) { + return new Promise(function (resolve) { + function checkForStyleModification(mutations) { + for (const mutation of mutations) { + if ( + mutation.type === "attributes" && + mutation.attributeName === "style" + ) { + inspector.off("markupmutation", checkForStyleModification); + resolve(); + return; + } + } + } + inspector.on("markupmutation", checkForStyleModification); + }); +} + +/** + * Click on the icon next to the selector of a CSS rule in the Rules view + * to toggle the selector highlighter. If a selector highlighter is not already visible + * for the given selector, wait for it to be shown. Otherwise, wait for it to be hidden. + * + * @param {CssRuleView} view + * The instance of the Rules view + * @param {String} selectorText + * The selector of the CSS rule to look for + * @param {Number} index + * If there are more CSS rules with the same selector, use this index + * to determine which one should be retrieved. Defaults to 0 (first) + */ +async function clickSelectorIcon(view, selectorText, index = 0) { + const { inspector } = view; + const rule = getRuleViewRule(view, selectorText, index); + + info(`Waiting for icon to be available for selector: ${selectorText}`); + const icon = await waitFor(() => { + return rule.querySelector(".js-toggle-selector-highlighter"); + }); + + // Grab the actual selector associated with the matched icon. + // For inline styles, the CSS rule with the "element" selector actually points to + // a generated unique selector, for example: "div:nth-child(1)". + // The selector highlighter is invoked with this unique selector. + // Continuing to use selectorText ("element") would fail some of the checks below. + const selector = icon.dataset.selector; + + const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } = + getHighlighterTestHelpers(inspector); + + // If there is an active selector highlighter, get its configuration options. + // Will be undefined if there isn't an active selector highlighter. + const options = inspector.highlighters.getOptionsForActiveHighlighter( + inspector.highlighters.TYPES.SELECTOR + ); + + // If there is already a highlighter visible for this selector, + // wait for hidden event. Otherwise, wait for shown event. + const waitForEvent = + options?.selector === selector + ? waitForHighlighterTypeHidden(inspector.highlighters.TYPES.SELECTOR) + : waitForHighlighterTypeShown(inspector.highlighters.TYPES.SELECTOR); + + // Boolean flag whether we waited for a highlighter shown event + const waitedForShown = options?.selector !== selector; + + info(`Click the icon for selector: ${selectorText}`); + EventUtils.synthesizeMouseAtCenter(icon, {}, view.styleWindow); + + // Promise resolves with event data from either highlighter shown or hidden event. + const data = await waitForEvent; + return { ...data, isShown: waitedForShown }; +} +/** + * Toggle one of the checkboxes inside the class-panel. Resolved after the DOM mutation + * has been recorded. + * @param {CssRuleView} view The rule-view instance. + * @param {String} name The class name to find the checkbox. + */ +async function toggleClassPanelCheckBox(view, name) { + info(`Clicking on checkbox for class ${name}`); + const checkBox = [ + ...view.classPanel.querySelectorAll("[type=checkbox]"), + ].find(box => { + return box.dataset.name === name; + }); + + const onMutation = view.inspector.once("markupmutation"); + checkBox.click(); + info("Waiting for a markupmutation as a result of toggling this class"); + await onMutation; +} + +/** + * Verify the content of the class-panel. + * @param {CssRuleView} view The rule-view instance + * @param {Array} classes The list of expected classes. Each item in this array is an + * object with the following properties: {name: {String}, state: {Boolean}} + */ +function checkClassPanelContent(view, classes) { + const checkBoxNodeList = view.classPanel.querySelectorAll("[type=checkbox]"); + is( + checkBoxNodeList.length, + classes.length, + "The panel contains the expected number of checkboxes" + ); + + for (let i = 0; i < classes.length; i++) { + is( + checkBoxNodeList[i].dataset.name, + classes[i].name, + `Checkbox ${i} has the right class name` + ); + is( + checkBoxNodeList[i].checked, + classes[i].state, + `Checkbox ${i} has the right state` + ); + } +} + +/** + * Opens the eyedropper from the colorpicker tooltip + * by selecting the colorpicker and then selecting the eyedropper icon + * @param {view} ruleView + * @param {swatch} color swatch of a particular property + */ +async function openEyedropper(view, swatch) { + const tooltip = view.tooltips.getTooltip("colorPicker").tooltip; + + info("Click on the swatch"); + const onColorPickerReady = view.tooltips + .getTooltip("colorPicker") + .once("ready"); + EventUtils.synthesizeMouseAtCenter(swatch, {}, swatch.ownerGlobal); + await onColorPickerReady; + + const dropperButton = tooltip.container.querySelector("#eyedropper-button"); + + info("Click on the eyedropper icon"); + const onOpened = tooltip.once("eyedropper-opened"); + dropperButton.click(); + await onOpened; +} + +/** + * Gets a set of declarations for a rule index. + * + * @param {ruleView} view + * The rule-view instance. + * @param {Number} ruleIndex + * The index we expect the rule to have in the rule-view. + * @param {boolean} addCompatibilityData + * Optional argument to add compatibility dat with the property data + * + * @returns A Promise that resolves with a Map containing stringified property declarations e.g. + * [ + * { + * "color:red": + * { + * propertyName: "color", + * propertyValue: "red", + * warning: "This won't work", + * used: true, + * compatibilityData: { + * isCompatible: true, + * }, + * } + * }, + * ... + * ] + */ +async function getPropertiesForRuleIndex( + view, + ruleIndex, + addCompatibilityData = false +) { + const declaration = new Map(); + const ruleEditor = getRuleViewRuleEditor(view, ruleIndex); + for (const currProp of ruleEditor.rule.textProps) { + const icon = currProp.editor.unusedState; + const unused = currProp.editor.element.classList.contains("unused"); + + let compatibilityData; + let compatibilityIcon; + if (addCompatibilityData) { + compatibilityData = await currProp.isCompatible(); + compatibilityIcon = currProp.editor.compatibilityState; + } + + declaration.set(`${currProp.name}:${currProp.value}`, { + propertyName: currProp.name, + propertyValue: currProp.value, + icon, + data: currProp.isUsed(), + warning: unused, + used: !unused, + ...(addCompatibilityData + ? { + compatibilityData, + compatibilityIcon, + } + : {}), + }); + } + + return declaration; +} + +/** + * Toggle a declaration disabled or enabled. + * + * @param {ruleView} view + * The rule-view instance + * @param {Number} ruleIndex + * The index of the CSS rule where we can find the declaration to be + * toggled. + * @param {Object} declaration + * An object representing the declaration e.g. { color: "red" }. + */ +async function toggleDeclaration(view, ruleIndex, declaration) { + const textProp = getTextProperty(view, ruleIndex, declaration); + const [[name, value]] = Object.entries(declaration); + const dec = `${name}:${value}`; + ok(textProp, `Declaration "${dec}" found`); + + const newStatus = textProp.enabled ? "disabled" : "enabled"; + info(`Toggling declaration "${dec}" of rule ${ruleIndex} to ${newStatus}`); + + await togglePropStatus(view, textProp); + info("Toggled successfully."); +} + +/** + * Update a declaration from a CSS rule in the Rules view + * by changing its property name, property value or both. + * + * @param {RuleView} view + * Instance of RuleView. + * @param {Number} ruleIndex + * The index of the CSS rule where to find the declaration. + * @param {Object} declaration + * An object representing the target declaration e.g. { color: red }. + * @param {Object} newDeclaration + * An object representing the desired updated declaration e.g. { display: none }. + */ +async function updateDeclaration( + view, + ruleIndex, + declaration, + newDeclaration = {} +) { + const textProp = getTextProperty(view, ruleIndex, declaration); + const [[name, value]] = Object.entries(declaration); + const [[newName, newValue]] = Object.entries(newDeclaration); + + if (newName && name !== newName) { + info( + `Updating declaration ${name}:${value}; + Changing ${name} to ${newName}` + ); + await renameProperty(view, textProp, newName); + } + + if (newValue && value !== newValue) { + info( + `Updating declaration ${name}:${value}; + Changing ${value} to ${newValue}` + ); + await setProperty(view, textProp, newValue); + } +} + +/** + * Get the TextProperty instance corresponding to a CSS declaration + * from a CSS rule in the Rules view. + * + * @param {RuleView} view + * Instance of RuleView. + * @param {Number} ruleIndex + * The index of the CSS rule where to find the declaration. + * @param {Object} declaration + * An object representing the target declaration e.g. { color: red }. + * The first TextProperty instance which matches will be returned. + * @return {TextProperty} + */ +function getTextProperty(view, ruleIndex, declaration) { + const ruleEditor = getRuleViewRuleEditor(view, ruleIndex); + const [[name, value]] = Object.entries(declaration); + const textProp = ruleEditor.rule.textProps.find(prop => { + return prop.name === name && prop.value === value; + }); + + if (!textProp) { + throw Error( + `Declaration ${name}:${value} not found on rule at index ${ruleIndex}` + ); + } + + return textProp; +} + +/** + * Check whether the given CSS declaration is compatible or not + * + * @param {ruleView} view + * The rule-view instance. + * @param {Number} ruleIndex + * The index we expect the rule to have in the rule-view. + * @param {Object} declaration + * An object representing the declaration e.g. { color: "red" }. + * @param {Object} options + * @param {string | undefined} options.expected + * Expected message ID for the given incompatible property. + * If the expected message is not specified (undefined), the given declaration + * is inferred as cross-browser compatible and is tested for same. + * @param {string | null | undefined} options.expectedLearnMoreUrl + * Expected learn more link. Pass `null` to check that no "Learn more" link is displayed. + */ +async function checkDeclarationCompatibility( + view, + ruleIndex, + declaration, + { expected, expectedLearnMoreUrl } +) { + const declarations = await getPropertiesForRuleIndex(view, ruleIndex, true); + const [[name, value]] = Object.entries(declaration); + const dec = `${name}:${value}`; + const { compatibilityData } = declarations.get(dec); + + is( + !expected, + compatibilityData.isCompatible, + `"${dec}" has the correct compatibility status in the payload` + ); + + is(compatibilityData.msgId, expected, `"${dec}" has expected message ID`); + + if (expected) { + await checkInteractiveTooltip( + view, + "compatibility-tooltip", + ruleIndex, + declaration + ); + } + + if (expectedLearnMoreUrl !== undefined) { + // Show the tooltip + const tooltip = view.tooltips.getTooltip("interactiveTooltip"); + const onTooltipReady = tooltip.once("shown"); + const { compatibilityIcon } = declarations.get(dec); + await view.tooltips.onInteractiveTooltipTargetHover(compatibilityIcon); + tooltip.show(compatibilityIcon); + await onTooltipReady; + + const learnMoreEl = tooltip.panel.querySelector(".link"); + if (expectedLearnMoreUrl === null) { + ok(!learnMoreEl, `"${dec}" has no "Learn more" link`); + } else { + ok(learnMoreEl, `"${dec}" has a "Learn more" link`); + + const { link } = await simulateLinkClick(learnMoreEl); + is( + link, + expectedLearnMoreUrl, + `Click on ${dec} "Learn more" link navigates user to expected url` + ); + } + + // Hide the tooltip. + const onTooltipHidden = tooltip.once("hidden"); + tooltip.hide(); + await onTooltipHidden; + } +} + +/** + * Check that a declaration is marked inactive and that it has the expected + * warning. + * + * @param {ruleView} view + * The rule-view instance. + * @param {Number} ruleIndex + * The index we expect the rule to have in the rule-view. + * @param {Object} declaration + * An object representing the declaration e.g. { color: "red" }. + */ +async function checkDeclarationIsInactive(view, ruleIndex, declaration) { + const declarations = await getPropertiesForRuleIndex(view, ruleIndex); + const [[name, value]] = Object.entries(declaration); + const dec = `${name}:${value}`; + const { used, warning } = declarations.get(dec); + + ok(!used, `"${dec}" is inactive`); + ok(warning, `"${dec}" has a warning`); + + await checkInteractiveTooltip( + view, + "inactive-css-tooltip", + ruleIndex, + declaration + ); +} + +/** + * Check that a declaration is marked active. + * + * @param {ruleView} view + * The rule-view instance. + * @param {Number} ruleIndex + * The index we expect the rule to have in the rule-view. + * @param {Object} declaration + * An object representing the declaration e.g. { color: "red" }. + */ +async function checkDeclarationIsActive(view, ruleIndex, declaration) { + const declarations = await getPropertiesForRuleIndex(view, ruleIndex); + const [[name, value]] = Object.entries(declaration); + const dec = `${name}:${value}`; + const { used, warning } = declarations.get(dec); + + ok(used, `${dec} is active`); + ok(!warning, `${dec} has no warning`); +} + +/** + * Check that a tooltip contains the correct value. + * + * @param {ruleView} view + * The rule-view instance. + * @param {string} type + * The interactive tooltip type being tested. + * @param {Number} ruleIndex + * The index we expect the rule to have in the rule-view. + * @param {Object} declaration + * An object representing the declaration e.g. { color: "red" }. + */ +async function checkInteractiveTooltip(view, type, ruleIndex, declaration) { + // Get the declaration + const declarations = await getPropertiesForRuleIndex( + view, + ruleIndex, + type === "compatibility-tooltip" + ); + const [[name, value]] = Object.entries(declaration); + const dec = `${name}:${value}`; + + // Get the relevant icon and tooltip payload data + let icon; + let data; + if (type === "inactive-css-tooltip") { + ({ icon, data } = declarations.get(dec)); + } else { + const { compatibilityIcon, compatibilityData } = declarations.get(dec); + icon = compatibilityIcon; + data = compatibilityData; + } + + // Get the tooltip. + const tooltip = view.tooltips.getTooltip("interactiveTooltip"); + + // Get the necessary tooltip helper to fetch the Fluent template. + let tooltipHelper; + if (type === "inactive-css-tooltip") { + tooltipHelper = view.tooltips.inactiveCssTooltipHelper; + } else { + tooltipHelper = view.tooltips.compatibilityTooltipHelper; + } + + // Get the HTML template. + const template = tooltipHelper.getTemplate(data, tooltip); + + // Translate the template using Fluent. + const { doc } = tooltip; + await doc.l10n.translateFragment(template); + + // Get the expected HTML content of the now translated template. + const expected = template.firstElementChild.outerHTML; + + // Show the tooltip for the correct icon. + const onTooltipReady = tooltip.once("shown"); + await view.tooltips.onInteractiveTooltipTargetHover(icon); + tooltip.show(icon); + await onTooltipReady; + + // Get the tooltip's actual HTML content. + const actual = tooltip.panel.firstElementChild.outerHTML; + + // Hide the tooltip. + const onTooltipHidden = tooltip.once("hidden"); + tooltip.hide(); + await onTooltipHidden; + + // Finally, check the values. + is(actual, expected, "Tooltip contains the correct value."); +} + +/** + * CSS compatibility test runner. + * + * @param {ruleView} view + * The rule-view instance. + * @param {InspectorPanel} inspector + * The instance of InspectorPanel currently loaded in the toolbox. + * @param {Array} tests + * An array of test object for this method to consume e.g. + * [ + * { + * selector: "#flex-item", + * rules: [ + * // Rule Index: 0 + * { + * // If the object doesn't include the "expected" + * // key, we consider the declaration as + * // cross-browser compatible and test for same + * color: { value: "green" }, + * }, + * // Rule Index: 1 + * { + * cursor: + * { + * value: "grab", + * expected: INCOMPATIBILITY_TOOLTIP_MESSAGE.default, + * expectedLearnMoreUrl: "https://developer.mozilla.org/en-US/docs/Web/CSS/cursor", + * }, + * }, + * ], + * }, + * ... + * ] + */ +async function runCSSCompatibilityTests(view, inspector, tests) { + for (const test of tests) { + if (test.selector) { + await selectNode(test.selector, inspector); + } + + for (const [ruleIndex, rules] of test.rules.entries()) { + for (const rule in rules) { + await checkDeclarationCompatibility( + view, + ruleIndex, + { + [rule]: rules[rule].value, + }, + { + expected: rules[rule].expected, + expectedLearnMoreUrl: rules[rule].expectedLearnMoreUrl, + } + ); + } + } + } +} + +/** + * Inactive CSS test runner. + * + * @param {ruleView} view + * The rule-view instance. + * @param {InspectorPanel} inspector + * The instance of InspectorPanel currently loaded in the toolbox. + * @param {Array} tests + * An array of test object for this method to consume e.g. + * [ + * { + * selector: "#flex-item", + * activeDeclarations: [ + * { + * declarations: { + * "order": "2", + * }, + * ruleIndex: 0, + * }, + * { + * declarations: { + * "flex-basis": "auto", + * "flex-grow": "1", + * "flex-shrink": "1", + * }, + * ruleIndex: 1, + * }, + * ], + * inactiveDeclarations: [ + * { + * declaration: { + * "flex-direction": "row", + * }, + * ruleIndex: 1, + * }, + * ], + * }, + * ... + * ] + */ +async function runInactiveCSSTests(view, inspector, tests) { + for (const test of tests) { + if (test.selector) { + await selectNode(test.selector, inspector); + } + + if (test.activeDeclarations) { + info("Checking whether declarations are marked as used."); + + for (const activeDeclarations of test.activeDeclarations) { + for (const [name, value] of Object.entries( + activeDeclarations.declarations + )) { + await checkDeclarationIsActive(view, activeDeclarations.ruleIndex, { + [name]: value, + }); + } + } + } + + if (test.inactiveDeclarations) { + info("Checking that declarations are unused and have a warning."); + + for (const inactiveDeclaration of test.inactiveDeclarations) { + await checkDeclarationIsInactive( + view, + inactiveDeclaration.ruleIndex, + inactiveDeclaration.declaration + ); + } + } + } +} + +/** + * Return the checkbox element from the Rules view corresponding + * to the given pseudo-class. + * + * @param {Object} view + * Instance of RuleView. + * @param {String} pseudo + * Pseudo-class, like :hover, :active, :focus, etc. + * @return {HTMLElement} + */ +function getPseudoClassCheckbox(view, pseudo) { + return view.pseudoClassCheckboxes.filter( + checkbox => checkbox.value === pseudo + )[0]; +} + +/** + * Check that the CSS variable output has the expected class name and data attribute. + * + * @param {RulesView} view + * The RulesView instance. + * @param {String} selector + * Selector name for a rule. (e.g. "div", "div::before" and ".sample" etc); + * @param {String} propertyName + * Property name (e.g. "color" and "padding-top" etc); + * @param {String} expectedClassName + * The class name the variable should have. + * @param {String} expectedDatasetValue + * The variable data attribute value. + */ +function checkCSSVariableOutput( + view, + selector, + propertyName, + expectedClassName, + expectedDatasetValue +) { + const target = getRuleViewProperty( + view, + selector, + propertyName + ).valueSpan.querySelector(`.${expectedClassName}`); + + ok(target, "The target element should exist"); + is(target.dataset.variable, expectedDatasetValue); +} + +/** + * Return specific rule ancestor data element (i.e. the one containing @layer / @media + * information) from the Rules view + * + * @param {RulesView} view + * The RulesView instance. + * @param {Number} ruleIndex + * @returns {HTMLElement} + */ +function getRuleViewAncestorRulesDataElementByIndex(view, ruleIndex) { + return view.styleDocument + .querySelectorAll(`.ruleview-rule`) + [ruleIndex]?.querySelector(`.ruleview-rule-ancestor-data`); +} + +/** + * Return specific rule ancestor data text from the Rules view. + * Will return something like "@layer topLayer\n@media screen\n@layer". + * + * @param {RulesView} view + * The RulesView instance. + * @param {Number} ruleIndex + * @returns {String} + */ +function getRuleViewAncestorRulesDataTextByIndex(view, ruleIndex) { + return getRuleViewAncestorRulesDataElementByIndex(view, ruleIndex)?.innerText; +} |