diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/client/inspector/rules | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
349 files changed, 35428 insertions, 0 deletions
diff --git a/devtools/client/inspector/rules/constants.js b/devtools/client/inspector/rules/constants.js new file mode 100644 index 0000000000..7414014cb2 --- /dev/null +++ b/devtools/client/inspector/rules/constants.js @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Compatibility tooltip message id shared between the +// models/text-property.js and the tooltip tests +exports.COMPATIBILITY_TOOLTIP_MESSAGE = { + default: "css-compatibility-default-message", + deprecated: "css-compatibility-deprecated-message", + "deprecated-experimental": + "css-compatibility-deprecated-experimental-message", + "deprecated-experimental-supported": + "css-compatibility-deprecated-experimental-supported-message", + "deprecated-supported": "css-compatibility-deprecated-supported-message", + experimental: "css-compatibility-experimental-message", + "experimental-supported": "css-compatibility-experimental-supported-message", +}; diff --git a/devtools/client/inspector/rules/models/class-list.js b/devtools/client/inspector/rules/models/class-list.js new file mode 100644 index 0000000000..9173977382 --- /dev/null +++ b/devtools/client/inspector/rules/models/class-list.js @@ -0,0 +1,271 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +// This serves as a local cache for the classes applied to each of the node we care about +// here. +// The map is indexed by NodeFront. Any time a new node is selected in the inspector, an +// entry is added here, indexed by the corresponding NodeFront. +// The value for each entry is an array of each of the class this node has. Items of this +// array are objects like: { name, isApplied } where the name is the class itself, and +// isApplied is a Boolean indicating if the class is applied on the node or not. +const CLASSES = new WeakMap(); + +/** + * Manages the list classes per DOM elements we care about. + * The actual list is stored in the CLASSES const, indexed by NodeFront objects. + * The responsibility of this class is to be the source of truth for anyone who wants to + * know which classes a given NodeFront has, and which of these are enabled and which are + * disabled. + * It also reacts to DOM mutations so the list of classes is up to date with what is in + * the DOM. + * It can also be used to enable/disable a given class, or add classes. + * + * @param {Inspector} inspector + * The current inspector instance. + */ +class ClassList { + constructor(inspector) { + EventEmitter.decorate(this); + + this.inspector = inspector; + + this.onMutations = this.onMutations.bind(this); + this.inspector.on("markupmutation", this.onMutations); + + this.classListProxyNode = this.inspector.panelDoc.createElement("div"); + this.previewClasses = []; + this.unresolvedStateChanges = []; + } + + destroy() { + this.inspector.off("markupmutation", this.onMutations); + this.inspector = null; + this.classListProxyNode = null; + } + + /** + * The current node selection (which only returns if the node is an ELEMENT_NODE type + * since that's the only type this model can work with.) + */ + get currentNode() { + if ( + this.inspector.selection.isElementNode() && + !this.inspector.selection.isPseudoElementNode() + ) { + return this.inspector.selection.nodeFront; + } + return null; + } + + /** + * The class states for the current node selection. See the documentation of the CLASSES + * constant. + */ + get currentClasses() { + if (!this.currentNode) { + return []; + } + + if (!CLASSES.has(this.currentNode)) { + // Use the proxy node to get a clean list of classes. + this.classListProxyNode.className = this.currentNode.className; + const nodeClasses = [...new Set([...this.classListProxyNode.classList])] + .filter( + className => + !this.previewClasses.some( + previewClass => + previewClass.className === className && + !previewClass.wasAppliedOnNode + ) + ) + .map(name => { + return { name, isApplied: true }; + }); + + CLASSES.set(this.currentNode, nodeClasses); + } + + return CLASSES.get(this.currentNode); + } + + /** + * Same as currentClasses, but returns it in the form of a className string, where only + * enabled classes are added. + */ + get currentClassesPreview() { + const currentClasses = this.currentClasses + .filter(({ isApplied }) => isApplied) + .map(({ name }) => name); + const previewClasses = this.previewClasses + .filter(previewClass => !currentClasses.includes(previewClass.className)) + .filter(item => item !== "") + .map(({ className }) => className); + + return currentClasses.concat(previewClasses).join(" ").trim(); + } + + /** + * Set the state for a given class on the current node. + * + * @param {String} name + * The class which state should be changed. + * @param {Boolean} isApplied + * True if the class should be enabled, false otherwise. + * @return {Promise} Resolves when the change has been made in the DOM. + */ + setClassState(name, isApplied) { + // Do the change in our local model. + const nodeClasses = this.currentClasses; + nodeClasses.find(({ name: cName }) => cName === name).isApplied = isApplied; + + return this.applyClassState(); + } + + /** + * Add several classes to the current node at once. + * + * @param {String} classNameString + * The string that contains all classes. + * @return {Promise} Resolves when the change has been made in the DOM. + */ + addClassName(classNameString) { + this.classListProxyNode.className = classNameString; + this.eraseClassPreview(); + return Promise.all( + [...new Set([...this.classListProxyNode.classList])].map(name => { + return this.addClass(name); + }) + ); + } + + /** + * Add a class to the current node at once. + * + * @param {String} name + * The class to be added. + * @return {Promise} Resolves when the change has been made in the DOM. + */ + addClass(name) { + // Avoid adding the same class again. + if (this.currentClasses.some(({ name: cName }) => cName === name)) { + return Promise.resolve(); + } + + // Change the local model, so we retain the state of the existing classes. + this.currentClasses.push({ name, isApplied: true }); + + return this.applyClassState(); + } + + /** + * Used internally by other functions like addClass or setClassState. Actually applies + * the class change to the DOM. + * + * @return {Promise} Resolves when the change has been made in the DOM. + */ + applyClassState() { + // If there is no valid inspector selection, bail out silently. No need to report an + // error here. + if (!this.currentNode) { + return Promise.resolve(); + } + + // Remember which node & className we applied until their mutation event is received, so we + // can filter out dom mutations that are caused by us in onMutations, even in situations when + // a new change is applied before that the event of the previous one has been received yet + this.unresolvedStateChanges.push({ + node: this.currentNode, + className: this.currentClassesPreview, + }); + + // Apply the change to the node. + const mod = this.currentNode.startModifyingAttributes(); + mod.setAttribute("class", this.currentClassesPreview); + return mod.apply(); + } + + onMutations(mutations) { + for (const { type, target, attributeName } of mutations) { + // Only care if this mutation is for the class attribute. + if (type !== "attributes" || attributeName !== "class") { + continue; + } + + const isMutationForOurChange = this.unresolvedStateChanges.some( + previousStateChange => + previousStateChange.node === target && + previousStateChange.className === target.className + ); + + if (!isMutationForOurChange) { + CLASSES.delete(target); + if (target === this.currentNode) { + this.emit("current-node-class-changed"); + } + } else { + this.removeResolvedStateChanged(target, target.className); + } + } + } + + /** + * Get the available classNames in the document where the current selected node lives: + * - the one already used on elements of the document + * - the one defined in Stylesheets of the document + * + * @param {String} filter: A string the classNames should start with (an insensitive + * case matching will be done). + * @returns {Promise<Array<String>>} A promise that resolves with an array of strings + * matching the passed filter. + */ + getClassNames(filter) { + return this.currentNode.inspectorFront.pageStyle.getAttributesInOwnerDocument( + filter, + "class", + this.currentNode + ); + } + + previewClass(inputClasses) { + if ( + this.previewClasses + .map(previewClass => previewClass.className) + .join(" ") !== inputClasses + ) { + this.previewClasses = []; + inputClasses.split(" ").forEach(className => { + this.previewClasses.push({ + className, + wasAppliedOnNode: this.isClassAlreadyApplied(className), + }); + }); + this.applyClassState(); + } + } + + eraseClassPreview() { + this.previewClass(""); + } + + removeResolvedStateChanged(currentNode, currentClassesPreview) { + this.unresolvedStateChanges.splice( + 0, + this.unresolvedStateChanges.findIndex( + previousState => + previousState.node === currentNode && + previousState.className === currentClassesPreview + ) + 1 + ); + } + + isClassAlreadyApplied(className) { + return this.currentClasses.some(({ name }) => name === className); + } +} + +module.exports = ClassList; diff --git a/devtools/client/inspector/rules/models/element-style.js b/devtools/client/inspector/rules/models/element-style.js new file mode 100644 index 0000000000..0de235fe96 --- /dev/null +++ b/devtools/client/inspector/rules/models/element-style.js @@ -0,0 +1,818 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Rule = require("resource://devtools/client/inspector/rules/models/rule.js"); +const UserProperties = require("resource://devtools/client/inspector/rules/models/user-properties.js"); +const { + style: { ELEMENT_STYLE }, +} = require("resource://devtools/shared/constants.js"); + +loader.lazyRequireGetter( + this, + "promiseWarn", + "resource://devtools/client/inspector/shared/utils.js", + true +); +loader.lazyRequireGetter( + this, + ["parseDeclarations", "parseNamedDeclarations", "parseSingleValue"], + "resource://devtools/shared/css/parsing-utils.js", + true +); +loader.lazyRequireGetter( + this, + "isCssVariable", + "resource://devtools/client/fronts/css-properties.js", + true +); + +const PREF_INACTIVE_CSS_ENABLED = "devtools.inspector.inactive.css.enabled"; + +/** + * ElementStyle is responsible for the following: + * Keeps track of which properties are overridden. + * Maintains a list of Rule objects for a given element. + */ +class ElementStyle { + /** + * @param {Element} element + * The element whose style we are viewing. + * @param {CssRuleView} ruleView + * The instance of the rule-view panel. + * @param {Object} store + * The ElementStyle can use this object to store metadata + * that might outlast the rule view, particularly the current + * set of disabled properties. + * @param {PageStyleFront} pageStyle + * Front for the page style actor that will be providing + * the style information. + * @param {Boolean} showUserAgentStyles + * Should user agent styles be inspected? + */ + constructor(element, ruleView, store, pageStyle, showUserAgentStyles) { + this.element = element; + this.ruleView = ruleView; + this.store = store || {}; + this.pageStyle = pageStyle; + this.pseudoElements = []; + this.showUserAgentStyles = showUserAgentStyles; + this.rules = []; + this.cssProperties = this.ruleView.cssProperties; + this.variablesMap = new Map(); + + // We don't want to overwrite this.store.userProperties so we only create it + // if it doesn't already exist. + if (!("userProperties" in this.store)) { + this.store.userProperties = new UserProperties(); + } + + if (!("disabled" in this.store)) { + this.store.disabled = new WeakMap(); + } + } + + get unusedCssEnabled() { + if (!this._unusedCssEnabled) { + this._unusedCssEnabled = Services.prefs.getBoolPref( + PREF_INACTIVE_CSS_ENABLED, + false + ); + } + return this._unusedCssEnabled; + } + + destroy() { + if (this.destroyed) { + return; + } + + this.destroyed = true; + this.pseudoElements = []; + + for (const rule of this.rules) { + if (rule.editor) { + rule.editor.destroy(); + } + + rule.destroy(); + } + } + + /** + * Called by the Rule object when it has been changed through the + * setProperty* methods. + */ + _changed() { + if (this.onChanged) { + this.onChanged(); + } + } + + /** + * Refresh the list of rules to be displayed for the active element. + * Upon completion, this.rules[] will hold a list of Rule objects. + * + * Returns a promise that will be resolved when the elementStyle is + * ready. + */ + populate() { + const populated = this.pageStyle + .getApplied(this.element, { + inherited: true, + matchedSelectors: true, + filter: this.showUserAgentStyles ? "ua" : undefined, + }) + .then(entries => { + if (this.destroyed || this.populated !== populated) { + return Promise.resolve(undefined); + } + + // Store the current list of rules (if any) during the population + // process. They will be reused if possible. + const existingRules = this.rules; + + this.rules = []; + + for (const entry of entries) { + this._maybeAddRule(entry, existingRules); + } + + // Store a list of all pseudo-element types found in the matching rules. + this.pseudoElements = this.rules + .filter(r => r.pseudoElement) + .map(r => r.pseudoElement); + + // Mark overridden computed styles. + this.onRuleUpdated(); + + this._sortRulesForPseudoElement(); + + // We're done with the previous list of rules. + for (const r of existingRules) { + if (r?.editor) { + r.editor.destroy(); + } + + r.destroy(); + } + + return undefined; + }) + .catch(e => { + // populate is often called after a setTimeout, + // the connection may already be closed. + if (this.destroyed) { + return Promise.resolve(undefined); + } + return promiseWarn(e); + }); + this.populated = populated; + return this.populated; + } + + /** + * Returns the Rule object of the given rule id. + * + * @param {String|null} id + * The id of the Rule object. + * @return {Rule|undefined} of the given rule id or undefined if it cannot be found. + */ + getRule(id) { + return id + ? this.rules.find(rule => rule.domRule.actorID === id) + : undefined; + } + + /** + * Get the font families in use by the element. + * + * Returns a promise that will be resolved to a list of CSS family + * names. The list might have duplicates. + */ + getUsedFontFamilies() { + return new Promise((resolve, reject) => { + this.ruleView.styleWindow.requestIdleCallback(async () => { + if (this.element.isDestroyed()) { + resolve([]); + return; + } + try { + const fonts = await this.pageStyle.getUsedFontFaces(this.element, { + includePreviews: false, + }); + resolve(fonts.map(font => font.CSSFamilyName)); + } catch (e) { + reject(e); + } + }); + }); + } + + /** + * Put pseudo elements in front of others. + */ + _sortRulesForPseudoElement() { + this.rules = this.rules.sort((a, b) => { + return (a.pseudoElement || "z") > (b.pseudoElement || "z"); + }); + } + + /** + * Add a rule if it's one we care about. Filters out duplicates and + * inherited styles with no inherited properties. + * + * @param {Object} options + * Options for creating the Rule, see the Rule constructor. + * @param {Array} existingRules + * Rules to reuse if possible. If a rule is reused, then it + * it will be deleted from this array. + * @return {Boolean} true if we added the rule. + */ + _maybeAddRule(options, existingRules) { + // If we've already included this domRule (for example, when a + // common selector is inherited), ignore it. + if ( + options.system || + (options.rule && this.rules.some(rule => rule.domRule === options.rule)) + ) { + return false; + } + + let rule = null; + + // If we're refreshing and the rule previously existed, reuse the + // Rule object. + if (existingRules) { + const ruleIndex = existingRules.findIndex(r => r.matches(options)); + if (ruleIndex >= 0) { + rule = existingRules[ruleIndex]; + rule.refresh(options); + existingRules.splice(ruleIndex, 1); + } + } + + // If this is a new rule, create its Rule object. + if (!rule) { + rule = new Rule(this, options); + } + + // Ignore inherited rules with no visible properties. + if (options.inherited && !rule.hasAnyVisibleProperties()) { + return false; + } + + this.rules.push(rule); + return true; + } + + /** + * Calls updateDeclarations with all supported pseudo elements + */ + onRuleUpdated() { + this.updateDeclarations(); + + // Update declarations for matching rules for pseudo-elements. + for (const pseudo of this.pseudoElements) { + this.updateDeclarations(pseudo); + } + } + + /** + * Go over all CSS rules matching the selected element and mark the CSS declarations + * (aka TextProperty instances) with an `overridden` Boolean flag if an earlier or + * higher priority declaration overrides it. Rules are already ordered by specificity. + * + * If a pseudo-element type is passed (ex: ::before, ::first-line, etc), + * restrict the operation only to declarations in rules matching that pseudo-element. + * + * At the end, update the declaration's view (TextPropertyEditor instance) so it relects + * the latest state. Use this opportunity to also trigger checks for the "inactive" + * state of the declaration (whether it has effect or not). + * + * @param {String} pseudo + * Optional pseudo-element for which to restrict marking CSS declarations as + * overridden. + */ + updateDeclarations(pseudo = "") { + // Gather all text properties applicable to the selected element or pseudo-element. + const textProps = this._getDeclarations(pseudo); + // Gather all the computed properties applied by those text properties. + let computedProps = []; + for (const textProp of textProps) { + computedProps = computedProps.concat(textProp.computed); + } + + // CSS Variables inherits from the normal element in case of pseudo element. + const variables = new Map(pseudo ? this.variablesMap.get("") : null); + + // Walk over the computed properties. As we see a property name + // for the first time, mark that property's name as taken by this + // property. + // + // If we come across a property whose name is already taken, check + // its priority against the property that was found first: + // + // If the new property is a higher priority, mark the old + // property overridden and mark the property name as taken by + // the new property. + // + // If the new property is a lower or equal priority, mark it as + // overridden. + // + // _overriddenDirty will be set on each prop, indicating whether its + // dirty status changed during this pass. + const taken = {}; + for (const computedProp of computedProps) { + const earlier = taken[computedProp.name]; + + // Prevent -webkit-gradient from being selected after unchecking + // linear-gradient in this case: + // -moz-linear-gradient: ...; + // -webkit-linear-gradient: ...; + // linear-gradient: ...; + if (!computedProp.textProp.isValid()) { + computedProp.overridden = true; + continue; + } + + let overridden; + if ( + earlier && + computedProp.priority === "important" && + earlier.priority !== "important" && + // For !important only consider rules applying to the same parent node. + computedProp.textProp.rule.inherited == earlier.textProp.rule.inherited + ) { + // New property is higher priority. Mark the earlier property + // overridden (which will reverse its dirty state). + earlier._overriddenDirty = !earlier._overriddenDirty; + earlier.overridden = true; + overridden = false; + } else { + overridden = !!earlier; + } + + computedProp._overriddenDirty = !!computedProp.overridden !== overridden; + computedProp.overridden = overridden; + + if (!computedProp.overridden && computedProp.textProp.enabled) { + taken[computedProp.name] = computedProp; + + if (isCssVariable(computedProp.name)) { + variables.set(computedProp.name, computedProp.value); + } + } + } + + // Find the CSS variables that have been updated. + const previousVariablesMap = new Map(this.variablesMap.get(pseudo)); + const changedVariableNamesSet = new Set( + [...variables.keys(), ...previousVariablesMap.keys()].filter( + k => variables.get(k) !== previousVariablesMap.get(k) + ) + ); + + this.variablesMap.set(pseudo, variables); + + // For each TextProperty, mark it overridden if all of its computed + // properties are marked overridden. Update the text property's associated + // editor, if any. This will clear the _overriddenDirty state on all + // computed properties. For each editor we also show or hide the inactive + // CSS icon as needed. + for (const textProp of textProps) { + // _updatePropertyOverridden will return true if the + // overridden state has changed for the text property. + // _hasUpdatedCSSVariable will return true if the declaration contains any + // of the updated CSS variable names. + if ( + this._updatePropertyOverridden(textProp) || + this._hasUpdatedCSSVariable(textProp, changedVariableNamesSet) + ) { + textProp.updateEditor(); + } + + // For each editor show or hide the inactive CSS icon as needed. + if (textProp.editor && this.unusedCssEnabled) { + textProp.editor.updatePropertyState(); + } + } + } + + /** + * Returns true if the given declaration's property value contains a CSS variable + * matching any of the updated CSS variable names. + * + * @param {TextProperty} declaration + * A TextProperty of a rule. + * @param {Set<>String} variableNamesSet + * A Set of CSS variable names that have been updated. + */ + _hasUpdatedCSSVariable(declaration, variableNamesSet) { + for (const variableName of variableNamesSet) { + if (declaration.hasCSSVariable(variableName)) { + return true; + } + } + + return false; + } + + /** + * Helper for |this.updateDeclarations()| to mark CSS declarations as overridden. + * + * Returns an array of CSS declarations (aka TextProperty instances) from all rules + * applicable to the selected element ordered from more- to less-specific. + * + * If a pseudo-element type is given, restrict the result only to declarations + * applicable to that pseudo-element. + * + * NOTE: this method skips CSS declarations in @keyframes rules because a number of + * criteria such as time and animation delay need to be checked in order to determine + * if the property is overridden at runtime. + * + * @param {String} pseudo + * Optional pseudo-element for which to restrict marking CSS declarations as + * overridden. If omitted, only declarations for regular style rules are + * returned (no pseudo-element style rules). + * + * @return {Array} + * Array of TextProperty instances. + */ + _getDeclarations(pseudo = "") { + const textProps = []; + + for (const rule of this.rules) { + // Skip @keyframes rules + if (rule.keyframes) { + continue; + } + + // Style rules must be considered only when they have selectors that match the node. + // When renaming a selector, the unmatched rule lingers in the Rule view, but it no + // longer matches the node. This strict check avoids accidentally causing + // declarations to be overridden in the remaining matching rules. + const isStyleRule = + rule.pseudoElement === "" && !!rule.matchedSelectors.length; + + // Style rules for pseudo-elements must always be considered, regardless if their + // selector matches the node. As a convenience, declarations in rules for + // pseudo-elements show up in a separate Pseudo-elements accordion when selecting + // the host node (instead of the pseudo-element node directly, which is sometimes + // impossible, for example with ::selection or ::first-line). + // Loosening the strict check on matched selectors ensures these declarations + // participate in the algorithm below to mark them as overridden. + const isPseudoElementRule = + rule.pseudoElement !== "" && rule.pseudoElement === pseudo; + + const isElementStyle = rule.domRule.type === ELEMENT_STYLE; + + const filterCondition = + pseudo === "" ? isStyleRule || isElementStyle : isPseudoElementRule; + + // Collect all relevant CSS declarations (aka TextProperty instances). + if (filterCondition) { + for (const textProp of rule.textProps.slice(0).reverse()) { + if (textProp.enabled) { + textProps.push(textProp); + } + } + } + } + + return textProps; + } + + /** + * Adds a new declaration to the rule. + * + * @param {String} ruleId + * The id of the Rule to be modified. + * @param {String} value + * The new declaration value. + */ + addNewDeclaration(ruleId, value) { + const rule = this.getRule(ruleId); + if (!rule) { + return; + } + + const declarationsToAdd = parseNamedDeclarations( + this.cssProperties.isKnown, + value, + true + ); + if (!declarationsToAdd.length) { + return; + } + + this._addMultipleDeclarations(rule, declarationsToAdd); + } + + /** + * Adds a new rule. The rules view is updated from a "stylesheet-updated" event + * emitted the PageStyleActor as a result of the rule being inserted into the + * the stylesheet. + */ + async addNewRule() { + await this.pageStyle.addNewRule( + this.element, + this.element.pseudoClassLocks + ); + } + + /** + * Given the id of the rule and the new declaration name, modifies the existing + * declaration name to the new given value. + * + * @param {String} ruleId + * The Rule id of the given CSS declaration. + * @param {String} declarationId + * The TextProperty id for the CSS declaration. + * @param {String} name + * The new declaration name. + */ + async modifyDeclarationName(ruleId, declarationId, name) { + const rule = this.getRule(ruleId); + if (!rule) { + return; + } + + const declaration = rule.getDeclaration(declarationId); + if (!declaration || declaration.name === name) { + return; + } + + // Adding multiple rules inside of name field overwrites the current + // property with the first, then adds any more onto the property list. + const declarations = parseDeclarations(this.cssProperties.isKnown, name); + if (!declarations.length) { + return; + } + + await declaration.setName(declarations[0].name); + + if (!declaration.enabled) { + await declaration.setEnabled(true); + } + } + + /** + * Helper function to addNewDeclaration() and modifyDeclarationValue() for + * adding multiple declarations to a rule. + * + * @param {Rule} rule + * The Rule object to write new declarations to. + * @param {Array<Object>} declarationsToAdd + * An array of object containg the parsed declaration data to be added. + * @param {TextProperty|null} siblingDeclaration + * Optional declaration next to which the new declaration will be added. + */ + _addMultipleDeclarations(rule, declarationsToAdd, siblingDeclaration = null) { + for (const { commentOffsets, name, value, priority } of declarationsToAdd) { + const isCommented = Boolean(commentOffsets); + const enabled = !isCommented; + siblingDeclaration = rule.createProperty( + name, + value, + priority, + enabled, + siblingDeclaration + ); + } + } + + /** + * Parse a value string and break it into pieces, starting with the + * first value, and into an array of additional declarations (if any). + * + * Example: Calling with "red; width: 100px" would return + * { firstValue: "red", propertiesToAdd: [{ name: "width", value: "100px" }] } + * + * @param {String} value + * The string to parse. + * @return {Object} An object with the following properties: + * firstValue: A string containing a simple value, like + * "red" or "100px!important" + * declarationsToAdd: An array with additional declarations, following the + * parseDeclarations format of { name, value, priority } + */ + _getValueAndExtraProperties(value) { + // The inplace editor will prevent manual typing of multiple declarations, + // but we need to deal with the case during a paste event. + // Adding multiple declarations inside of value editor sets value with the + // first, then adds any more onto the declaration list (below this declarations). + let firstValue = value; + let declarationsToAdd = []; + + const declarations = parseDeclarations(this.cssProperties.isKnown, value); + + // Check to see if the input string can be parsed as multiple declarations + if (declarations.length) { + // Get the first property value (if any), and any remaining + // declarations (if any) + if (!declarations[0].name && declarations[0].value) { + firstValue = declarations[0].value; + declarationsToAdd = declarations.slice(1); + } else if (declarations[0].name && declarations[0].value) { + // In some cases, the value could be a property:value pair + // itself. Join them as one value string and append + // potentially following declarations + firstValue = declarations[0].name + ": " + declarations[0].value; + declarationsToAdd = declarations.slice(1); + } + } + + return { + declarationsToAdd, + firstValue, + }; + } + + /** + * Given the id of the rule and the new declaration value, modifies the existing + * declaration value to the new given value. + * + * @param {String} ruleId + * The Rule id of the given CSS declaration. + * @param {String} declarationId + * The TextProperty id for the CSS declaration. + * @param {String} value + * The new declaration value. + */ + async modifyDeclarationValue(ruleId, declarationId, value) { + const rule = this.getRule(ruleId); + if (!rule) { + return; + } + + const declaration = rule.getDeclaration(declarationId); + if (!declaration) { + return; + } + + const { declarationsToAdd, firstValue } = + this._getValueAndExtraProperties(value); + const parsedValue = parseSingleValue( + this.cssProperties.isKnown, + firstValue + ); + + if ( + !declarationsToAdd.length && + declaration.value === parsedValue.value && + declaration.priority === parsedValue.priority + ) { + return; + } + + // First, set this declaration value (common case, only modified a property) + await declaration.setValue(parsedValue.value, parsedValue.priority); + + if (!declaration.enabled) { + await declaration.setEnabled(true); + } + + this._addMultipleDeclarations(rule, declarationsToAdd, declaration); + } + + /** + * Modifies the existing rule's selector to the new given value. + * + * @param {String} ruleId + * The id of the Rule to be modified. + * @param {String} selector + * The new selector value. + */ + async modifySelector(ruleId, selector) { + try { + const rule = this.getRule(ruleId); + if (!rule) { + return; + } + + const response = await rule.domRule.modifySelector( + this.element, + selector + ); + const { ruleProps, isMatching } = response; + + if (!ruleProps) { + // Notify for changes, even when nothing changes, just to allow tests + // being able to track end of this request. + this.ruleView.emit("ruleview-invalid-selector"); + return; + } + + const newRule = new Rule(this, { + ...ruleProps, + isUnmatched: !isMatching, + }); + + // Recompute the list of applied styles because editing a + // selector might cause this rule's position to change. + const appliedStyles = await this.pageStyle.getApplied(this.element, { + inherited: true, + matchedSelectors: true, + filter: this.showUserAgentStyles ? "ua" : undefined, + }); + const newIndex = appliedStyles.findIndex(r => r.rule == ruleProps.rule); + const oldIndex = this.rules.indexOf(rule); + + // Remove the old rule and insert the new rule according to where it appears + // in the list of applied styles. + this.rules.splice(oldIndex, 1); + // If the selector no longer matches, then we leave the rule in + // the same relative position. + this.rules.splice(newIndex === -1 ? oldIndex : newIndex, 0, newRule); + + // Recompute, mark and update the UI for any properties that are + // overridden or contain inactive CSS according to the new list of rules. + this.onRuleUpdated(); + + // In order to keep the new rule in place of the old in the rules view, we need + // to remove the rule again if the rule was inserted to its new index according + // to the list of applied styles. + // Note: you might think we would replicate the list-modification logic above, + // but that is complicated due to the way the UI installs pseudo-element rules + // and the like. + if (newIndex !== -1) { + this.rules.splice(newIndex, 1); + this.rules.splice(oldIndex, 0, newRule); + } + this._changed(); + } catch (e) { + console.error(e); + } + } + + /** + * Toggles the enabled state of the given CSS declaration. + * + * @param {String} ruleId + * The Rule id of the given CSS declaration. + * @param {String} declarationId + * The TextProperty id for the CSS declaration. + */ + toggleDeclaration(ruleId, declarationId) { + const rule = this.getRule(ruleId); + if (!rule) { + return; + } + + const declaration = rule.getDeclaration(declarationId); + if (!declaration) { + return; + } + + declaration.setEnabled(!declaration.enabled); + } + + /** + * Mark a given TextProperty as overridden or not depending on the + * state of its computed properties. Clears the _overriddenDirty state + * on all computed properties. + * + * @param {TextProperty} prop + * The text property to update. + * @return {Boolean} true if the TextProperty's overridden state (or any of + * its computed properties overridden state) changed. + */ + _updatePropertyOverridden(prop) { + let overridden = true; + let dirty = false; + + for (const computedProp of prop.computed) { + if (!computedProp.overridden) { + overridden = false; + } + + dirty = computedProp._overriddenDirty || dirty; + delete computedProp._overriddenDirty; + } + + dirty = !!prop.overridden !== overridden || dirty; + prop.overridden = overridden; + return dirty; + } + + /** + * Returns the current value of a CSS variable; or null if the + * variable is not defined. + * + * @param {String} name + * The name of the variable. + * @param {String} pseudo + * The pseudo-element name of the rule. + * @return {String} the variable's value or null if the variable is + * not defined. + */ + getVariable(name, pseudo = "") { + const variables = this.variablesMap.get(pseudo); + return variables ? variables.get(name) : null; + } +} + +module.exports = ElementStyle; diff --git a/devtools/client/inspector/rules/models/moz.build b/devtools/client/inspector/rules/models/moz.build new file mode 100644 index 0000000000..7a5561e213 --- /dev/null +++ b/devtools/client/inspector/rules/models/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "class-list.js", + "element-style.js", + "rule.js", + "text-property.js", + "user-properties.js", +) diff --git a/devtools/client/inspector/rules/models/rule.js b/devtools/client/inspector/rules/models/rule.js new file mode 100644 index 0000000000..626ef883d6 --- /dev/null +++ b/devtools/client/inspector/rules/models/rule.js @@ -0,0 +1,831 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + style: { ELEMENT_STYLE }, +} = require("resource://devtools/shared/constants.js"); +const CssLogic = require("resource://devtools/shared/inspector/css-logic.js"); +const TextProperty = require("resource://devtools/client/inspector/rules/models/text-property.js"); + +loader.lazyRequireGetter( + this, + "promiseWarn", + "resource://devtools/client/inspector/shared/utils.js", + true +); +loader.lazyRequireGetter( + this, + "parseNamedDeclarations", + "resource://devtools/shared/css/parsing-utils.js", + true +); + +const STYLE_INSPECTOR_PROPERTIES = + "devtools/shared/locales/styleinspector.properties"; +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES); + +/** + * Rule is responsible for the following: + * Manages a single style declaration or rule. + * Applies changes to the properties in a rule. + * Maintains a list of TextProperty objects. + */ +class Rule { + /** + * @param {ElementStyle} elementStyle + * The ElementStyle to which this rule belongs. + * @param {Object} options + * The information used to construct this rule. Properties include: + * rule: A StyleRuleActor + * inherited: An element this rule was inherited from. If omitted, + * the rule applies directly to the current element. + * isSystem: Is this a user agent style? + * isUnmatched: True if the rule does not match the current selected + * element, otherwise, false. + */ + constructor(elementStyle, options) { + this.elementStyle = elementStyle; + this.domRule = options.rule; + this.compatibilityIssues = null; + this.matchedSelectors = options.matchedSelectors || []; + this.pseudoElement = options.pseudoElement || ""; + this.isSystem = options.isSystem; + this.isUnmatched = options.isUnmatched || false; + this.inherited = options.inherited || null; + this.keyframes = options.keyframes || null; + + this.cssProperties = this.elementStyle.ruleView.cssProperties; + this.inspector = this.elementStyle.ruleView.inspector; + this.store = this.elementStyle.ruleView.store; + + // Populate the text properties with the style's current authoredText + // value, and add in any disabled properties from the store. + this.textProps = this._getTextProperties(); + this.textProps = this.textProps.concat(this._getDisabledProperties()); + + this.getUniqueSelector = this.getUniqueSelector.bind(this); + this.onStyleRuleFrontUpdated = this.onStyleRuleFrontUpdated.bind(this); + + this.domRule.on("rule-updated", this.onStyleRuleFrontUpdated); + } + + destroy() { + if (this._unsubscribeSourceMap) { + this._unsubscribeSourceMap(); + } + + this.domRule.off("rule-updated", this.onStyleRuleFrontUpdated); + this.compatibilityIssues = null; + this.destroyed = true; + } + + get declarations() { + return this.textProps; + } + + get inheritance() { + if (!this.inherited) { + return null; + } + + return { + inherited: this.inherited, + inheritedSource: this.inheritedSource, + }; + } + + get selector() { + return { + getUniqueSelector: this.getUniqueSelector, + matchedSelectors: this.matchedSelectors, + selectors: this.domRule.selectors, + selectorText: this.keyframes ? this.domRule.keyText : this.selectorText, + }; + } + + get sourceMapURLService() { + return this.inspector.toolbox.sourceMapURLService; + } + + get title() { + let title = CssLogic.shortSource(this.sheet); + if (this.domRule.type !== ELEMENT_STYLE && this.ruleLine > 0) { + title += ":" + this.ruleLine; + } + + return title; + } + + get inheritedSource() { + if (this._inheritedSource) { + return this._inheritedSource; + } + this._inheritedSource = ""; + if (this.inherited) { + let eltText = this.inherited.displayName; + if (this.inherited.id) { + eltText += "#" + this.inherited.id; + } + this._inheritedSource = STYLE_INSPECTOR_L10N.getFormatStr( + "rule.inheritedFrom", + eltText + ); + } + return this._inheritedSource; + } + + get keyframesName() { + if (this._keyframesName) { + return this._keyframesName; + } + this._keyframesName = ""; + if (this.keyframes) { + this._keyframesName = STYLE_INSPECTOR_L10N.getFormatStr( + "rule.keyframe", + this.keyframes.name + ); + } + return this._keyframesName; + } + + get keyframesRule() { + if (!this.keyframes) { + return null; + } + + return { + id: this.keyframes.actorID, + keyframesName: this.keyframesName, + }; + } + + get selectorText() { + return this.domRule.selectors + ? this.domRule.selectors.join(", ") + : CssLogic.l10n("rule.sourceElement"); + } + + /** + * The rule's stylesheet. + */ + get sheet() { + return this.domRule ? this.domRule.parentStyleSheet : null; + } + + /** + * The rule's line within a stylesheet + */ + get ruleLine() { + return this.domRule ? this.domRule.line : -1; + } + + /** + * The rule's column within a stylesheet + */ + get ruleColumn() { + return this.domRule ? this.domRule.column : null; + } + + /** + * Get the declaration block issues from the compatibility actor + * @returns A promise that resolves with an array of objects in following form: + * { + * // Type of compatibility issue + * type: <string>, + * // The CSS declaration that has compatibility issues + * property: <string>, + * // Alias to the given CSS property + * alias: <Array>, + * // Link to MDN documentation for the particular CSS rule + * url: <string>, + * deprecated: <boolean>, + * experimental: <boolean>, + * // An array of all the browsers that don't support the given CSS rule + * unsupportedBrowsers: <Array>, + * } + */ + async getCompatibilityIssues() { + if (!this.compatibilityIssues) { + this.compatibilityIssues = + this.inspector.commands.inspectorCommand.getCSSDeclarationBlockIssues( + this.domRule.declarations + ); + } + + return this.compatibilityIssues; + } + + /** + * Returns the TextProperty with the given id or undefined if it cannot be found. + * + * @param {String|null} id + * A TextProperty id. + * @return {TextProperty|undefined} with the given id in the current Rule or undefined + * if it cannot be found. + */ + getDeclaration(id) { + return id ? this.textProps.find(textProp => textProp.id === id) : undefined; + } + + /** + * Returns an unique selector for the CSS rule. + */ + async getUniqueSelector() { + let selector = ""; + + if (this.domRule.selectors) { + // This is a style rule with a selector. + selector = this.domRule.selectors.join(", "); + } else if (this.inherited) { + // This is an inline style from an inherited rule. Need to resolve the unique + // selector from the node which rule this is inherited from. + selector = await this.inherited.getUniqueSelector(); + } else { + // This is an inline style from the current node. + selector = await this.inspector.selection.nodeFront.getUniqueSelector(); + } + + return selector; + } + + /** + * Returns true if the rule matches the creation options + * specified. + * + * @param {Object} options + * Creation options. See the Rule constructor for documentation. + */ + matches(options) { + return this.domRule === options.rule; + } + + /** + * Create a new TextProperty to include in the rule. + * + * @param {String} name + * The text property name (such as "background" or "border-top"). + * @param {String} value + * The property's value (not including priority). + * @param {String} priority + * The property's priority (either "important" or an empty string). + * @param {Boolean} enabled + * True if the property should be enabled. + * @param {TextProperty} siblingProp + * Optional, property next to which the new property will be added. + */ + createProperty(name, value, priority, enabled, siblingProp) { + const prop = new TextProperty(this, name, value, priority, enabled); + + let ind; + if (siblingProp) { + ind = this.textProps.indexOf(siblingProp) + 1; + this.textProps.splice(ind, 0, prop); + } else { + ind = this.textProps.length; + this.textProps.push(prop); + } + + this.applyProperties(modifications => { + modifications.createProperty(ind, name, value, priority, enabled); + // Now that the rule has been updated, the server might have given us data + // that changes the state of the property. Update it now. + prop.updateEditor(); + }); + + return prop; + } + + /** + * Helper function for applyProperties that is called when the actor + * does not support as-authored styles. Store disabled properties + * in the element style's store. + */ + _applyPropertiesNoAuthored(modifications) { + this.elementStyle.onRuleUpdated(); + + const disabledProps = []; + + for (const prop of this.textProps) { + if (prop.invisible) { + continue; + } + if (!prop.enabled) { + disabledProps.push({ + name: prop.name, + value: prop.value, + priority: prop.priority, + }); + continue; + } + if (prop.value.trim() === "") { + continue; + } + + modifications.setProperty(-1, prop.name, prop.value, prop.priority); + + prop.updateComputed(); + } + + // Store disabled properties in the disabled store. + const disabled = this.elementStyle.store.disabled; + if (disabledProps.length) { + disabled.set(this.domRule, disabledProps); + } else { + disabled.delete(this.domRule); + } + + return modifications.apply().then(() => { + const cssProps = {}; + // Note that even though StyleRuleActors normally provide parsed + // declarations already, _applyPropertiesNoAuthored is only used when + // connected to older backend that do not provide them. So parse here. + for (const cssProp of parseNamedDeclarations( + this.cssProperties.isKnown, + this.domRule.authoredText + )) { + cssProps[cssProp.name] = cssProp; + } + + for (const textProp of this.textProps) { + if (!textProp.enabled) { + continue; + } + let cssProp = cssProps[textProp.name]; + + if (!cssProp) { + cssProp = { + name: textProp.name, + value: "", + priority: "", + }; + } + + textProp.priority = cssProp.priority; + } + }); + } + + /** + * A helper for applyProperties that applies properties in the "as + * authored" case; that is, when the StyleRuleActor supports + * setRuleText. + */ + _applyPropertiesAuthored(modifications) { + return modifications.apply().then(() => { + // The rewriting may have required some other property values to + // change, e.g., to insert some needed terminators. Update the + // relevant properties here. + for (const index in modifications.changedDeclarations) { + const newValue = modifications.changedDeclarations[index]; + this.textProps[index].updateValue(newValue); + } + // Recompute and redisplay the computed properties. + for (const prop of this.textProps) { + if (!prop.invisible && prop.enabled) { + prop.updateComputed(); + prop.updateEditor(); + } + } + }); + } + + /** + * Reapply all the properties in this rule, and update their + * computed styles. Will re-mark overridden properties. Sets the + * |_applyingModifications| property to a promise which will resolve + * when the edit has completed. + * + * @param {Function} modifier a function that takes a RuleModificationList + * (or RuleRewriter) as an argument and that modifies it + * to apply the desired edit + * @return {Promise} a promise which will resolve when the edit + * is complete + */ + applyProperties(modifier) { + // If there is already a pending modification, we have to wait + // until it settles before applying the next modification. + const resultPromise = Promise.resolve(this._applyingModifications) + .then(() => { + const modifications = this.domRule.startModifyingProperties( + this.cssProperties + ); + modifier(modifications); + if (this.domRule.canSetRuleText) { + return this._applyPropertiesAuthored(modifications); + } + return this._applyPropertiesNoAuthored(modifications); + }) + .then(() => { + this.elementStyle.onRuleUpdated(); + + if (resultPromise === this._applyingModifications) { + this._applyingModifications = null; + this.elementStyle._changed(); + } + }) + .catch(promiseWarn); + + this._applyingModifications = resultPromise; + return resultPromise; + } + + /** + * Renames a property. + * + * @param {TextProperty} property + * The property to rename. + * @param {String} name + * The new property name (such as "background" or "border-top"). + * @return {Promise} + */ + setPropertyName(property, name) { + if (name === property.name) { + return Promise.resolve(); + } + + const oldName = property.name; + property.name = name; + const index = this.textProps.indexOf(property); + return this.applyProperties(modifications => { + modifications.renameProperty(index, oldName, name); + }); + } + + /** + * Sets the value and priority of a property, then reapply all properties. + * + * @param {TextProperty} property + * The property to manipulate. + * @param {String} value + * The property's value (not including priority). + * @param {String} priority + * The property's priority (either "important" or an empty string). + * @return {Promise} + */ + setPropertyValue(property, value, priority) { + if (value === property.value && priority === property.priority) { + return Promise.resolve(); + } + + property.value = value; + property.priority = priority; + + const index = this.textProps.indexOf(property); + return this.applyProperties(modifications => { + modifications.setProperty(index, property.name, value, priority); + }); + } + + /** + * Just sets the value and priority of a property, in order to preview its + * effect on the content document. + * + * @param {TextProperty} property + * The property which value will be previewed + * @param {String} value + * The value to be used for the preview + * @param {String} priority + * The property's priority (either "important" or an empty string). + **@return {Promise} + */ + previewPropertyValue(property, value, priority) { + this.elementStyle.ruleView.emitForTests("start-preview-property-value"); + const modifications = this.domRule.startModifyingProperties( + this.cssProperties + ); + modifications.setProperty( + this.textProps.indexOf(property), + property.name, + value, + priority + ); + return modifications.apply().then(() => { + // Ensure dispatching a ruleview-changed event + // also for previews + this.elementStyle._changed(); + }); + } + + /** + * Disables or enables given TextProperty. + * + * @param {TextProperty} property + * The property to enable/disable + * @param {Boolean} value + */ + setPropertyEnabled(property, value) { + if (property.enabled === !!value) { + return; + } + property.enabled = !!value; + const index = this.textProps.indexOf(property); + this.applyProperties(modifications => { + modifications.setPropertyEnabled(index, property.name, property.enabled); + }); + } + + /** + * Remove a given TextProperty from the rule and update the rule + * accordingly. + * + * @param {TextProperty} property + * The property to be removed + */ + removeProperty(property) { + const index = this.textProps.indexOf(property); + this.textProps.splice(index, 1); + // Need to re-apply properties in case removing this TextProperty + // exposes another one. + this.applyProperties(modifications => { + modifications.removeProperty(index, property.name); + }); + } + + /** + * Event handler for "rule-updated" event fired by StyleRuleActor. + * + * @param {StyleRuleFront} front + */ + onStyleRuleFrontUpdated(front) { + // Overwritting this reference is not required, but it's here to avoid confusion. + // Whenever an actor is passed over the protocol, either as a return value or as + // payload on an event, the `form` of its corresponding front will be automatically + // updated. No action required. + // Even if this `domRule` reference here is not explicitly updated, lookups of + // `this.domRule.declarations` will point to the latest state of declarations set + // on the actor. Everything on `StyleRuleForm.form` will point to the latest state. + this.domRule = front; + } + + /** + * Get the list of TextProperties from the style. Needs + * to parse the style's authoredText. + */ + _getTextProperties() { + const textProps = []; + const store = this.elementStyle.store; + + for (const prop of this.domRule.declarations) { + const name = prop.name; + // In an inherited rule, we only show inherited properties. + // However, we must keep all properties in order for rule + // rewriting to work properly. So, compute the "invisible" + // property here. + const invisible = this.inherited && !this.cssProperties.isInherited(name); + const value = store.userProperties.getProperty( + this.domRule, + name, + prop.value + ); + const textProp = new TextProperty( + this, + name, + value, + prop.priority, + !("commentOffsets" in prop), + invisible + ); + textProps.push(textProp); + } + + return textProps; + } + + /** + * Return the list of disabled properties from the store for this rule. + */ + _getDisabledProperties() { + const store = this.elementStyle.store; + + // Include properties from the disabled property store, if any. + const disabledProps = store.disabled.get(this.domRule); + if (!disabledProps) { + return []; + } + + const textProps = []; + + for (const prop of disabledProps) { + const value = store.userProperties.getProperty( + this.domRule, + prop.name, + prop.value + ); + const textProp = new TextProperty(this, prop.name, value, prop.priority); + textProp.enabled = false; + textProps.push(textProp); + } + + return textProps; + } + + /** + * Reread the current state of the rules and rebuild text + * properties as needed. + */ + refresh(options) { + this.matchedSelectors = options.matchedSelectors || []; + const newTextProps = this._getTextProperties(); + + // The element style rule behaves differently on refresh. We basically need to update + // it to reflect the new text properties exactly. The order might have changed, some + // properties might have been removed, etc. And we don't need to mark anything as + // disabled here. The element style rule should always reflect the content of the + // style attribute. + if (this.domRule.type === ELEMENT_STYLE) { + this.textProps = newTextProps; + + if (this.editor) { + this.editor.populate(true); + } + + return; + } + + // Update current properties for each property present on the style. + // This will mark any touched properties with _visited so we + // can detect properties that weren't touched (because they were + // removed from the style). + // Also keep track of properties that didn't exist in the current set + // of properties. + const brandNewProps = []; + for (const newProp of newTextProps) { + if (!this._updateTextProperty(newProp)) { + brandNewProps.push(newProp); + } + } + + // Refresh editors and disabled state for all the properties that + // were updated. + for (const prop of this.textProps) { + // Properties that weren't touched during the update + // process must no longer exist on the node. Mark them disabled. + if (!prop._visited) { + prop.enabled = false; + prop.updateEditor(); + } else { + delete prop._visited; + } + } + + // Add brand new properties. + this.textProps = this.textProps.concat(brandNewProps); + + // Refresh the editor if one already exists. + if (this.editor) { + this.editor.populate(); + } + } + + /** + * Update the current TextProperties that match a given property + * from the authoredText. Will choose one existing TextProperty to update + * with the new property's value, and will disable all others. + * + * When choosing the best match to reuse, properties will be chosen + * by assigning a rank and choosing the highest-ranked property: + * Name, value, and priority match, enabled. (6) + * Name, value, and priority match, disabled. (5) + * Name and value match, enabled. (4) + * Name and value match, disabled. (3) + * Name matches, enabled. (2) + * Name matches, disabled. (1) + * + * If no existing properties match the property, nothing happens. + * + * @param {TextProperty} newProp + * The current version of the property, as parsed from the + * authoredText in Rule._getTextProperties(). + * @return {Boolean} true if a property was updated, false if no properties + * were updated. + */ + _updateTextProperty(newProp) { + const match = { rank: 0, prop: null }; + + for (const prop of this.textProps) { + if (prop.name !== newProp.name) { + continue; + } + + // Mark this property visited. + prop._visited = true; + + // Start at rank 1 for matching name. + let rank = 1; + + // Value and Priority matches add 2 to the rank. + // Being enabled adds 1. This ranks better matches higher, + // with priority breaking ties. + if (prop.value === newProp.value) { + rank += 2; + if (prop.priority === newProp.priority) { + rank += 2; + } + } + + if (prop.enabled) { + rank += 1; + } + + if (rank > match.rank) { + if (match.prop) { + // We outrank a previous match, disable it. + match.prop.enabled = false; + match.prop.updateEditor(); + } + match.rank = rank; + match.prop = prop; + } else if (rank) { + // A previous match outranks us, disable ourself. + prop.enabled = false; + prop.updateEditor(); + } + } + + // If we found a match, update its value with the new text property + // value. + if (match.prop) { + match.prop.set(newProp); + return true; + } + + return false; + } + + /** + * Jump between editable properties in the UI. If the focus direction is + * forward, begin editing the next property name if available or focus the + * new property editor otherwise. If the focus direction is backward, + * begin editing the previous property value or focus the selector editor if + * this is the first element in the property list. + * + * @param {TextProperty} textProperty + * The text property that will be left to focus on a sibling. + * @param {Number} direction + * The move focus direction number. + */ + editClosestTextProperty(textProperty, direction) { + let index = this.textProps.indexOf(textProperty); + + if (direction === Services.focus.MOVEFOCUS_FORWARD) { + for (++index; index < this.textProps.length; ++index) { + if (!this.textProps[index].invisible) { + break; + } + } + if (index === this.textProps.length) { + textProperty.rule.editor.closeBrace.click(); + } else { + this.textProps[index].editor.nameSpan.click(); + } + } else if (direction === Services.focus.MOVEFOCUS_BACKWARD) { + for (--index; index >= 0; --index) { + if (!this.textProps[index].invisible) { + break; + } + } + if (index < 0) { + textProperty.editor.ruleEditor.selectorText.click(); + } else { + this.textProps[index].editor.valueSpan.click(); + } + } + } + + /** + * Return a string representation of the rule. + */ + stringifyRule() { + const selectorText = this.selectorText; + let cssText = ""; + const terminator = Services.appinfo.OS === "WINNT" ? "\r\n" : "\n"; + + for (const textProp of this.textProps) { + if (!textProp.invisible) { + cssText += "\t" + textProp.stringifyProperty() + terminator; + } + } + + return selectorText + " {" + terminator + cssText + "}"; + } + + /** + * See whether this rule has any non-invisible properties. + * @return {Boolean} true if there is any visible property, or false + * if all properties are invisible + */ + hasAnyVisibleProperties() { + for (const prop of this.textProps) { + if (!prop.invisible) { + return true; + } + } + return false; + } +} + +module.exports = Rule; diff --git a/devtools/client/inspector/rules/models/text-property.js b/devtools/client/inspector/rules/models/text-property.js new file mode 100644 index 0000000000..d7568f74f3 --- /dev/null +++ b/devtools/client/inspector/rules/models/text-property.js @@ -0,0 +1,400 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { generateUUID } = require("resource://devtools/shared/generate-uuid.js"); +const { + COMPATIBILITY_TOOLTIP_MESSAGE, +} = require("resource://devtools/client/inspector/rules/constants.js"); + +loader.lazyRequireGetter( + this, + "escapeCSSComment", + "resource://devtools/shared/css/parsing-utils.js", + true +); + +loader.lazyRequireGetter( + this, + "getCSSVariables", + "resource://devtools/client/inspector/rules/utils/utils.js", + true +); + +/** + * TextProperty is responsible for the following: + * Manages a single property from the authoredText attribute of the + * relevant declaration. + * Maintains a list of computed properties that come from this + * property declaration. + * Changes to the TextProperty are sent to its related Rule for + * application. + */ +class TextProperty { + /** + * @param {Rule} rule + * The rule this TextProperty came from. + * @param {String} name + * The text property name (such as "background" or "border-top"). + * @param {String} value + * The property's value (not including priority). + * @param {String} priority + * The property's priority (either "important" or an empty string). + * @param {Boolean} enabled + * Whether the property is enabled. + * @param {Boolean} invisible + * Whether the property is invisible. In an inherited rule, only show + * the inherited declarations. The other declarations are considered + * invisible and does not show up in the UI. These are needed so that + * the index of a property in Rule.textProps is the same as the index + * coming from parseDeclarations. + */ + constructor(rule, name, value, priority, enabled = true, invisible = false) { + this.id = name + "_" + generateUUID().toString(); + this.rule = rule; + this.name = name; + this.value = value; + this.priority = priority; + this.enabled = !!enabled; + this.invisible = invisible; + this.elementStyle = this.rule.elementStyle; + this.cssProperties = this.elementStyle.ruleView.cssProperties; + this.panelDoc = this.elementStyle.ruleView.inspector.panelDoc; + this.userProperties = this.elementStyle.store.userProperties; + // Names of CSS variables used in the value of this declaration. + this.usedVariables = new Set(); + + this.updateComputed(); + this.updateUsedVariables(); + } + + get computedProperties() { + return this.computed + .filter(computed => computed.name !== this.name) + .map(computed => { + return { + isOverridden: computed.overridden, + name: computed.name, + priority: computed.priority, + value: computed.value, + }; + }); + } + + /** + * Returns whether or not the declaration's name is known. + * + * @return {Boolean} true if the declaration name is known, false otherwise. + */ + get isKnownProperty() { + return this.cssProperties.isKnown(this.name); + } + + /** + * Returns whether or not the declaration is changed by the user. + * + * @return {Boolean} true if the declaration is changed by the user, false + * otherwise. + */ + get isPropertyChanged() { + return this.userProperties.contains(this.rule.domRule, this.name); + } + + /** + * Update the editor associated with this text property, + * if any. + */ + updateEditor() { + // When the editor updates, reset the saved + // compatibility issues list as any updates + // may alter the compatibility status of declarations + this.rule.compatibilityIssues = null; + if (this.editor) { + this.editor.update(); + } + } + + /** + * Update the list of computed properties for this text property. + */ + updateComputed() { + if (!this.name) { + return; + } + + // This is a bit funky. To get the list of computed properties + // for this text property, we'll set the property on a dummy element + // and see what the computed style looks like. + const dummyElement = this.elementStyle.ruleView.dummyElement; + const dummyStyle = dummyElement.style; + dummyStyle.cssText = ""; + dummyStyle.setProperty(this.name, this.value, this.priority); + + this.computed = []; + + // Manually get all the properties that are set when setting a value on + // this.name and check the computed style on dummyElement for each one. + // If we just read dummyStyle, it would skip properties when value === "". + const subProps = this.cssProperties.getSubproperties(this.name); + + for (const prop of subProps) { + this.computed.push({ + textProp: this, + name: prop, + value: dummyStyle.getPropertyValue(prop), + priority: dummyStyle.getPropertyPriority(prop), + }); + } + } + + /** + * Extract all CSS variable names used in this declaration's value into a Set for + * easy querying. Call this method any time the declaration's value changes. + */ + updateUsedVariables() { + this.usedVariables.clear(); + + for (const variable of getCSSVariables(this.value)) { + this.usedVariables.add(variable); + } + } + + /** + * Set all the values from another TextProperty instance into + * this TextProperty instance. + * + * @param {TextProperty} prop + * The other TextProperty instance. + */ + set(prop) { + let changed = false; + for (const item of ["name", "value", "priority", "enabled"]) { + if (this[item] !== prop[item]) { + this[item] = prop[item]; + changed = true; + } + } + + if (changed) { + this.updateUsedVariables(); + this.updateEditor(); + } + } + + setValue(value, priority, force = false) { + if (value !== this.value || force) { + this.userProperties.setProperty(this.rule.domRule, this.name, value); + } + return this.rule.setPropertyValue(this, value, priority).then(() => { + this.updateUsedVariables(); + this.updateEditor(); + }); + } + + /** + * Called when the property's value has been updated externally, and + * the property and editor should update to reflect that value. + * + * @param {String} value + * Property value + */ + updateValue(value) { + if (value !== this.value) { + this.value = value; + this.updateUsedVariables(); + this.updateEditor(); + } + } + + async setName(name) { + if (name !== this.name) { + this.userProperties.setProperty(this.rule.domRule, name, this.value); + } + + await this.rule.setPropertyName(this, name); + this.updateEditor(); + } + + setEnabled(value) { + this.rule.setPropertyEnabled(this, value); + this.updateEditor(); + } + + remove() { + this.rule.removeProperty(this); + } + + /** + * Return a string representation of the rule property. + */ + stringifyProperty() { + // Get the displayed property value + let declaration = this.name + ": " + this.value; + + if (this.priority) { + declaration += " !" + this.priority; + } + + declaration += ";"; + + // Comment out property declarations that are not enabled + if (!this.enabled) { + declaration = "/* " + escapeCSSComment(declaration) + " */"; + } + + return declaration; + } + + /** + * Validate this property. Does it make sense for this value to be assigned + * to this property name? + * + * @return {Boolean} true if the whole CSS declaration is valid, false otherwise. + */ + isValid() { + const selfIndex = this.rule.textProps.indexOf(this); + + // When adding a new property in the rule-view, the TextProperty object is + // created right away before the rule gets updated on the server, so we're + // not going to find the corresponding declaration object yet. Default to + // true. + if (!this.rule.domRule.declarations[selfIndex]) { + return true; + } + + return this.rule.domRule.declarations[selfIndex].isValid; + } + + isUsed() { + const selfIndex = this.rule.textProps.indexOf(this); + const declarations = this.rule.domRule.declarations; + + // StyleRuleActor's declarations may have a isUsed flag (if the server is the right + // version). Just return true if the information is missing. + if ( + !declarations || + !declarations[selfIndex] || + !declarations[selfIndex].isUsed + ) { + return { used: true }; + } + + return declarations[selfIndex].isUsed; + } + + /** + * Get compatibility issue linked with the textProp. + * + * @returns A JSON objects with compatibility information in following form: + * { + * // A boolean to denote the compatibility status + * isCompatible: <boolean>, + * // The CSS declaration that has compatibility issues + * property: <string>, + * // The un-aliased root CSS declaration for the given property + * rootProperty: <string>, + * // The l10n message id for the tooltip message + * msgId: <string>, + * // Link to MDN documentation for the rootProperty + * url: <string>, + * // An array of all the browsers that don't support the given CSS rule + * unsupportedBrowsers: <Array>, + * } + */ + async isCompatible() { + // This is a workaround for Bug 1648339 + // https://bugzilla.mozilla.org/show_bug.cgi?id=1648339 + // that makes the tooltip icon inconsistent with the + // position of the rule it is associated with. Once solved, + // the compatibility data can be directly accessed from the + // declaration and this logic can be used to set isCompatible + // property directly to domRule in StyleRuleActor's form() method. + if (!this.enabled) { + return { isCompatible: true }; + } + + const compatibilityIssues = await this.rule.getCompatibilityIssues(); + if (!compatibilityIssues.length) { + return { isCompatible: true }; + } + + const property = this.name; + const indexOfProperty = compatibilityIssues.findIndex( + issue => issue.property === property || issue.aliases?.includes(property) + ); + + if (indexOfProperty < 0) { + return { isCompatible: true }; + } + + const { + property: rootProperty, + deprecated, + experimental, + specUrl, + url, + unsupportedBrowsers, + } = compatibilityIssues[indexOfProperty]; + + let msgId = COMPATIBILITY_TOOLTIP_MESSAGE.default; + if (deprecated && experimental && !unsupportedBrowsers.length) { + msgId = + COMPATIBILITY_TOOLTIP_MESSAGE["deprecated-experimental-supported"]; + } else if (deprecated && experimental) { + msgId = COMPATIBILITY_TOOLTIP_MESSAGE["deprecated-experimental"]; + } else if (deprecated && !unsupportedBrowsers.length) { + msgId = COMPATIBILITY_TOOLTIP_MESSAGE["deprecated-supported"]; + } else if (deprecated) { + msgId = COMPATIBILITY_TOOLTIP_MESSAGE.deprecated; + } else if (experimental && !unsupportedBrowsers.length) { + msgId = COMPATIBILITY_TOOLTIP_MESSAGE["experimental-supported"]; + } else if (experimental) { + msgId = COMPATIBILITY_TOOLTIP_MESSAGE.experimental; + } + + return { + isCompatible: false, + property, + rootProperty, + msgId, + specUrl, + url, + unsupportedBrowsers, + }; + } + + /** + * Validate the name of this property. + * + * @return {Boolean} true if the property name is valid, false otherwise. + */ + isNameValid() { + const selfIndex = this.rule.textProps.indexOf(this); + + // When adding a new property in the rule-view, the TextProperty object is + // created right away before the rule gets updated on the server, so we're + // not going to find the corresponding declaration object yet. Default to + // true. + if (!this.rule.domRule.declarations[selfIndex]) { + return true; + } + + return this.rule.domRule.declarations[selfIndex].isNameValid; + } + + /** + * Returns true if the property value is a CSS variables and contains the given variable + * name, and false otherwise. + * + * @param {String} + * CSS variable name (e.g. "--color") + * @return {Boolean} + */ + hasCSSVariable(name) { + return this.usedVariables.has(name); + } +} + +module.exports = TextProperty; diff --git a/devtools/client/inspector/rules/models/user-properties.js b/devtools/client/inspector/rules/models/user-properties.js new file mode 100644 index 0000000000..381b800e59 --- /dev/null +++ b/devtools/client/inspector/rules/models/user-properties.js @@ -0,0 +1,85 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Store of CSSStyleDeclarations mapped to properties that have been changed by + * the user. + */ +class UserProperties { + constructor() { + this.map = new Map(); + } + + /** + * Get a named property for a given CSSStyleDeclaration. + * + * @param {CSSStyleDeclaration} style + * The CSSStyleDeclaration against which the property is mapped. + * @param {String} name + * The name of the property to get. + * @param {String} value + * Default value. + * @return {String} + * The property value if it has previously been set by the user, null + * otherwise. + */ + getProperty(style, name, value) { + const key = this.getKey(style); + const entry = this.map.get(key, null); + + if (entry && name in entry) { + return entry[name]; + } + return value; + } + + /** + * Set a named property for a given CSSStyleDeclaration. + * + * @param {CSSStyleDeclaration} style + * The CSSStyleDeclaration against which the property is to be mapped. + * @param {String} name + * The name of the property to set. + * @param {String} userValue + * The value of the property to set. + */ + setProperty(style, name, userValue) { + const key = this.getKey(style, name); + const entry = this.map.get(key, null); + + if (entry) { + entry[name] = userValue; + } else { + const props = {}; + props[name] = userValue; + this.map.set(key, props); + } + } + + /** + * Check whether a named property for a given CSSStyleDeclaration is stored. + * + * @param {CSSStyleDeclaration} style + * The CSSStyleDeclaration against which the property would be mapped. + * @param {String} name + * The name of the property to check. + */ + contains(style, name) { + const key = this.getKey(style, name); + const entry = this.map.get(key, null); + return !!entry && name in entry; + } + + getKey(style, name) { + return style.actorID + ":" + name; + } + + clear() { + this.map.clear(); + } +} + +module.exports = UserProperties; diff --git a/devtools/client/inspector/rules/moz.build b/devtools/client/inspector/rules/moz.build new file mode 100644 index 0000000000..0cc40dab81 --- /dev/null +++ b/devtools/client/inspector/rules/moz.build @@ -0,0 +1,25 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "models", + "utils", + "views", +] + +DevToolsModules( + "constants.js", + "rules.js", + "types.js", +) + +BROWSER_CHROME_MANIFESTS += [ + "test/browser_part1.ini", + "test/browser_part2.ini", +] + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Inspector: Rules") diff --git a/devtools/client/inspector/rules/rules.js b/devtools/client/inspector/rules/rules.js new file mode 100644 index 0000000000..5ddef719d5 --- /dev/null +++ b/devtools/client/inspector/rules/rules.js @@ -0,0 +1,2199 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const flags = require("resource://devtools/shared/flags.js"); +const { l10n } = require("resource://devtools/shared/inspector/css-logic.js"); +const { + style: { ELEMENT_STYLE }, +} = require("resource://devtools/shared/constants.js"); +const { + PSEUDO_CLASSES, +} = require("resource://devtools/shared/css/constants.js"); +const OutputParser = require("resource://devtools/client/shared/output-parser.js"); +const { PrefObserver } = require("resource://devtools/client/shared/prefs.js"); +const ElementStyle = require("resource://devtools/client/inspector/rules/models/element-style.js"); +const RuleEditor = require("resource://devtools/client/inspector/rules/views/rule-editor.js"); +const TooltipsOverlay = require("resource://devtools/client/inspector/shared/tooltips-overlay.js"); +const { + createChild, + promiseWarn, +} = require("resource://devtools/client/inspector/shared/utils.js"); +const { debounce } = require("resource://devtools/shared/debounce.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const DOUBLESPACE = " "; + +loader.lazyRequireGetter( + this, + ["flashElementOn", "flashElementOff"], + "resource://devtools/client/inspector/markup/utils.js", + true +); +loader.lazyRequireGetter( + this, + "ClassListPreviewer", + "resource://devtools/client/inspector/rules/views/class-list-previewer.js" +); +loader.lazyRequireGetter( + this, + ["getNodeInfo", "getNodeCompatibilityInfo", "getRuleFromNode"], + "resource://devtools/client/inspector/rules/utils/utils.js", + true +); +loader.lazyRequireGetter( + this, + "StyleInspectorMenu", + "resource://devtools/client/inspector/shared/style-inspector-menu.js" +); +loader.lazyRequireGetter( + this, + "AutocompletePopup", + "resource://devtools/client/shared/autocomplete-popup.js" +); +loader.lazyRequireGetter( + this, + "KeyShortcuts", + "resource://devtools/client/shared/key-shortcuts.js" +); +loader.lazyRequireGetter( + this, + "clipboardHelper", + "resource://devtools/shared/platform/clipboard.js" +); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const PREF_UA_STYLES = "devtools.inspector.showUserAgentStyles"; +const PREF_DEFAULT_COLOR_UNIT = "devtools.defaultColorUnit"; +const PREF_DRAGGABLE = "devtools.inspector.draggable_properties"; +const FILTER_CHANGED_TIMEOUT = 150; +// Removes the flash-out class from an element after 1 second. +const PROPERTY_FLASHING_DURATION = 1000; + +// This is used to parse user input when filtering. +const FILTER_PROP_RE = /\s*([^:\s]*)\s*:\s*(.*?)\s*;?$/; +// This is used to parse the filter search value to see if the filter +// should be strict or not +const FILTER_STRICT_RE = /\s*`(.*?)`\s*$/; + +/** + * Our model looks like this: + * + * ElementStyle: + * Responsible for keeping track of which properties are overridden. + * Maintains a list of Rule objects that apply to the element. + * Rule: + * Manages a single style declaration or rule. + * Responsible for applying changes to the properties in a rule. + * Maintains a list of TextProperty objects. + * TextProperty: + * Manages a single property from the authoredText attribute of the + * relevant declaration. + * Maintains a list of computed properties that come from this + * property declaration. + * Changes to the TextProperty are sent to its related Rule for + * application. + * + * View hierarchy mostly follows the model hierarchy. + * + * CssRuleView: + * Owns an ElementStyle and creates a list of RuleEditors for its + * Rules. + * RuleEditor: + * Owns a Rule object and creates a list of TextPropertyEditors + * for its TextProperties. + * Manages creation of new text properties. + * TextPropertyEditor: + * Owns a TextProperty object. + * Manages changes to the TextProperty. + * Can be expanded to display computed properties. + * Can mark a property disabled or enabled. + */ + +/** + * CssRuleView is a view of the style rules and declarations that + * apply to a given element. After construction, the 'element' + * property will be available with the user interface. + * + * @param {Inspector} inspector + * Inspector toolbox panel + * @param {Document} document + * The document that will contain the rule view. + * @param {Object} store + * The CSS rule view can use this object to store metadata + * that might outlast the rule view, particularly the current + * set of disabled properties. + */ +function CssRuleView(inspector, document, store) { + EventEmitter.decorate(this); + + this.inspector = inspector; + this.cssProperties = inspector.cssProperties; + this.styleDocument = document; + this.styleWindow = this.styleDocument.defaultView; + this.store = store || {}; + + // Allow tests to override debouncing behavior, as this can cause intermittents. + this.debounce = debounce; + + // Variable used to stop the propagation of mouse events to children + // when we are updating a value by dragging the mouse and we then release it + this.childHasDragged = false; + + this._outputParser = new OutputParser(document, this.cssProperties); + + this._onAddRule = this._onAddRule.bind(this); + this._onContextMenu = this._onContextMenu.bind(this); + this._onCopy = this._onCopy.bind(this); + this._onFilterStyles = this._onFilterStyles.bind(this); + this._onClearSearch = this._onClearSearch.bind(this); + this._onTogglePseudoClassPanel = this._onTogglePseudoClassPanel.bind(this); + this._onTogglePseudoClass = this._onTogglePseudoClass.bind(this); + this._onToggleClassPanel = this._onToggleClassPanel.bind(this); + this._onToggleLightColorSchemeSimulation = + this._onToggleLightColorSchemeSimulation.bind(this); + this._onToggleDarkColorSchemeSimulation = + this._onToggleDarkColorSchemeSimulation.bind(this); + this._onTogglePrintSimulation = this._onTogglePrintSimulation.bind(this); + this.highlightElementRule = this.highlightElementRule.bind(this); + this.highlightProperty = this.highlightProperty.bind(this); + this.refreshPanel = this.refreshPanel.bind(this); + + const doc = this.styleDocument; + // Delegate bulk handling of events happening within the DOM tree of the Rules view + // to this.handleEvent(). Listening on the capture phase of the event bubbling to be + // able to stop event propagation on a case-by-case basis and prevent event target + // ancestor nodes from handling them. + this.styleDocument.addEventListener("click", this, { capture: true }); + this.element = doc.getElementById("ruleview-container-focusable"); + this.addRuleButton = doc.getElementById("ruleview-add-rule-button"); + this.searchField = doc.getElementById("ruleview-searchbox"); + this.searchClearButton = doc.getElementById("ruleview-searchinput-clear"); + this.pseudoClassPanel = doc.getElementById("pseudo-class-panel"); + this.pseudoClassToggle = doc.getElementById("pseudo-class-panel-toggle"); + this.classPanel = doc.getElementById("ruleview-class-panel"); + this.classToggle = doc.getElementById("class-panel-toggle"); + this.colorSchemeLightSimulationButton = doc.getElementById( + "color-scheme-simulation-light-toggle" + ); + this.colorSchemeDarkSimulationButton = doc.getElementById( + "color-scheme-simulation-dark-toggle" + ); + this.printSimulationButton = doc.getElementById("print-simulation-toggle"); + + this._initSimulationFeatures(); + + this.searchClearButton.hidden = true; + + this.onHighlighterShown = data => + this.handleHighlighterEvent("highlighter-shown", data); + this.onHighlighterHidden = data => + this.handleHighlighterEvent("highlighter-hidden", data); + this.inspector.highlighters.on("highlighter-shown", this.onHighlighterShown); + this.inspector.highlighters.on( + "highlighter-hidden", + this.onHighlighterHidden + ); + + this.shortcuts = new KeyShortcuts({ window: this.styleWindow }); + this._onShortcut = this._onShortcut.bind(this); + this.shortcuts.on("Escape", event => this._onShortcut("Escape", event)); + this.shortcuts.on("Return", event => this._onShortcut("Return", event)); + this.shortcuts.on("Space", event => this._onShortcut("Space", event)); + this.shortcuts.on("CmdOrCtrl+F", event => + this._onShortcut("CmdOrCtrl+F", event) + ); + this.element.addEventListener("copy", this._onCopy); + this.element.addEventListener("contextmenu", this._onContextMenu); + this.addRuleButton.addEventListener("click", this._onAddRule); + this.searchField.addEventListener("input", this._onFilterStyles); + this.searchClearButton.addEventListener("click", this._onClearSearch); + this.pseudoClassToggle.addEventListener( + "click", + this._onTogglePseudoClassPanel + ); + this.classToggle.addEventListener("click", this._onToggleClassPanel); + // The "change" event bubbles up from checkbox inputs nested within the panel container. + this.pseudoClassPanel.addEventListener("change", this._onTogglePseudoClass); + + if (flags.testing) { + // In tests, we start listening immediately to avoid having to simulate a mousemove. + this.highlighters.addToView(this); + } else { + this.element.addEventListener( + "mousemove", + () => { + this.highlighters.addToView(this); + }, + { once: true } + ); + } + + this._handlePrefChange = this._handlePrefChange.bind(this); + this._handleUAStylePrefChange = this._handleUAStylePrefChange.bind(this); + this._handleDefaultColorUnitPrefChange = + this._handleDefaultColorUnitPrefChange.bind(this); + this._handleDraggablePrefChange = this._handleDraggablePrefChange.bind(this); + + this._prefObserver = new PrefObserver("devtools."); + this._prefObserver.on(PREF_UA_STYLES, this._handleUAStylePrefChange); + this._prefObserver.on( + PREF_DEFAULT_COLOR_UNIT, + this._handleDefaultColorUnitPrefChange + ); + this._prefObserver.on(PREF_DRAGGABLE, this._handleDraggablePrefChange); + // Initialize value of this.draggablePropertiesEnabled + this._handleDraggablePrefChange(); + + this.pseudoClassCheckboxes = this._createPseudoClassCheckboxes(); + this.showUserAgentStyles = Services.prefs.getBoolPref(PREF_UA_STYLES); + + // Add the tooltips and highlighters to the view + this.tooltips = new TooltipsOverlay(this); +} + +CssRuleView.prototype = { + // The element that we're inspecting. + _viewedElement: null, + + // Used for cancelling timeouts in the style filter. + _filterChangedTimeout: null, + + // Empty, unconnected element of the same type as this node, used + // to figure out how shorthand properties will be parsed. + _dummyElement: null, + + get popup() { + if (!this._popup) { + // The popup will be attached to the toolbox document. + this._popup = new AutocompletePopup(this.inspector.toolbox.doc, { + autoSelect: true, + }); + } + + return this._popup; + }, + + get classListPreviewer() { + if (!this._classListPreviewer) { + this._classListPreviewer = new ClassListPreviewer( + this.inspector, + this.classPanel + ); + } + + return this._classListPreviewer; + }, + + get contextMenu() { + if (!this._contextMenu) { + this._contextMenu = new StyleInspectorMenu(this, { isRuleView: true }); + } + + return this._contextMenu; + }, + + // Get the dummy elemenet. + get dummyElement() { + return this._dummyElement; + }, + + // Get the highlighters overlay from the Inspector. + get highlighters() { + if (!this._highlighters) { + // highlighters is a lazy getter in the inspector. + this._highlighters = this.inspector.highlighters; + } + + return this._highlighters; + }, + + // Get the filter search value. + get searchValue() { + return this.searchField.value.toLowerCase(); + }, + + get rules() { + return this._elementStyle ? this._elementStyle.rules : []; + }, + + get currentTarget() { + return this.inspector.toolbox.target; + }, + + /** + * Highlight/unhighlight all the nodes that match a given selector + * inside the document of the current selected node. + * Only one selector can be highlighted at a time, so calling the method a + * second time with a different selector will first unhighlight the previously + * highlighted nodes. + * Calling the method a second time with the same selector will just + * unhighlight the highlighted nodes. + * + * @param {String} selector + * Elements matching this selector will be highlighted on the page. + */ + async toggleSelectorHighlighter(selector) { + if (this.isSelectorHighlighted(selector)) { + await this.inspector.highlighters.hideHighlighterType( + this.inspector.highlighters.TYPES.SELECTOR + ); + } else { + await this.inspector.highlighters.showHighlighterTypeForNode( + this.inspector.highlighters.TYPES.SELECTOR, + this.inspector.selection.nodeFront, + { + hideInfoBar: true, + hideGuides: true, + selector, + } + ); + } + }, + + isPanelVisible() { + if (this.inspector.is3PaneModeEnabled) { + return true; + } + return ( + this.inspector.toolbox && + this.inspector.sidebar && + this.inspector.toolbox.currentToolId === "inspector" && + this.inspector.sidebar.getCurrentTabID() == "ruleview" + ); + }, + + /** + * Check whether a SelectorHighlighter is active for the given selector text. + * + * @param {String} selector + * @return {Boolean} + */ + isSelectorHighlighted(selector) { + const options = this.inspector.highlighters.getOptionsForActiveHighlighter( + this.inspector.highlighters.TYPES.SELECTOR + ); + + return options?.selector === selector; + }, + + /** + * Delegate handler for events happening within the DOM tree of the Rules view. + * Itself delegates to specific handlers by event type. + * + * Use this instead of attaching specific event handlers when: + * - there are many elements with the same event handler (eases memory pressure) + * - you want to avoid having to remove event handlers manually + * - elements are added/removed from the DOM tree arbitrarily over time + * + * @param {MouseEvent|UIEvent} event + */ + handleEvent(event) { + if (this.childHasDragged) { + this.childHasDragged = false; + event.stopPropagation(); + return; + } + switch (event.type) { + case "click": + this.handleClickEvent(event); + break; + default: + } + }, + + /** + * Delegate handler for click events happening within the DOM tree of the Rules view. + * Stop propagation of click event wrapping a CSS rule or CSS declaration to avoid + * triggering the prompt to add a new CSS declaration or to edit the existing one. + * + * @param {MouseEvent} event + */ + async handleClickEvent(event) { + const target = event.target; + + // Handle click on the icon next to a CSS selector. + if (target.classList.contains("js-toggle-selector-highlighter")) { + event.stopPropagation(); + let selector = target.dataset.selector; + // dataset.selector will be empty for inline styles (inherited or not) + // Rules associated with a regular selector should have this data-attirbute + // set in devtools/client/inspector/rules/views/rule-editor.js + if (selector === "") { + try { + const rule = getRuleFromNode(target, this._elementStyle); + if (rule.inherited) { + // This is an inline style from an inherited rule. Need to resolve the + // unique selector from the node which this rule is inherited from. + selector = await rule.inherited.getUniqueSelector(); + } else { + // This is an inline style from the current node. + selector = + await this.inspector.selection.nodeFront.getUniqueSelector(); + } + + // Now that the selector was computed, we can store it in + // dataset.selector for subsequent usage. + target.dataset.selector = selector; + } finally { + // Could not resolve a unique selector for the inline style. + } + } + + this.toggleSelectorHighlighter(selector); + } + + // Handle click on swatches next to flex and inline-flex CSS properties + if (target.classList.contains("js-toggle-flexbox-highlighter")) { + event.stopPropagation(); + this.inspector.highlighters.toggleFlexboxHighlighter( + this.inspector.selection.nodeFront, + "rule" + ); + } + + // Handle click on swatches next to grid CSS properties + if (target.classList.contains("js-toggle-grid-highlighter")) { + event.stopPropagation(); + this.inspector.highlighters.toggleGridHighlighter( + this.inspector.selection.nodeFront, + "rule" + ); + } + }, + + /** + * Delegate handler for highlighter events. + * + * This is the place to observe for highlighter events, check the highlighter type and + * event name, then react to specific events, for example by modifying the DOM. + * + * @param {String} eventName + * Highlighter event name. One of: "highlighter-hidden", "highlighter-shown" + * @param {Object} data + * Object with data associated with the highlighter event. + */ + handleHighlighterEvent(eventName, data) { + switch (data.type) { + // Toggle the "highlighted" class on selector icons in the Rules view when + // the SelectorHighlighter is shown/hidden for a certain CSS selector. + case this.inspector.highlighters.TYPES.SELECTOR: + { + const selector = data?.options?.selector; + if (!selector) { + return; + } + + const query = `.js-toggle-selector-highlighter[data-selector='${selector}']`; + for (const node of this.styleDocument.querySelectorAll(query)) { + node.classList.toggle( + "highlighted", + eventName == "highlighter-shown" + ); + } + } + break; + + // Toggle the "active" class on swatches next to flex and inline-flex CSS properties + // when the FlexboxHighlighter is shown/hidden for the currently selected node. + case this.inspector.highlighters.TYPES.FLEXBOX: + { + const query = ".js-toggle-flexbox-highlighter"; + for (const node of this.styleDocument.querySelectorAll(query)) { + node.classList.toggle("active", eventName == "highlighter-shown"); + } + } + break; + + // Toggle the "active" class on swatches next to grid CSS properties + // when the GridHighlighter is shown/hidden for the currently selected node. + case this.inspector.highlighters.TYPES.GRID: + { + const query = ".js-toggle-grid-highlighter"; + for (const node of this.styleDocument.querySelectorAll(query)) { + // From the Layout panel, we can toggle grid highlighters for nodes which are + // not currently selected. The Rules view shows `display: grid` declarations + // only for the selected node. Avoid mistakenly marking them as "active". + if (data.nodeFront === this.inspector.selection.nodeFront) { + node.classList.toggle("active", eventName == "highlighter-shown"); + } + + // When the max limit of grid highlighters is reached (default 3), + // mark inactive grid swatches as disabled. + node.toggleAttribute( + "disabled", + !this.inspector.highlighters.canGridHighlighterToggle( + this.inspector.selection.nodeFront + ) + ); + } + } + break; + } + }, + + /** + * Enables the print and color scheme simulation only for local and remote tab debugging. + */ + async _initSimulationFeatures() { + if (!this.inspector.commands.descriptorFront.isTabDescriptor) { + return; + } + this.colorSchemeLightSimulationButton.removeAttribute("hidden"); + this.colorSchemeDarkSimulationButton.removeAttribute("hidden"); + this.printSimulationButton.removeAttribute("hidden"); + this.printSimulationButton.addEventListener( + "click", + this._onTogglePrintSimulation + ); + this.colorSchemeLightSimulationButton.addEventListener( + "click", + this._onToggleLightColorSchemeSimulation + ); + this.colorSchemeDarkSimulationButton.addEventListener( + "click", + this._onToggleDarkColorSchemeSimulation + ); + }, + + /** + * Get the type of a given node in the rule-view + * + * @param {DOMNode} node + * The node which we want information about + * @return {Object|null} containing the following props: + * - type {String} One of the VIEW_NODE_XXX_TYPE const in + * client/inspector/shared/node-types. + * - rule {Rule} The Rule object. + * - value {Object} Depends on the type of the node. + * Otherwise, returns null if the node isn't anything we care about. + */ + getNodeInfo(node) { + return getNodeInfo(node, this._elementStyle); + }, + + /** + * Get the node's compatibility issues + * + * @param {DOMNode} node + * The node which we want information about + * @return {Object|null} containing the following props: + * - type {String} Compatibility issue type. + * - property {string} The incompatible rule + * - alias {Array} The browser specific alias of rule + * - url {string} Link to MDN documentation + * - deprecated {bool} True if the rule is deprecated + * - experimental {bool} True if rule is experimental + * - unsupportedBrowsers {Array} Array of unsupported browser + * Otherwise, returns null if the node has cross-browser compatible CSS + */ + async getNodeCompatibilityInfo(node) { + const compatibilityInfo = await getNodeCompatibilityInfo( + node, + this._elementStyle + ); + + return compatibilityInfo; + }, + + /** + * Context menu handler. + */ + _onContextMenu(event) { + if ( + event.originalTarget.closest("input[type=text]") || + event.originalTarget.closest("input:not([type])") || + event.originalTarget.closest("textarea") + ) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + + this.contextMenu.show(event); + }, + + /** + * Callback for copy event. Copy the selected text. + * + * @param {Event} event + * copy event object. + */ + _onCopy(event) { + if (event) { + this.copySelection(event.target); + event.preventDefault(); + event.stopPropagation(); + } + }, + + /** + * Copy the current selection. The current target is necessary + * if the selection is inside an input or a textarea + * + * @param {DOMNode} target + * DOMNode target of the copy action + */ + copySelection(target) { + try { + let text = ""; + + const nodeName = target?.nodeName; + const targetType = target?.type; + + if ( + // The target can be the enable/disable rule checkbox here (See Bug 1680893). + (nodeName === "input" && targetType !== "checkbox") || + nodeName == "textarea" + ) { + const start = Math.min(target.selectionStart, target.selectionEnd); + const end = Math.max(target.selectionStart, target.selectionEnd); + const count = end - start; + text = target.value.substr(start, count); + } else { + text = this.styleWindow.getSelection().toString(); + + // Remove any double newlines. + text = text.replace(/(\r?\n)\r?\n/g, "$1"); + + // Replace 4 space indentation with 2 Spaces. + text = text.replace(/\ {4}/g, DOUBLESPACE); + } + + clipboardHelper.copyString(text); + } catch (e) { + console.error(e); + } + }, + + /** + * Add a new rule to the current element. + */ + _onAddRule() { + const elementStyle = this._elementStyle; + const element = elementStyle.element; + const pseudoClasses = element.pseudoClassLocks; + + // Adding a new rule with authored styles will cause the actor to + // emit an event, which will in turn cause the rule view to be + // updated. So, we wait for this update and for the rule creation + // request to complete, and then focus the new rule's selector. + const eventPromise = this.once("ruleview-refreshed"); + const newRulePromise = this.pageStyle.addNewRule(element, pseudoClasses); + Promise.all([eventPromise, newRulePromise]).then(values => { + const options = values[1]; + // Be sure the reference the correct |rules| here. + for (const rule of this._elementStyle.rules) { + if (options.rule === rule.domRule) { + rule.editor.selectorText.click(); + elementStyle._changed(); + break; + } + } + }); + }, + + /** + * Disables add rule button when needed + */ + refreshAddRuleButtonState() { + const shouldBeDisabled = + !this._viewedElement || + !this.inspector.selection.isElementNode() || + this.inspector.selection.isAnonymousNode(); + this.addRuleButton.disabled = shouldBeDisabled; + }, + + /** + * Return {Boolean} true if the rule view currently has an input + * editor visible. + */ + get isEditing() { + return ( + this.tooltips.isEditing || + !!this.element.querySelectorAll(".styleinspector-propertyeditor").length + ); + }, + + _handleUAStylePrefChange() { + this.showUserAgentStyles = Services.prefs.getBoolPref(PREF_UA_STYLES); + this._handlePrefChange(PREF_UA_STYLES); + }, + + _handleDefaultColorUnitPrefChange() { + this._handlePrefChange(PREF_DEFAULT_COLOR_UNIT); + }, + + _handleDraggablePrefChange() { + this.draggablePropertiesEnabled = Services.prefs.getBoolPref( + PREF_DRAGGABLE, + false + ); + // This event is consumed by text-property-editor instances in order to + // update their draggable behavior. Preferences observer are costly, so + // we are forwarding the preference update via the EventEmitter. + this.emit("draggable-preference-updated"); + }, + + _handlePrefChange(pref) { + // Reselect the currently selected element + const refreshOnPrefs = [PREF_UA_STYLES, PREF_DEFAULT_COLOR_UNIT]; + if (refreshOnPrefs.indexOf(pref) > -1) { + this.selectElement(this._viewedElement, true); + } + }, + + /** + * Set the filter style search value. + * @param {String} value + * The search value. + */ + setFilterStyles(value = "") { + this.searchField.value = value; + this.searchField.focus(); + this._onFilterStyles(); + }, + + /** + * Called when the user enters a search term in the filter style search box. + */ + _onFilterStyles() { + if (this._filterChangedTimeout) { + clearTimeout(this._filterChangedTimeout); + } + + const filterTimeout = this.searchValue.length ? FILTER_CHANGED_TIMEOUT : 0; + this.searchClearButton.hidden = this.searchValue.length === 0; + + this._filterChangedTimeout = setTimeout(() => { + this.searchData = { + searchPropertyMatch: FILTER_PROP_RE.exec(this.searchValue), + searchPropertyName: this.searchValue, + searchPropertyValue: this.searchValue, + strictSearchValue: "", + strictSearchPropertyName: false, + strictSearchPropertyValue: false, + strictSearchAllValues: false, + }; + + if (this.searchData.searchPropertyMatch) { + // Parse search value as a single property line and extract the + // property name and value. If the parsed property name or value is + // contained in backquotes (`), extract the value within the backquotes + // and set the corresponding strict search for the property to true. + if (FILTER_STRICT_RE.test(this.searchData.searchPropertyMatch[1])) { + this.searchData.strictSearchPropertyName = true; + this.searchData.searchPropertyName = FILTER_STRICT_RE.exec( + this.searchData.searchPropertyMatch[1] + )[1]; + } else { + this.searchData.searchPropertyName = + this.searchData.searchPropertyMatch[1]; + } + + if (FILTER_STRICT_RE.test(this.searchData.searchPropertyMatch[2])) { + this.searchData.strictSearchPropertyValue = true; + this.searchData.searchPropertyValue = FILTER_STRICT_RE.exec( + this.searchData.searchPropertyMatch[2] + )[1]; + } else { + this.searchData.searchPropertyValue = + this.searchData.searchPropertyMatch[2]; + } + + // Strict search for stylesheets will match the property line regex. + // Extract the search value within the backquotes to be used + // in the strict search for stylesheets in _highlightStyleSheet. + if (FILTER_STRICT_RE.test(this.searchValue)) { + this.searchData.strictSearchValue = FILTER_STRICT_RE.exec( + this.searchValue + )[1]; + } + } else if (FILTER_STRICT_RE.test(this.searchValue)) { + // If the search value does not correspond to a property line and + // is contained in backquotes, extract the search value within the + // backquotes and set the flag to perform a strict search for all + // the values (selector, stylesheet, property and computed values). + const searchValue = FILTER_STRICT_RE.exec(this.searchValue)[1]; + this.searchData.strictSearchAllValues = true; + this.searchData.searchPropertyName = searchValue; + this.searchData.searchPropertyValue = searchValue; + this.searchData.strictSearchValue = searchValue; + } + + this._clearHighlight(this.element); + this._clearRules(); + this._createEditors(); + + this.inspector.emit("ruleview-filtered"); + + this._filterChangeTimeout = null; + }, filterTimeout); + }, + + /** + * Called when the user clicks on the clear button in the filter style search + * box. Returns true if the search box is cleared and false otherwise. + */ + _onClearSearch() { + if (this.searchField.value) { + this.setFilterStyles(""); + return true; + } + + return false; + }, + + destroy() { + this.isDestroyed = true; + this.clear(); + + this._dummyElement = null; + // off handlers must have the same reference as their on handlers + this._prefObserver.off(PREF_UA_STYLES, this._handleUAStylePrefChange); + this._prefObserver.off( + PREF_DEFAULT_COLOR_UNIT, + this._handleDefaultColorUnitPrefChange + ); + this._prefObserver.off(PREF_DRAGGABLE, this._handleDraggablePrefChange); + this._prefObserver.destroy(); + + this._outputParser = null; + + if (this._classListPreviewer) { + this._classListPreviewer.destroy(); + this._classListPreviewer = null; + } + + if (this._contextMenu) { + this._contextMenu.destroy(); + this._contextMenu = null; + } + + if (this._highlighters) { + this._highlighters.removeFromView(this); + this._highlighters = null; + } + + // Clean-up for simulations. + this.colorSchemeLightSimulationButton.removeEventListener( + "click", + this._onToggleLightColorSchemeSimulation + ); + this.colorSchemeDarkSimulationButton.removeEventListener( + "click", + this._onToggleDarkColorSchemeSimulation + ); + this.printSimulationButton.removeEventListener( + "click", + this._onTogglePrintSimulation + ); + + this.colorSchemeLightSimulationButton = null; + this.colorSchemeDarkSimulationButton = null; + this.printSimulationButton = null; + + this.tooltips.destroy(); + + // Remove bound listeners + this.shortcuts.destroy(); + this.styleDocument.removeEventListener("click", this, { capture: true }); + this.element.removeEventListener("copy", this._onCopy); + this.element.removeEventListener("contextmenu", this._onContextMenu); + this.addRuleButton.removeEventListener("click", this._onAddRule); + this.searchField.removeEventListener("input", this._onFilterStyles); + this.searchClearButton.removeEventListener("click", this._onClearSearch); + this.pseudoClassPanel.removeEventListener( + "change", + this._onTogglePseudoClass + ); + this.pseudoClassToggle.removeEventListener( + "click", + this._onTogglePseudoClassPanel + ); + this.classToggle.removeEventListener("click", this._onToggleClassPanel); + this.inspector.highlighters.off( + "highlighter-shown", + this.onHighlighterShown + ); + this.inspector.highlighters.off( + "highlighter-hidden", + this.onHighlighterHidden + ); + + this.searchField = null; + this.searchClearButton = null; + this.pseudoClassPanel = null; + this.pseudoClassToggle = null; + this.pseudoClassCheckboxes = null; + this.classPanel = null; + this.classToggle = null; + + this.inspector = null; + this.styleDocument = null; + this.styleWindow = null; + + if (this.element.parentNode) { + this.element.remove(); + } + + if (this._elementStyle) { + this._elementStyle.destroy(); + } + + if (this._popup) { + this._popup.destroy(); + this._popup = null; + } + }, + + /** + * Mark the view as selecting an element, disabling all interaction, and + * visually clearing the view after a few milliseconds to avoid confusion + * about which element's styles the rule view shows. + */ + _startSelectingElement() { + this.element.classList.add("non-interactive"); + }, + + /** + * Mark the view as no longer selecting an element, re-enabling interaction. + */ + _stopSelectingElement() { + this.element.classList.remove("non-interactive"); + }, + + /** + * Update the view with a new selected element. + * + * @param {NodeActor} element + * The node whose style rules we'll inspect. + * @param {Boolean} allowRefresh + * Update the view even if the element is the same as last time. + */ + selectElement(element, allowRefresh = false) { + const refresh = this._viewedElement === element; + if (refresh && !allowRefresh) { + return Promise.resolve(undefined); + } + + if (this._popup && this.popup.isOpen) { + this.popup.hidePopup(); + } + + this.clear(false); + this._viewedElement = element; + + this.clearPseudoClassPanel(); + this.refreshAddRuleButtonState(); + + if (!this._viewedElement) { + this._stopSelectingElement(); + this._clearRules(); + this._showEmpty(); + this.refreshPseudoClassPanel(); + if (this.pageStyle) { + this.pageStyle.off("stylesheet-updated", this.refreshPanel); + this.pageStyle = null; + } + return Promise.resolve(undefined); + } + + this.pageStyle = element.inspectorFront.pageStyle; + this.pageStyle.on("stylesheet-updated", this.refreshPanel); + + // To figure out how shorthand properties are interpreted by the + // engine, we will set properties on a dummy element and observe + // how their .style attribute reflects them as computed values. + const dummyElementPromise = Promise.resolve(this.styleDocument) + .then(document => { + // ::before and ::after do not have a namespaceURI + const namespaceURI = + this.element.namespaceURI || document.documentElement.namespaceURI; + this._dummyElement = document.createElementNS( + namespaceURI, + this.element.tagName + ); + }) + .catch(promiseWarn); + + const elementStyle = new ElementStyle( + element, + this, + this.store, + this.pageStyle, + this.showUserAgentStyles + ); + this._elementStyle = elementStyle; + + this._startSelectingElement(); + + return dummyElementPromise + .then(() => { + if (this._elementStyle === elementStyle) { + return this._populate(); + } + return undefined; + }) + .then(() => { + if (this._elementStyle === elementStyle) { + if (!refresh) { + this.element.scrollTop = 0; + } + this._stopSelectingElement(); + this._elementStyle.onChanged = () => { + this._changed(); + }; + } + }) + .catch(e => { + if (this._elementStyle === elementStyle) { + this._stopSelectingElement(); + this._clearRules(); + } + console.error(e); + }); + }, + + /** + * Update the rules for the currently highlighted element. + */ + refreshPanel() { + // Ignore refreshes when the panel is hidden, or during editing or when no element is selected. + if (!this.isPanelVisible() || this.isEditing || !this._elementStyle) { + return Promise.resolve(undefined); + } + + // Repopulate the element style once the current modifications are done. + const promises = []; + for (const rule of this._elementStyle.rules) { + if (rule._applyingModifications) { + promises.push(rule._applyingModifications); + } + } + + return Promise.all(promises).then(() => { + return this._populate(); + }); + }, + + /** + * Clear the pseudo class options panel by removing the checked and disabled + * attributes for each checkbox. + */ + clearPseudoClassPanel() { + this.pseudoClassCheckboxes.forEach(checkbox => { + checkbox.checked = false; + checkbox.disabled = false; + }); + }, + + /** + * For each item in PSEUDO_CLASSES, create a checkbox input element for toggling a + * pseudo-class on the selected element and append it to the pseudo-class panel. + * + * Returns an array with the checkbox input elements for pseudo-classes. + * + * @return {Array} + */ + _createPseudoClassCheckboxes() { + const doc = this.styleDocument; + const fragment = doc.createDocumentFragment(); + + for (const pseudo of PSEUDO_CLASSES) { + const label = doc.createElement("label"); + const checkbox = doc.createElement("input"); + checkbox.setAttribute("tabindex", "-1"); + checkbox.setAttribute("type", "checkbox"); + checkbox.setAttribute("value", pseudo); + + label.append(checkbox, pseudo); + fragment.append(label); + } + + this.pseudoClassPanel.append(fragment); + return Array.from( + this.pseudoClassPanel.querySelectorAll("input[type=checkbox]") + ); + }, + + /** + * Update the pseudo class options for the currently highlighted element. + */ + refreshPseudoClassPanel() { + if (!this._elementStyle || !this.inspector.selection.isElementNode()) { + this.pseudoClassCheckboxes.forEach(checkbox => { + checkbox.disabled = true; + }); + return; + } + + const pseudoClassLocks = this._elementStyle.element.pseudoClassLocks; + this.pseudoClassCheckboxes.forEach(checkbox => { + checkbox.disabled = false; + checkbox.checked = pseudoClassLocks.includes(checkbox.value); + }); + }, + + _populate() { + const elementStyle = this._elementStyle; + return this._elementStyle + .populate() + .then(() => { + if (this._elementStyle !== elementStyle || this.isDestroyed) { + return null; + } + + this._clearRules(); + const onEditorsReady = this._createEditors(); + this.refreshPseudoClassPanel(); + + // Notify anyone that cares that we refreshed. + return onEditorsReady.then(() => { + this.emit("ruleview-refreshed"); + }, console.error); + }) + .catch(promiseWarn); + }, + + /** + * Show the user that the rule view has no node selected. + */ + _showEmpty() { + if (this.styleDocument.getElementById("ruleview-no-results")) { + return; + } + + createChild(this.element, "div", { + id: "ruleview-no-results", + class: "devtools-sidepanel-no-result", + textContent: l10n("rule.empty"), + }); + }, + + /** + * Clear the rules. + */ + _clearRules() { + this.element.innerHTML = ""; + }, + + /** + * Clear the rule view. + */ + clear(clearDom = true) { + if (clearDom) { + this._clearRules(); + } + this._viewedElement = null; + + if (this._elementStyle) { + this._elementStyle.destroy(); + this._elementStyle = null; + } + + if (this.pageStyle) { + this.pageStyle.off("stylesheet-updated", this.refreshPanel); + this.pageStyle = null; + } + }, + + /** + * Called when the user has made changes to the ElementStyle. + * Emits an event that clients can listen to. + */ + _changed() { + this.emit("ruleview-changed"); + }, + + /** + * Text for header that shows above rules for this element + */ + get selectedElementLabel() { + if (this._selectedElementLabel) { + return this._selectedElementLabel; + } + this._selectedElementLabel = l10n("rule.selectedElement"); + return this._selectedElementLabel; + }, + + /** + * Text for header that shows above rules for pseudo elements + */ + get pseudoElementLabel() { + if (this._pseudoElementLabel) { + return this._pseudoElementLabel; + } + this._pseudoElementLabel = l10n("rule.pseudoElement"); + return this._pseudoElementLabel; + }, + + get showPseudoElements() { + if (this._showPseudoElements === undefined) { + this._showPseudoElements = Services.prefs.getBoolPref( + "devtools.inspector.show_pseudo_elements" + ); + } + return this._showPseudoElements; + }, + + /** + * Creates an expandable container in the rule view + * + * @param {String} label + * The label for the container header + * @param {Boolean} isPseudo + * Whether or not the container will hold pseudo element rules + * @return {DOMNode} The container element + */ + createExpandableContainer(label, isPseudo = false) { + const header = this.styleDocument.createElementNS(HTML_NS, "div"); + header.className = this._getRuleViewHeaderClassName(true); + header.setAttribute("role", "heading"); + header.textContent = label; + + const twisty = this.styleDocument.createElementNS(HTML_NS, "span"); + twisty.className = "ruleview-expander theme-twisty"; + twisty.setAttribute("open", "true"); + twisty.setAttribute("role", "button"); + twisty.setAttribute("aria-label", l10n("rule.twistyCollapse.label")); + + header.insertBefore(twisty, header.firstChild); + this.element.appendChild(header); + + const container = this.styleDocument.createElementNS(HTML_NS, "div"); + container.classList.add("ruleview-expandable-container"); + container.hidden = false; + this.element.appendChild(container); + + header.addEventListener("click", () => { + this._toggleContainerVisibility( + twisty, + container, + isPseudo, + !this.showPseudoElements + ); + }); + + if (isPseudo) { + container.id = "pseudo-elements-container"; + twisty.id = "pseudo-elements-header-twisty"; + this._toggleContainerVisibility( + twisty, + container, + isPseudo, + this.showPseudoElements + ); + } + + return container; + }, + + /** + * Toggle the visibility of an expandable container + * + * @param {DOMNode} twisty + * Clickable toggle DOM Node + * @param {DOMNode} container + * Expandable container DOM Node + * @param {Boolean} isPseudo + * Whether or not the container will hold pseudo element rules + * @param {Boolean} showPseudo + * Whether or not pseudo element rules should be displayed + */ + _toggleContainerVisibility(twisty, container, isPseudo, showPseudo) { + let isOpen = twisty.getAttribute("open"); + + if (isPseudo) { + this._showPseudoElements = !!showPseudo; + + Services.prefs.setBoolPref( + "devtools.inspector.show_pseudo_elements", + this.showPseudoElements + ); + + container.hidden = !this.showPseudoElements; + isOpen = !this.showPseudoElements; + } else { + container.hidden = !container.hidden; + } + + if (isOpen) { + twisty.removeAttribute("open"); + twisty.setAttribute("aria-label", l10n("rule.twistyExpand.label")); + } else { + twisty.setAttribute("open", "true"); + twisty.setAttribute("aria-label", l10n("rule.twistyCollapse.label")); + } + }, + + _getRuleViewHeaderClassName(isPseudo) { + const baseClassName = "ruleview-header"; + return isPseudo + ? baseClassName + " ruleview-expandable-header" + : baseClassName; + }, + + /** + * Creates editor UI for each of the rules in _elementStyle. + */ + // eslint-disable-next-line complexity + _createEditors() { + // Run through the current list of rules, attaching + // their editors in order. Create editors if needed. + let lastInheritedSource = ""; + let lastKeyframes = null; + let seenPseudoElement = false; + let seenNormalElement = false; + let seenSearchTerm = false; + let container = null; + + if (!this._elementStyle.rules) { + return Promise.resolve(); + } + + const editorReadyPromises = []; + for (const rule of this._elementStyle.rules) { + if (rule.domRule.system) { + continue; + } + + // Initialize rule editor + if (!rule.editor) { + rule.editor = new RuleEditor(this, rule); + editorReadyPromises.push(rule.editor.once("source-link-updated")); + } + + // Filter the rules and highlight any matches if there is a search input + if (this.searchValue && this.searchData) { + if (this.highlightRule(rule)) { + seenSearchTerm = true; + } else if (rule.domRule.type !== ELEMENT_STYLE) { + continue; + } + } + + // Only print header for this element if there are pseudo elements + if (seenPseudoElement && !seenNormalElement && !rule.pseudoElement) { + seenNormalElement = true; + const div = this.styleDocument.createElementNS(HTML_NS, "div"); + div.className = this._getRuleViewHeaderClassName(); + div.setAttribute("role", "heading"); + div.textContent = this.selectedElementLabel; + this.element.appendChild(div); + } + + const inheritedSource = rule.inherited; + if (inheritedSource && inheritedSource !== lastInheritedSource) { + const div = this.styleDocument.createElementNS(HTML_NS, "div"); + div.className = this._getRuleViewHeaderClassName(); + div.setAttribute("role", "heading"); + div.setAttribute("aria-level", "3"); + div.textContent = rule.inheritedSource; + lastInheritedSource = inheritedSource; + this.element.appendChild(div); + } + + if (!seenPseudoElement && rule.pseudoElement) { + seenPseudoElement = true; + container = this.createExpandableContainer( + this.pseudoElementLabel, + true + ); + } + + const keyframes = rule.keyframes; + if (keyframes && keyframes !== lastKeyframes) { + lastKeyframes = keyframes; + container = this.createExpandableContainer(rule.keyframesName); + } + + rule.editor.element.setAttribute("role", "article"); + if (container && (rule.pseudoElement || keyframes)) { + container.appendChild(rule.editor.element); + } else { + this.element.appendChild(rule.editor.element); + } + } + + const searchBox = this.searchField.parentNode; + searchBox.classList.toggle( + "devtools-searchbox-no-match", + this.searchValue && !seenSearchTerm + ); + + return Promise.all(editorReadyPromises); + }, + + /** + * Highlight rules that matches the filter search value and returns a + * boolean indicating whether or not rules were highlighted. + * + * @param {Rule} rule + * The rule object we're highlighting if its rule selectors or + * property values match the search value. + * @return {Boolean} true if the rule was highlighted, false otherwise. + */ + highlightRule(rule) { + const isRuleSelectorHighlighted = this._highlightRuleSelector(rule); + const isStyleSheetHighlighted = this._highlightStyleSheet(rule); + const isAncestorRulesHighlighted = this._highlightAncestorRules(rule); + let isHighlighted = + isRuleSelectorHighlighted || + isStyleSheetHighlighted || + isAncestorRulesHighlighted; + + // Highlight search matches in the rule properties + for (const textProp of rule.textProps) { + if (!textProp.invisible && this._highlightProperty(textProp.editor)) { + isHighlighted = true; + } + } + + return isHighlighted; + }, + + /** + * Highlights the rule selector that matches the filter search value and + * returns a boolean indicating whether or not the selector was highlighted. + * + * @param {Rule} rule + * The Rule object. + * @return {Boolean} true if the rule selector was highlighted, + * false otherwise. + */ + _highlightRuleSelector(rule) { + let isSelectorHighlighted = false; + + let selectorNodes = [...rule.editor.selectorText.childNodes]; + if (rule.domRule.type === CSSRule.KEYFRAME_RULE) { + selectorNodes = [rule.editor.selectorText]; + } else if (rule.domRule.type === ELEMENT_STYLE) { + selectorNodes = []; + } + + // Highlight search matches in the rule selectors + for (const selectorNode of selectorNodes) { + const selector = selectorNode.textContent.toLowerCase(); + if ( + (this.searchData.strictSearchAllValues && + selector === this.searchData.strictSearchValue) || + (!this.searchData.strictSearchAllValues && + selector.includes(this.searchValue)) + ) { + selectorNode.classList.add("ruleview-highlight"); + isSelectorHighlighted = true; + } + } + + return isSelectorHighlighted; + }, + + /** + * Highlights the ancestor rules data (@media / @layer) that matches the filter search + * value and returns a boolean indicating whether or not element was highlighted. + * + * @return {Boolean} true if the element was highlighted, false otherwise. + */ + _highlightAncestorRules(rule) { + const element = rule.editor.ancestorDataEl; + if (!element) { + return false; + } + + let isHighlighted = false; + for (let i = 0; i < element.childNodes.length; i++) { + const child = element.childNodes[i]; + const dataText = child.innerText.toLowerCase(); + const matches = this.searchData.strictSearchValue + ? dataText === this.searchData.strictSearchValue + : dataText.includes(this.searchValue); + if (matches) { + isHighlighted = true; + child.classList.add("ruleview-highlight"); + } + } + + return isHighlighted; + }, + + /** + * Highlights the stylesheet source that matches the filter search value and + * returns a boolean indicating whether or not the stylesheet source was + * highlighted. + * + * @return {Boolean} true if the stylesheet source was highlighted, false + * otherwise. + */ + _highlightStyleSheet(rule) { + const styleSheetSource = rule.title.toLowerCase(); + const isStyleSheetHighlighted = this.searchData.strictSearchValue + ? styleSheetSource === this.searchData.strictSearchValue + : styleSheetSource.includes(this.searchValue); + + if (isStyleSheetHighlighted) { + rule.editor.source.classList.add("ruleview-highlight"); + } + + return isStyleSheetHighlighted; + }, + + /** + * Highlights the rule properties and computed properties that match the + * filter search value and returns a boolean indicating whether or not the + * property or computed property was highlighted. + * + * @param {TextPropertyEditor} editor + * The rule property TextPropertyEditor object. + * @return {Boolean} true if the property or computed property was + * highlighted, false otherwise. + */ + _highlightProperty(editor) { + const isPropertyHighlighted = this._highlightRuleProperty(editor); + const isComputedHighlighted = this._highlightComputedProperty(editor); + + // Expand the computed list if a computed property is highlighted and the + // property rule is not highlighted + if ( + !isPropertyHighlighted && + isComputedHighlighted && + !editor.computed.hasAttribute("user-open") + ) { + editor.expandForFilter(); + } + + return isPropertyHighlighted || isComputedHighlighted; + }, + + /** + * Called when TextPropertyEditor is updated and updates the rule property + * highlight. + * + * @param {TextPropertyEditor} editor + * The rule property TextPropertyEditor object. + */ + _updatePropertyHighlight(editor) { + if (!this.searchValue || !this.searchData) { + return; + } + + this._clearHighlight(editor.element); + + if (this._highlightProperty(editor)) { + this.searchField.classList.remove("devtools-style-searchbox-no-match"); + } + }, + + /** + * Highlights the rule property that matches the filter search value + * and returns a boolean indicating whether or not the property was + * highlighted. + * + * @param {TextPropertyEditor} editor + * The rule property TextPropertyEditor object. + * @return {Boolean} true if the rule property was highlighted, + * false otherwise. + */ + _highlightRuleProperty(editor) { + // Get the actual property value displayed in the rule view + const propertyName = editor.prop.name.toLowerCase(); + const propertyValue = editor.valueSpan.textContent.toLowerCase(); + + return this._highlightMatches( + editor.container, + propertyName, + propertyValue + ); + }, + + /** + * Highlights the computed property that matches the filter search value and + * returns a boolean indicating whether or not the computed property was + * highlighted. + * + * @param {TextPropertyEditor} editor + * The rule property TextPropertyEditor object. + * @return {Boolean} true if the computed property was highlighted, false + * otherwise. + */ + _highlightComputedProperty(editor) { + let isComputedHighlighted = false; + + // Highlight search matches in the computed list of properties + editor._populateComputed(); + for (const computed of editor.prop.computed) { + if (computed.element) { + // Get the actual property value displayed in the computed list + const computedName = computed.name.toLowerCase(); + const computedValue = computed.parsedValue.toLowerCase(); + + isComputedHighlighted = this._highlightMatches( + computed.element, + computedName, + computedValue + ) + ? true + : isComputedHighlighted; + } + } + + return isComputedHighlighted; + }, + + /** + * Helper function for highlightRules that carries out highlighting the given + * element if the search terms match the property, and returns a boolean + * indicating whether or not the search terms match. + * + * @param {DOMNode} element + * The node to highlight if search terms match + * @param {String} propertyName + * The property name of a rule + * @param {String} propertyValue + * The property value of a rule + * @return {Boolean} true if the given search terms match the property, false + * otherwise. + */ + _highlightMatches(element, propertyName, propertyValue) { + const { + searchPropertyName, + searchPropertyValue, + searchPropertyMatch, + strictSearchPropertyName, + strictSearchPropertyValue, + strictSearchAllValues, + } = this.searchData; + let matches = false; + + // If the inputted search value matches a property line like + // `font-family: arial`, then check to make sure the name and value match. + // Otherwise, just compare the inputted search string directly against the + // name and value of the rule property. + const hasNameAndValue = + searchPropertyMatch && searchPropertyName && searchPropertyValue; + const isMatch = (value, query, isStrict) => { + return isStrict ? value === query : query && value.includes(query); + }; + + if (hasNameAndValue) { + matches = + isMatch(propertyName, searchPropertyName, strictSearchPropertyName) && + isMatch(propertyValue, searchPropertyValue, strictSearchPropertyValue); + } else { + matches = + isMatch( + propertyName, + searchPropertyName, + strictSearchPropertyName || strictSearchAllValues + ) || + isMatch( + propertyValue, + searchPropertyValue, + strictSearchPropertyValue || strictSearchAllValues + ); + } + + if (matches) { + element.classList.add("ruleview-highlight"); + } + + return matches; + }, + + /** + * Clear all search filter highlights in the panel, and close the computed + * list if toggled opened + */ + _clearHighlight(element) { + for (const el of element.querySelectorAll(".ruleview-highlight")) { + el.classList.remove("ruleview-highlight"); + } + + for (const computed of element.querySelectorAll( + ".ruleview-computedlist[filter-open]" + )) { + computed.parentNode._textPropertyEditor.collapseForFilter(); + } + }, + + /** + * Called when the pseudo class panel button is clicked and toggles + * the display of the pseudo class panel. + */ + _onTogglePseudoClassPanel() { + if (this.pseudoClassPanel.hidden) { + this.showPseudoClassPanel(); + } else { + this.hidePseudoClassPanel(); + } + }, + + showPseudoClassPanel() { + this.hideClassPanel(); + + this.pseudoClassToggle.classList.add("checked"); + this.pseudoClassCheckboxes.forEach(checkbox => { + checkbox.setAttribute("tabindex", "0"); + }); + this.pseudoClassPanel.hidden = false; + }, + + hidePseudoClassPanel() { + this.pseudoClassToggle.classList.remove("checked"); + this.pseudoClassCheckboxes.forEach(checkbox => { + checkbox.setAttribute("tabindex", "-1"); + }); + this.pseudoClassPanel.hidden = true; + }, + + /** + * Called when a pseudo class checkbox is clicked and toggles + * the pseudo class for the current selected element. + */ + _onTogglePseudoClass(event) { + const target = event.target; + this.inspector.togglePseudoClass(target.value); + }, + + /** + * Called when the class panel button is clicked and toggles the display of the class + * panel. + */ + _onToggleClassPanel() { + if (this.classPanel.hidden) { + this.showClassPanel(); + } else { + this.hideClassPanel(); + } + }, + + showClassPanel() { + this.hidePseudoClassPanel(); + + this.classToggle.classList.add("checked"); + this.classPanel.hidden = false; + + this.classListPreviewer.focusAddClassField(); + }, + + hideClassPanel() { + this.classToggle.classList.remove("checked"); + this.classPanel.hidden = true; + }, + + /** + * Handle the keypress event in the rule view. + */ + _onShortcut(name, event) { + if (!event.target.closest("#sidebar-panel-ruleview")) { + return; + } + + if (name === "CmdOrCtrl+F") { + this.searchField.focus(); + event.preventDefault(); + } else if ( + (name === "Return" || name === "Space") && + this.element.classList.contains("non-interactive") + ) { + event.preventDefault(); + } else if ( + name === "Escape" && + event.target === this.searchField && + this._onClearSearch() + ) { + // Handle the search box's keypress event. If the escape key is pressed, + // clear the search box field. + event.preventDefault(); + event.stopPropagation(); + } + }, + + async _onToggleLightColorSchemeSimulation() { + const shouldSimulateLightScheme = + this.colorSchemeLightSimulationButton.classList.toggle("checked"); + + const darkColorSchemeEnabled = + this.colorSchemeDarkSimulationButton.classList.contains("checked"); + if (shouldSimulateLightScheme && darkColorSchemeEnabled) { + this.colorSchemeDarkSimulationButton.classList.toggle("checked"); + } + + await this.inspector.commands.targetConfigurationCommand.updateConfiguration( + { + colorSchemeSimulation: shouldSimulateLightScheme ? "light" : null, + } + ); + // Refresh the current element's rules in the panel. + this.refreshPanel(); + }, + + async _onToggleDarkColorSchemeSimulation() { + const shouldSimulateDarkScheme = + this.colorSchemeDarkSimulationButton.classList.toggle("checked"); + + const lightColorSchemeEnabled = + this.colorSchemeLightSimulationButton.classList.contains("checked"); + if (shouldSimulateDarkScheme && lightColorSchemeEnabled) { + this.colorSchemeLightSimulationButton.classList.toggle("checked"); + } + + await this.inspector.commands.targetConfigurationCommand.updateConfiguration( + { + colorSchemeSimulation: shouldSimulateDarkScheme ? "dark" : null, + } + ); + // Refresh the current element's rules in the panel. + this.refreshPanel(); + }, + + async _onTogglePrintSimulation() { + const enabled = this.printSimulationButton.classList.toggle("checked"); + await this.inspector.commands.targetConfigurationCommand.updateConfiguration( + { + printSimulationEnabled: enabled, + } + ); + // Refresh the current element's rules in the panel. + this.refreshPanel(); + }, + + /** + * Temporarily flash the given element. + * + * @param {Element} element + * The element. + */ + _flashElement(element) { + flashElementOn(element, { + backgroundClass: "theme-bg-contrast", + }); + + if (this._flashMutationTimer) { + clearTimeout(this._removeFlashOutTimer); + this._flashMutationTimer = null; + } + + this._flashMutationTimer = setTimeout(() => { + flashElementOff(element, { + backgroundClass: "theme-bg-contrast", + }); + + // Emit "scrolled-to-property" for use by tests. + this.emit("scrolled-to-element"); + }, PROPERTY_FLASHING_DURATION); + }, + + /** + * Scrolls to the top of either the rule or declaration. The view will try to scroll to + * the rule if both can fit in the viewport. If not, then scroll to the declaration. + * + * @param {Element} rule + * The rule to scroll to. + * @param {Element|null} declaration + * Optional. The declaration to scroll to. + * @param {String} scrollBehavior + * Optional. The transition animation when scrolling. If prefers-reduced-motion + * system pref is set, then the scroll behavior will be overridden to "auto". + */ + _scrollToElement(rule, declaration, scrollBehavior = "smooth") { + let elementToScrollTo = rule; + + if (declaration) { + const { offsetTop, offsetHeight } = declaration; + // Get the distance between both the rule and declaration. If the distance is + // greater than the height of the rule view, then only scroll to the declaration. + const distance = offsetTop + offsetHeight - rule.offsetTop; + + if (this.element.parentNode.offsetHeight <= distance) { + elementToScrollTo = declaration; + } + } + + // Ensure that smooth scrolling is disabled when the user prefers reduced motion. + const win = elementToScrollTo.ownerGlobal; + const reducedMotion = win.matchMedia("(prefers-reduced-motion)").matches; + scrollBehavior = reducedMotion ? "auto" : scrollBehavior; + + elementToScrollTo.scrollIntoView({ behavior: scrollBehavior }); + }, + + /** + * Toggles the visibility of the pseudo element rule's container. + */ + _togglePseudoElementRuleContainer() { + const container = this.styleDocument.getElementById( + "pseudo-elements-container" + ); + const twisty = this.styleDocument.getElementById( + "pseudo-elements-header-twisty" + ); + this._toggleContainerVisibility(twisty, container, true, true); + }, + + /** + * Finds the rule with the matching actorID and highlights it. + * + * @param {String} ruleId + * The actorID of the rule. + */ + highlightElementRule(ruleId) { + let scrollBehavior = "smooth"; + + const rule = this.rules.find(r => r.domRule.actorID === ruleId); + + if (!rule) { + return; + } + + if (rule.domRule.actorID === ruleId) { + // If using 2-Pane mode, then switch to the Rules tab first. + if (!this.inspector.is3PaneModeEnabled) { + this.inspector.sidebar.select("ruleview"); + } + + if (rule.pseudoElement.length && !this.showPseudoElements) { + scrollBehavior = "auto"; + this._togglePseudoElementRuleContainer(); + } + + const { + editor: { element }, + } = rule; + + // Scroll to the top of the rule and highlight it. + this._scrollToElement(element, null, scrollBehavior); + this._flashElement(element); + } + }, + + /** + * Finds the specified TextProperty name in the rule view. If found, scroll to and + * flash the TextProperty. + * + * @param {String} name + * The property name to scroll to and highlight. + * @return {Boolean} true if the TextProperty name is found, and false otherwise. + */ + highlightProperty(name) { + for (const rule of this.rules) { + for (const textProp of rule.textProps) { + if (textProp.overridden || textProp.invisible || !textProp.enabled) { + continue; + } + + const { + editor: { selectorText }, + } = rule; + let scrollBehavior = "smooth"; + + // First, search for a matching authored property. + if (textProp.name === name) { + // If using 2-Pane mode, then switch to the Rules tab first. + if (!this.inspector.is3PaneModeEnabled) { + this.inspector.sidebar.select("ruleview"); + } + + // If the property is being applied by a pseudo element rule, expand the pseudo + // element list container. + if (rule.pseudoElement.length && !this.showPseudoElements) { + // Set the scroll behavior to "auto" to avoid timing issues between toggling + // the pseudo element container and scrolling smoothly to the rule. + scrollBehavior = "auto"; + this._togglePseudoElementRuleContainer(); + } + + // Scroll to the top of the property's rule so that both the property and its + // rule are visible. + this._scrollToElement( + selectorText, + textProp.editor.element, + scrollBehavior + ); + this._flashElement(textProp.editor.element); + + return true; + } + + // If there is no matching property, then look in computed properties. + for (const computed of textProp.computed) { + if (computed.overridden) { + continue; + } + + if (computed.name === name) { + if (!this.inspector.is3PaneModeEnabled) { + this.inspector.sidebar.select("ruleview"); + } + + if ( + textProp.rule.pseudoElement.length && + !this.showPseudoElements + ) { + scrollBehavior = "auto"; + this._togglePseudoElementRuleContainer(); + } + + // Expand the computed list. + textProp.editor.expandForFilter(); + + this._scrollToElement( + selectorText, + computed.element, + scrollBehavior + ); + this._flashElement(computed.element); + + return true; + } + } + } + } + + return false; + }, +}; + +function RuleViewTool(inspector, window) { + this.inspector = inspector; + this.document = window.document; + + this.view = new CssRuleView(this.inspector, this.document); + + this._onResourceAvailable = this._onResourceAvailable.bind(this); + this.refresh = this.refresh.bind(this); + this.onDetachedFront = this.onDetachedFront.bind(this); + this.onPanelSelected = this.onPanelSelected.bind(this); + this.onDetachedFront = this.onDetachedFront.bind(this); + this.onSelected = this.onSelected.bind(this); + this.onViewRefreshed = this.onViewRefreshed.bind(this); + + this.view.on("ruleview-refreshed", this.onViewRefreshed); + this.inspector.selection.on("detached-front", this.onDetachedFront); + this.inspector.selection.on("new-node-front", this.onSelected); + this.inspector.selection.on("pseudoclass", this.refresh); + this.inspector.ruleViewSideBar.on("ruleview-selected", this.onPanelSelected); + this.inspector.sidebar.on("ruleview-selected", this.onPanelSelected); + this.inspector.styleChangeTracker.on("style-changed", this.refresh); + + this.inspector.commands.resourceCommand.watchResources( + [this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this._onResourceAvailable, + ignoreExistingResources: true, + } + ); + + // At the moment `readyPromise` is only consumed in tests (see `openRuleView`) to be + // notified when the ruleview was first populated to match the initial selected node. + this.readyPromise = this.onSelected(); +} + +RuleViewTool.prototype = { + isPanelVisible() { + if (!this.view) { + return false; + } + return this.view.isPanelVisible(); + }, + + onDetachedFront() { + this.onSelected(false); + }, + + onSelected(selectElement = true) { + // Ignore the event if the view has been destroyed, or if it's inactive. + // But only if the current selection isn't null. If it's been set to null, + // let the update go through as this is needed to empty the view on + // navigation. + if (!this.view) { + return null; + } + + const isInactive = + !this.isPanelVisible() && this.inspector.selection.nodeFront; + if (isInactive) { + return null; + } + + if ( + !this.inspector.selection.isConnected() || + !this.inspector.selection.isElementNode() + ) { + return this.view.selectElement(null); + } + + if (!selectElement) { + return null; + } + + const done = this.inspector.updating("rule-view"); + return this.view + .selectElement(this.inspector.selection.nodeFront) + .then(done, done); + }, + + refresh() { + if (this.isPanelVisible()) { + this.view.refreshPanel(); + } + }, + + _onResourceAvailable(resources) { + for (const resource of resources) { + if ( + resource.resourceType === + this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT && + resource.name === "will-navigate" && + resource.targetFront.isTopLevel + ) { + this.clearUserProperties(); + } + } + }, + + clearUserProperties() { + if (this.view && this.view.store && this.view.store.userProperties) { + this.view.store.userProperties.clear(); + } + }, + + onPanelSelected() { + if (this.inspector.selection.nodeFront === this.view._viewedElement) { + this.refresh(); + } else { + this.onSelected(); + } + }, + + onViewRefreshed() { + this.inspector.emit("rule-view-refreshed"); + }, + + destroy() { + this.inspector.styleChangeTracker.off("style-changed", this.refresh); + this.inspector.selection.off("detached-front", this.onDetachedFront); + this.inspector.selection.off("pseudoclass", this.refresh); + this.inspector.selection.off("new-node-front", this.onSelected); + this.inspector.currentTarget.off("navigate", this.clearUserProperties); + this.inspector.sidebar.off("ruleview-selected", this.onPanelSelected); + + this.inspector.commands.resourceCommand.unwatchResources( + [this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this._onResourceAvailable, + } + ); + + this.view.off("ruleview-refreshed", this.onViewRefreshed); + + this.view.destroy(); + + this.view = this.document = this.inspector = this.readyPromise = null; + }, +}; + +exports.CssRuleView = CssRuleView; +exports.RuleViewTool = RuleViewTool; diff --git a/devtools/client/inspector/rules/test/browser_part1.ini b/devtools/client/inspector/rules/test/browser_part1.ini new file mode 100644 index 0000000000..b31e7765a0 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_part1.ini @@ -0,0 +1,172 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + doc_blob_stylesheet.html + doc_copystyles.css + doc_copystyles.html + doc_class_panel_autocomplete_stylesheet.css + doc_class_panel_autocomplete.html + doc_conditional_import.css + doc_cssom.html + doc_custom.html + doc_edit_imported_selector.html + doc_imported_named_layer.css + doc_imported_no_layer.css + doc_test_image.png + doc_variables_4.html + head.js + !/devtools/client/inspector/test/head.js + !/devtools/client/inspector/test/shared-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_rules_add-property-and-reselect.js] +skip-if = http3 # Bug 1829298 +[browser_rules_add-property-cancel_01.js] +[browser_rules_add-property-cancel_02.js] +[browser_rules_add-property-cancel_03.js] +[browser_rules_add-property-commented.js] +skip-if = + verify && debug && os == "win" +[browser_rules_add-property_01.js] +[browser_rules_add-property_02.js] +[browser_rules_add-property-invalid-identifier.js] +[browser_rules_add-property-svg.js] +[browser_rules_add-rule-and-property.js] +[browser_rules_add-rule-and-remove-style-node.js] +[browser_rules_add-rule-button-state.js] +[browser_rules_add-rule-csp.js] +[browser_rules_add-rule-edit-selector.js] +[browser_rules_add-rule-iframes.js] +[browser_rules_add-rule-namespace-elements.js] +[browser_rules_add-rule-pseudo-class.js] +[browser_rules_add-rule-then-property-edit-selector.js] +[browser_rules_add-rule-with-menu.js] +[browser_rules_add-rule.js] +[browser_rules_authored.js] +[browser_rules_authored_color.js] +skip-if = + os == "linux" && os_version == '18.04' && !debug # Bug 1559315 + apple_catalina # Bug 1713158 + win10_2004 # Bug 1559315, 1723573 + win11_2009 # Bug 1797751 +[browser_rules_authored_override.js] +[browser_rules_blob_stylesheet.js] +[browser_rules_class_panel_add.js] +[browser_rules_class_panel_autocomplete.js] +[browser_rules_class_panel_content.js] +[browser_rules_class_panel_edit.js] +[browser_rules_class_panel_invalid_nodes.js] +[browser_rules_class_panel_mutation.js] +[browser_rules_class_panel_state_preserved.js] +[browser_rules_class_panel_toggle.js] +[browser_rules_color_scheme_simulation_bfcache.js] +[browser_rules_color_scheme_simulation_meta.js] +[browser_rules_color_scheme_simulation_rdm.js] +skip-if = + win10_2004 # Bug 1723573 +[browser_rules_color_scheme_simulation.js] +skip-if = + os == "win" && !debug # Bug 1703465 +[browser_rules_colorpicker-and-image-tooltip_01.js] +[browser_rules_colorpicker-and-image-tooltip_02.js] +[browser_rules_colorpicker-appears-on-swatch-click-or-keyboard-activation.js] +[browser_rules_colorpicker-commit-on-ENTER.js] +[browser_rules_colorpicker-contrast-ratio.js] +[browser_rules_colorpicker-edit-gradient.js] +[browser_rules_colorpicker-element-without-quads.js] +[browser_rules_colorpicker-hides-element-picker.js] +[browser_rules_colorpicker-hides-on-tooltip.js] +[browser_rules_colorpicker-multiple-changes.js] +[browser_rules_colorpicker-release-outside-frame.js] +[browser_rules_colorpicker-revert-on-ESC.js] +[browser_rules_colorpicker-swatch-displayed.js] +[browser_rules_colorpicker-works-with-css-vars.js] +[browser_rules_colorpicker-wrap-focus.js] +[browser_rules_colorUnit.js] +[browser_rules_completion-existing-property_01.js] +[browser_rules_completion-existing-property_02.js] +[browser_rules_completion-new-property_01.js] +[browser_rules_completion-new-property_02.js] +skip-if = + verify && !debug && os == "win" +[browser_rules_completion-new-property_03.js] +[browser_rules_completion-new-property_04.js] +[browser_rules_completion-new-property_multiline.js] +[browser_rules_completion-on-empty.js] +[browser_rules_completion-shortcut.js] +[browser_rules_computed-lists_01.js] +[browser_rules_computed-lists_02.js] +[browser_rules_computed-lists_03.js] +[browser_rules_completion-popup-hidden-after-navigation.js] +[browser_rules_conditional_import.js] +[browser_rules_container-queries.js] +[browser_rules_content_01.js] +[browser_rules_content_02.js] +[browser_rules_variables-in-pseudo-element_01.js] +[browser_rules_variables-in-pseudo-element_02.js] +[browser_rules_variables_01.js] +[browser_rules_variables_02.js] +skip-if = debug # Bug 1250058 - Docshell leak on debug +[browser_rules_variables_03-case-sensitive.js] +[browser_rules_variables_04-valid-chars.js] +[browser_rules_copy_styles.js] +[browser_rules_cssom.js] +[browser_rules_cubicbezier-appears-on-swatch-click.js] +[browser_rules_cubicbezier-commit-on-ENTER.js] +[browser_rules_cubicbezier-revert-on-ESC.js] +[browser_rules_custom.js] +[browser_rules_cycle-angle.js] +[browser_rules_cycle-color.js] +[browser_rules_edit-display-grid-property.js] +[browser_rules_edit-property-cancel.js] +[browser_rules_edit-property-click.js] +[browser_rules_edit-property-commit.js] +[browser_rules_edit-property-computed.js] +[browser_rules_edit-property-increments.js] +[browser_rules_edit-property-order.js] +[browser_rules_edit-property-remove_01.js] +skip-if = + verify && debug && os == "win" +[browser_rules_edit-property-remove_02.js] +[browser_rules_edit-property-remove_03.js] +[browser_rules_edit-property-remove_04.js] +[browser_rules_edit-property_01.js] +[browser_rules_edit-property_02.js] +[browser_rules_edit-property_03.js] +[browser_rules_edit-property_04.js] +[browser_rules_edit-property_05.js] +[browser_rules_edit-property_06.js] +[browser_rules_edit-property_07.js] +[browser_rules_edit-property_08.js] +[browser_rules_edit-property_09.js] +[browser_rules_edit-property_10.js] +[browser_rules_edit-selector-click.js] +[browser_rules_edit-selector-click-on-scrollbar.js] +skip-if = os == "mac" # Bug 1245996 : click on scrollbar not working on OSX +[browser_rules_edit-selector-commit.js] +[browser_rules_edit-selector_01.js] +[browser_rules_edit-selector_02.js] +[browser_rules_edit-selector_03.js] +[browser_rules_edit-selector_04.js] +[browser_rules_edit-selector_05.js] +[browser_rules_edit-selector_06.js] +[browser_rules_edit-selector_07.js] +[browser_rules_edit-selector_08.js] +[browser_rules_edit-selector_09.js] +[browser_rules_edit-selector_10.js] +[browser_rules_edit-selector_11.js] +[browser_rules_edit-selector_12.js] +[browser_rules_edit-size-property-dragging.js] +[browser_rules_edit-value-after-name_01.js] +[browser_rules_edit-value-after-name_02.js] +[browser_rules_edit-value-after-name_03.js] +[browser_rules_edit-value-after-name_04.js] +[browser_rules_edit-variable.js] +[browser_rules_edit-variable-add.js] +[browser_rules_edit-variable-remove.js] +[browser_rules_editable-field-focus_01.js] +[browser_rules_editable-field-focus_02.js] +[browser_rules_eyedropper.js] diff --git a/devtools/client/inspector/rules/test/browser_part2.ini b/devtools/client/inspector/rules/test/browser_part2.ini new file mode 100644 index 0000000000..3d245f2e2c --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_part2.ini @@ -0,0 +1,223 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + doc_author-sheet.html + doc_content_stylesheet.html + doc_content_stylesheet_imported.css + doc_content_stylesheet_imported2.css + doc_content_stylesheet_linked.css + doc_content_stylesheet_script.css + doc_filter.html + doc_grid_names.html + doc_grid_area_gridline_names.html + doc_inline_sourcemap.html + doc_invalid_sourcemap.css + doc_invalid_sourcemap.html + doc_keyframeanimation.css + doc_keyframeanimation.html + doc_keyframeLineNumbers.html + doc_media_queries.html + doc_print_media_simulation.html + doc_pseudoelement.html + doc_ruleLineNumbers.html + doc_rules_imported_stylesheet_edit.html + doc_sourcemaps.css + doc_sourcemaps.css.map + doc_sourcemaps.html + doc_sourcemaps.scss + doc_sourcemaps2.css + doc_sourcemaps2.css^headers^ + doc_sourcemaps2.html + doc_style_editor_link.css + doc_test_image.png + doc_urls_clickable.css + doc_urls_clickable.html + doc_variables_1.html + doc_variables_2.html + doc_variables_3.html + doc_visited.html + doc_visited_in_media_query.html + doc_visited_with_style_attribute.html + doc_imported_anonymous_layer.css + doc_imported_named_layer.css + doc_imported_no_layer.css + doc_inactive_css_xul.xhtml + head.js + sjs_imported_stylesheet_edit.sjs + square_svg.sjs + !/devtools/client/inspector/test/head.js + !/devtools/client/inspector/test/shared-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 + !/devtools/client/webconsole/test/browser/shared-head.js + +[browser_rules_css-compatibility-add-rename-rule.js] +skip-if = + os == "linux" #bug 1657807 + os == "win" #bug 1657807 +[browser_rules_css-compatibility-check-add-fix.js] +[browser_rules_css-compatibility-learn-more-link.js] +[browser_rules_css-compatibility-toggle-rules.js] +[browser_rules_css-compatibility-tooltip-telemetry.js] +[browser_rules_filtereditor-appears-on-swatch-click.js] +[browser_rules_filtereditor-commit-on-ENTER.js] +[browser_rules_filtereditor-revert-on-ESC.js] +skip-if = + os == "win" && debug # bug 963492: win. +[browser_rules_flexbox-highlighter-on-mutation.js] +[browser_rules_flexbox-highlighter-on-navigate.js] +[browser_rules_flexbox-highlighter-on-reload.js] +[browser_rules_flexbox-highlighter-restored-after-reload.js] +[browser_rules_flexbox-toggle-telemetry.js] +[browser_rules_flexbox-toggle_01.js] +[browser_rules_flexbox-toggle_01b.js] +[browser_rules_flexbox-toggle_02.js] +[browser_rules_flexbox-toggle_03.js] +[browser_rules_flexbox-toggle_04.js] +[browser_rules_font-family-parsing.js] +[browser_rules_grid-highlighter-on-mutation.js] +[browser_rules_grid-highlighter-on-navigate.js] +[browser_rules_grid-highlighter-on-reload.js] +[browser_rules_grid-highlighter-restored-after-reload.js] +[browser_rules_grid-template-areas.js] +[browser_rules_grid-toggle-telemetry.js] +[browser_rules_grid-toggle_01.js] +[browser_rules_grid-toggle_01b.js] +[browser_rules_grid-toggle_02.js] +[browser_rules_grid-toggle_03.js] +[browser_rules_grid-toggle_04.js] +[browser_rules_grid-toggle_05.js] +[browser_rules_gridline-names-autocomplete.js] +skip-if = + win10_2004 # Bug 1723573 + os == "mac" && !debug # Bug 1675592; high frequency with/out fission +[browser_rules_gridline-names-are-shown-correctly.js] +skip-if = os == "linux" # focusEditableField times out consistently on linux. +[browser_rules_guessIndentation.js] +[browser_rules_highlight-element-rule.js] +[browser_rules_highlight-property.js] +[browser_rules_highlight-used-fonts.js] +[browser_rules_imported_stylesheet_edit.js] +skip-if = http3 # Bug 1829298 +[browser_rules_inactive_css_display-justify.js] +[browser_rules_inactive_css_flexbox.js] +[browser_rules_inactive_css_grid.js] +[browser_rules_inactive_css_inline.js] +[browser_rules_inactive_css_split-condition.js] +[browser_rules_inactive_css_visited.js] +[browser_rules_inactive_css_xul.js] +[browser_rules_inherited-properties_01.js] +[browser_rules_inherited-properties_02.js] +[browser_rules_inherited-properties_03.js] +[browser_rules_inherited-properties_04.js] +[browser_rules_inline-source-map.js] +[browser_rules_inline-style-order.js] +[browser_rules_invalid.js] +[browser_rules_invalid-source-map.js] +[browser_rules_keybindings.js] +[browser_rules_keyframes-rule-shadowdom.js] +[browser_rules_keyframes-rule_01.js] +[browser_rules_keyframes-rule_02.js] +[browser_rules_keyframeLineNumbers.js] +[browser_rules_large_base64_background_image.js] +[browser_rules_layer.js] +[browser_rules_linear-easing-swatch.js] +[browser_rules_lineNumbers.js] +[browser_rules_livepreview.js] +[browser_rules_mark_overridden_01.js] +[browser_rules_mark_overridden_02.js] +[browser_rules_mark_overridden_03.js] +[browser_rules_mark_overridden_04.js] +[browser_rules_mark_overridden_05.js] +[browser_rules_mark_overridden_06.js] +[browser_rules_mark_overridden_07.js] +[browser_rules_mark_overridden_08.js] +[browser_rules_mathml-element.js] +disabled = bug 1231085 # This should be rewritten now that MathMLElement.style is available. +[browser_rules_media-queries_reload.js] +skip-if = + ccov && os == "win" # Bug 1516686 +[browser_rules_media-queries.js] +[browser_rules_multiple-properties-duplicates.js] +[browser_rules_multiple-properties-priority.js] +[browser_rules_multiple-properties-unfinished_01.js] +[browser_rules_multiple-properties-unfinished_02.js] +[browser_rules_multiple_properties_01.js] +[browser_rules_multiple_properties_02.js] +[browser_rules_nested_at_rules.js] +[browser_rules_non_ascii.js] +[browser_rules_original-source-link.js] +skip-if = + ccov #Bug 1432176 + http3 # Bug 1829298 +[browser_rules_original-source-link2.js] +skip-if = + ccov # Bug 1432176 + http3 # Bug 1829298 +[browser_rules_preview-tooltips-sizes.js] +[browser_rules_print_media_simulation.js] +[browser_rules_pseudo-element_01.js] +[browser_rules_pseudo-element_02.js] +[browser_rules_pseudo-visited.js] +[browser_rules_pseudo-visited_in_media-query.js] +[browser_rules_pseudo-visited_with_style-attribute.js] +[browser_rules_pseudo_lock_options.js] +[browser_rules_refresh-no-flicker.js] +[browser_rules_refresh-on-attribute-change_01.js] +[browser_rules_refresh-on-style-change.js] +[browser_rules_search-filter-computed-list_01.js] +[browser_rules_search-filter-computed-list_02.js] +[browser_rules_search-filter-computed-list_03.js] +[browser_rules_search-filter-computed-list_04.js] +[browser_rules_search-filter-computed-list_expander.js] +[browser_rules_search-filter-media-queries-layers.js] +[browser_rules_search-filter-overridden-property.js] +[browser_rules_search-filter_01.js] +[browser_rules_search-filter_02.js] +[browser_rules_search-filter_03.js] +[browser_rules_search-filter_04.js] +[browser_rules_search-filter_05.js] +[browser_rules_search-filter_06.js] +[browser_rules_search-filter_07.js] +[browser_rules_search-filter_08.js] +[browser_rules_search-filter_09.js] +[browser_rules_search-filter_10.js] +[browser_rules_search-filter_context-menu.js] +[browser_rules_search-filter_escape-keypress.js] +[browser_rules_select-and-copy-styles.js] +[browser_rules_selector-highlighter-iframe-picker.js] +[browser_rules_selector-highlighter-on-navigate.js] +[browser_rules_selector-highlighter_01.js] +[browser_rules_selector-highlighter_02.js] +[browser_rules_selector-highlighter_03.js] +[browser_rules_selector-highlighter_04.js] +[browser_rules_selector-highlighter_05.js] +[browser_rules_selector-highlighter_order.js] +[browser_rules_selector_highlight.js] +[browser_rules_shadowdom_slot_rules.js] +[browser_rules_shapes-toggle_01.js] +[browser_rules_shapes-toggle_02.js] +[browser_rules_shapes-toggle_03.js] +[browser_rules_shapes-toggle_04.js] +[browser_rules_shapes-toggle_05.js] +[browser_rules_shapes-toggle_06.js] +[browser_rules_shapes-toggle_07.js] +fail-if = a11y_checks # bug 1687723 ruleview-shapeswatch is not accessible +[browser_rules_shapes-toggle_basic-shapes-default.js] +[browser_rules_shorthand-overridden-lists.js] +[browser_rules_shorthand-overridden-lists_01.js] +[browser_rules_strict-search-filter-computed-list_01.js] +[browser_rules_strict-search-filter_01.js] +[browser_rules_strict-search-filter_02.js] +[browser_rules_strict-search-filter_03.js] +[browser_rules_style-editor-link.js] +[browser_rules_update_mask_image_cors.js] +[browser_rules_url-click-opens-new-tab.js] +[browser_rules_urls-clickable.js] +[browser_rules_user-agent-styles.js] +[browser_rules_user-agent-styles-uneditable.js] +[browser_rules_user-property-reset.js] +skip-if = + os == "win" && debug # bug 1758768, frequent leaks on win debug diff --git a/devtools/client/inspector/rules/test/browser_rules_add-property-and-reselect.js b/devtools/client/inspector/rules/test/browser_rules_add-property-and-reselect.js new file mode 100644 index 0000000000..d71af090cf --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-property-and-reselect.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that adding properties to rules work and reselecting the element still +// show them. + +const TEST_URI = URL_ROOT + "doc_content_stylesheet.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + await selectNode("#target", inspector); + + info("Setting a font-weight property on all rules"); + await setPropertyOnAllRules(view, inspector); + + info("Reselecting the element"); + await selectNode("body", inspector); + await selectNode("#target", inspector); + + checkPropertyOnAllRules(view); +}); + +async function setPropertyOnAllRules(view, inspector) { + // Set the inline style rule first independently because it needs to wait for specific + // events and the DOM mutation that it causes refreshes the rules view, so we need to + // get the list of rules again later. + info("Adding font-weight:bold in the inline style rule"); + const inlineStyleRuleEditor = view._elementStyle.rules[0].editor; + + const onMutation = inspector.once("markupmutation"); + const onRuleViewRefreshed = view.once("ruleview-refreshed"); + + inlineStyleRuleEditor.addProperty("font-weight", "bold", "", true); + + await Promise.all([onMutation, onRuleViewRefreshed]); + + // Now set the other rules after having retrieved the list. + const allRules = view._elementStyle.rules; + + for (let i = 1; i < allRules.length; i++) { + info(`Adding font-weight:bold in rule ${i}`); + const rule = allRules[i]; + const ruleEditor = rule.editor; + + const onRuleViewChanged = view.once("ruleview-changed"); + + ruleEditor.addProperty("font-weight", "bold", "", true); + + await onRuleViewChanged; + } +} + +function checkPropertyOnAllRules(view) { + for (const rule of view._elementStyle.rules) { + const lastProperty = rule.textProps[rule.textProps.length - 1]; + + is(lastProperty.name, "font-weight", "Last property name is font-weight"); + is(lastProperty.value, "bold", "Last property value is bold"); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_add-property-cancel_01.js b/devtools/client/inspector/rules/test/browser_rules_add-property-cancel_01.js new file mode 100644 index 0000000000..9131da6423 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-property-cancel_01.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests adding a new property and escapes the new empty property name editor. + +const TEST_URI = ` + <style type="text/css"> + #testid { + background-color: blue; + } + .testclass { + background-color: green; + } + </style> + <div id='testid' class='testclass'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const elementRuleEditor = getRuleViewRuleEditor(view, 0); + const editor = await focusNewRuleViewProperty(elementRuleEditor); + is( + inplaceEditor(elementRuleEditor.newPropSpan), + editor, + "The new property editor got focused" + ); + + info("Escape the new property editor"); + const onBlur = once(editor.input, "blur"); + EventUtils.synthesizeKey("VK_ESCAPE", {}, view.styleWindow); + await onBlur; + + info("Checking the state of cancelling a new property name editor"); + is( + elementRuleEditor.rule.textProps.length, + 0, + "Should have cancelled creating a new text property." + ); + ok( + !elementRuleEditor.propertyList.hasChildNodes(), + "Should not have any properties." + ); + + is( + view.styleDocument.activeElement, + view.styleDocument.body, + "Correct element has focus" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_add-property-cancel_02.js b/devtools/client/inspector/rules/test/browser_rules_add-property-cancel_02.js new file mode 100644 index 0000000000..e47023e8f3 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-property-cancel_02.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests adding a new property and escapes the new empty property value editor. + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: blue; + } + </style> + <div id='testid'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + info("Test creating a new property and escaping"); + await addProperty(view, 1, "color", "red", { + commitValueWith: "VK_ESCAPE", + blurNewProperty: false, + }); + + is( + view.styleDocument.activeElement, + view.styleDocument.body, + "Correct element has focus" + ); + + const elementRuleEditor = getRuleViewRuleEditor(view, 1); + is( + elementRuleEditor.rule.textProps.length, + 1, + "Removed the new text property." + ); + is( + elementRuleEditor.propertyList.children.length, + 1, + "Removed the property editor." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_add-property-cancel_03.js b/devtools/client/inspector/rules/test/browser_rules_add-property-cancel_03.js new file mode 100644 index 0000000000..ebaa27ae4c --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-property-cancel_03.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests adding a new property and escapes the property name editor with a +// value. + +const TEST_URI = ` + <style type='text/css'> + div { + background-color: blue; + } + </style> + <div>Test node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + // Add a property to the element's style declaration, add some text, + // then press escape. + + const elementRuleEditor = getRuleViewRuleEditor(view, 1); + const editor = await focusNewRuleViewProperty(elementRuleEditor); + + is( + inplaceEditor(elementRuleEditor.newPropSpan), + editor, + "Next focused editor should be the new property editor." + ); + + EventUtils.sendString("background", view.styleWindow); + + const onBlur = once(editor.input, "blur"); + EventUtils.synthesizeKey("KEY_Escape"); + await onBlur; + + is( + elementRuleEditor.rule.textProps.length, + 1, + "Should have canceled creating a new text property." + ); + is( + view.styleDocument.activeElement, + view.styleDocument.body, + "Correct element has focus" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_add-property-commented.js b/devtools/client/inspector/rules/test/browser_rules_add-property-commented.js new file mode 100644 index 0000000000..eebade7d70 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-property-commented.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that commented properties can be added and are disabled. + +const TEST_URI = "<div id='testid'></div>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testCreateNewSetOfCommentedAndUncommentedProperties(view); +}); + +async function testCreateNewSetOfCommentedAndUncommentedProperties(view) { + info("Test creating a new set of commented and uncommented properties"); + + info("Focusing a new property name in the rule-view"); + const ruleEditor = getRuleViewRuleEditor(view, 0); + const editor = await focusEditableField(view, ruleEditor.closeBrace); + is( + inplaceEditor(ruleEditor.newPropSpan), + editor, + "The new property editor has focus" + ); + + info( + "Entering a commented property/value pair into the property name editor" + ); + const input = editor.input; + input.value = `color: blue; + /* background-color: yellow; */ + width: 200px; + height: 100px; + /* padding-bottom: 1px; */`; + + info("Pressing return to commit and focus the new value field"); + const onModifications = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + await onModifications; + + const textProps = ruleEditor.rule.textProps; + ok(textProps[0].enabled, "The 'color' property is enabled."); + ok(!textProps[1].enabled, "The 'background-color' property is disabled."); + ok(textProps[2].enabled, "The 'width' property is enabled."); + ok(textProps[3].enabled, "The 'height' property is enabled."); + ok(!textProps[4].enabled, "The 'padding-bottom' property is disabled."); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_add-property-invalid-identifier.js b/devtools/client/inspector/rules/test/browser_rules_add-property-invalid-identifier.js new file mode 100644 index 0000000000..e4a4b0fba1 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-property-invalid-identifier.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test adding properties that are invalid identifiers. + +const TEST_URI = "<div id='testid'>Styled Node</div>"; +const TEST_DATA = [ + { name: "1", value: "100" }, + { name: "-1", value: "100" }, + { name: "1a", value: "100" }, + { name: "-11a", value: "100" }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + for (const { name, value } of TEST_DATA) { + info(`Test creating a new property ${name}: ${value}`); + const declaration = await addProperty(view, 0, name, value); + + is(declaration.name, name, "Property name should have been changed."); + is(declaration.value, value, "Property value should have been changed."); + is( + declaration.editor.isValid(), + false, + "The declaration should be invalid." + ); + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_add-property-svg.js b/devtools/client/inspector/rules/test/browser_rules_add-property-svg.js new file mode 100644 index 0000000000..f286d6f427 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-property-svg.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests editing SVG styles using the rules view. + +var TEST_URL = "chrome://devtools/skin/images/alert.svg"; +var TEST_SELECTOR = "path"; + +add_task(async function () { + await addTab(TEST_URL); + const { inspector, view } = await openRuleView(); + await selectNode(TEST_SELECTOR, inspector); + + info("Test creating a new property"); + await addProperty(view, 0, "fill", "red"); + + is( + await getComputedStyleProperty(TEST_SELECTOR, null, "fill"), + "rgb(255, 0, 0)", + "The fill was changed to red" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_add-property_01.js b/devtools/client/inspector/rules/test/browser_rules_add-property_01.js new file mode 100644 index 0000000000..34c6a2066d --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-property_01.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test adding an invalid property. + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: blue; + } + .testclass { + background-color: green; + } + </style> + <div id='testid' class='testclass'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + info("Test creating a new property"); + const textProp = await addProperty(view, 0, "background-color", "#XYZ"); + + is(textProp.value, "#XYZ", "Text prop should have been changed."); + is(textProp.overridden, true, "Property should be overridden"); + is(textProp.editor.isValid(), false, "#XYZ should not be a valid entry"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_add-property_02.js b/devtools/client/inspector/rules/test/browser_rules_add-property_02.js new file mode 100644 index 0000000000..fbe38c92ef --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-property_02.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test adding a valid property to a CSS rule, and navigating through the fields +// by pressing ENTER. + +const TEST_URI = ` + <style type="text/css"> + #testid { + color: blue; + } + </style> + <div id='testid'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + info("Focus the new property name field"); + const ruleEditor = getRuleViewRuleEditor(view, 1); + let editor = await focusNewRuleViewProperty(ruleEditor); + const input = editor.input; + + is( + inplaceEditor(ruleEditor.newPropSpan), + editor, + "Next focused editor should be the new property editor." + ); + ok( + input.selectionStart === 0 && input.selectionEnd === input.value.length, + "Editor contents are selected." + ); + + // Try clicking on the editor's input again, shouldn't cause trouble + // (see bug 761665). + EventUtils.synthesizeMouse(input, 1, 1, {}, view.styleWindow); + input.select(); + + info("Entering the property name"); + editor.input.value = "background-color"; + + info("Pressing RETURN and waiting for the value field focus"); + const onNameAdded = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + + await onNameAdded; + + editor = inplaceEditor(view.styleDocument.activeElement); + + is( + ruleEditor.rule.textProps.length, + 2, + "Should have created a new text property." + ); + is( + ruleEditor.propertyList.children.length, + 2, + "Should have created a property editor." + ); + const textProp = ruleEditor.rule.textProps[1]; + is( + editor, + inplaceEditor(textProp.editor.valueSpan), + "Should be editing the value span now." + ); + + info("Entering the property value"); + const onValueAdded = view.once("ruleview-changed"); + editor.input.value = "purple"; + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + await onValueAdded; + + is(textProp.value, "purple", "Text prop should have been changed."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_add-rule-and-property.js b/devtools/client/inspector/rules/test/browser_rules_add-rule-and-property.js new file mode 100644 index 0000000000..ddb99464e8 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-rule-and-property.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests adding a new rule and a new property in this rule. + +add_task(async function () { + await addTab( + "data:text/html;charset=utf-8,<div id='testid'>Styled Node</div>" + ); + const { inspector, view } = await openRuleView(); + + info("Selecting the test node"); + await selectNode("#testid", inspector); + + info("Adding a new rule for this node and blurring the new selector field"); + await addNewRuleAndDismissEditor(inspector, view, "#testid", 1); + + info("Adding a new property for this rule"); + const ruleEditor = getRuleViewRuleEditor(view, 1); + + const onRuleViewChanged = view.once("ruleview-changed"); + ruleEditor.addProperty("font-weight", "bold", "", true); + await onRuleViewChanged; + + const textProps = ruleEditor.rule.textProps; + const prop = textProps[textProps.length - 1]; + is(prop.name, "font-weight", "The last property name is font-weight"); + is(prop.value, "bold", "The last property value is bold"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_add-rule-and-remove-style-node.js b/devtools/client/inspector/rules/test/browser_rules_add-rule-and-remove-style-node.js new file mode 100644 index 0000000000..1505f70c32 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-rule-and-remove-style-node.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that bug 1736412 is fixed +// We press "add new rule", then we remove the style node +// We then try to press "add new rule again" + +const TEST_URI = '<div id="testid">Test Node</div>'; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await selectNode("#testid", inspector); + await addNewRule(inspector, view); + await testNewRule(view, 1); + await testRemoveStyleNode(); + await addNewRule(inspector, view); + await testNewRule(view, 1); +}); + +function testNewRule(view) { + const ruleEditor = getRuleViewRuleEditor(view, 1); + const editor = ruleEditor.selectorText.ownerDocument.activeElement; + is(editor.value, "#testid", "Selector editor value is as expected"); + info("Escaping from the selector field the change"); + EventUtils.synthesizeKey("KEY_Escape"); +} + +async function testRemoveStyleNode() { + info("Removing the style node from the dom"); + const nbStyleSheets = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + content.document.styleSheets[0].ownerNode.remove(); + return content.document.styleSheets.length; + } + ); + is(nbStyleSheets, 0, "Style node has been removed"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_add-rule-button-state.js b/devtools/client/inspector/rules/test/browser_rules_add-rule-button-state.js new file mode 100644 index 0000000000..578f50c5a8 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-rule-button-state.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests if the `Add rule` button disables itself properly for non-element nodes +// and anonymous element. + +const TEST_URI = ` + <style type="text/css"> + #pseudo::before { + content: "before"; + } + </style> + <div id="pseudo"></div> + <div id="testid">Test Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await testDisabledButton(inspector, view); +}); + +async function testDisabledButton(inspector, view) { + const node = "#testid"; + + info("Selecting a real element"); + await selectNode(node, inspector); + ok(!view.addRuleButton.disabled, "Add rule button should be enabled"); + + info("Select a null element"); + await view.selectElement(null); + ok(view.addRuleButton.disabled, "Add rule button should be disabled"); + + info("Selecting a real element"); + await selectNode(node, inspector); + ok(!view.addRuleButton.disabled, "Add rule button should be enabled"); + + info("Selecting a pseudo element"); + const pseudo = await getNodeFront("#pseudo", inspector); + const children = await inspector.walker.children(pseudo); + const before = children.nodes[0]; + await selectNode(before, inspector); + ok(view.addRuleButton.disabled, "Add rule button should be disabled"); + + info("Selecting a real element"); + await selectNode(node, inspector); + ok(!view.addRuleButton.disabled, "Add rule button should be enabled"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_add-rule-csp.js b/devtools/client/inspector/rules/test/browser_rules_add-rule-csp.js new file mode 100644 index 0000000000..85d91a621d --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-rule-csp.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = ` +<!doctype html> +<html> + <head> + <meta http-equiv="Content-Security-Policy" content="style-src 'none'"> + </head> + <body> + <div id="testid"></div> + </body> +</html> +`; + +// Tests adding a new rule works on a page with CSP style-src none. +add_task(async function () { + await addTab(`data:text/html;charset=utf-8,${encodeURIComponent(TEST_URI)}`); + const { inspector, view } = await openRuleView(); + + info("Selecting the test node"); + await selectNode("#testid", inspector); + + info("Adding a new rule for this node and blurring the new selector field"); + await addNewRuleAndDismissEditor(inspector, view, "#testid", 1); + + info("Adding a new property for this rule"); + const ruleEditor = getRuleViewRuleEditor(view, 1); + + const onRuleViewChanged = view.once("ruleview-changed"); + ruleEditor.addProperty("color", "red", "", true); + await onRuleViewChanged; + + const textProps = ruleEditor.rule.textProps; + const prop = textProps[textProps.length - 1]; + is(prop.name, "color", "The last property name is color"); + is(prop.value, "red", "The last property value is red"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_add-rule-edit-selector.js b/devtools/client/inspector/rules/test/browser_rules_add-rule-edit-selector.js new file mode 100644 index 0000000000..c5323983fc --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-rule-edit-selector.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the behaviour of adding a new rule to the rule view and editing +// its selector. + +const TEST_URI = ` + <style type="text/css"> + #testid { + text-align: center; + } + </style> + <div id="testid">Styled Node</div> + <span>This is a span</span> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + await addNewRule(inspector, view); + await testEditSelector(view, "span"); + + info("Selecting the modified element with the new rule"); + await selectNode("span", inspector); + await checkModifiedElement(view, "span"); +}); + +async function testEditSelector(view, name) { + info("Test editing existing selector field"); + const idRuleEditor = getRuleViewRuleEditor(view, 1); + const editor = idRuleEditor.selectorText.ownerDocument.activeElement; + + info("Entering a new selector name and committing"); + editor.value = name; + + info("Waiting for rule view to update"); + const onRuleViewChanged = once(view, "ruleview-changed"); + + info("Entering the commit key"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + is(view._elementStyle.rules.length, 3, "Should have 3 rules."); + ok(getRuleViewRule(view, name), "Rule with " + name + " selector exists."); +} + +function checkModifiedElement(view, name) { + is(view._elementStyle.rules.length, 2, "Should have 2 rules."); + ok(getRuleViewRule(view, name), "Rule with " + name + " selector exists."); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_add-rule-iframes.js b/devtools/client/inspector/rules/test/browser_rules_add-rule-iframes.js new file mode 100644 index 0000000000..5a46978bb7 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-rule-iframes.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests adding a rule on elements nested in iframes. + +const TEST_URI = `<div>outer</div> + <iframe id="frame1" src="data:text/html;charset=utf-8,<div>inner1</div>"> + </iframe> + <iframe id="frame2" src="data:text/html;charset=utf-8,<div>inner2</div>"> + </iframe>`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + await addNewRuleAndDismissEditor(inspector, view, "div", 1); + await addNewProperty(view, 1, "color", "red"); + + await selectNodeInFrames(["#frame1", "div"], inspector); + await addNewRuleAndDismissEditor(inspector, view, "div", 1); + await addNewProperty(view, 1, "color", "blue"); + + await selectNodeInFrames(["#frame2", "div"], inspector); + await addNewRuleAndDismissEditor(inspector, view, "div", 1); + await addNewProperty(view, 1, "color", "green"); +}); + +/** + * Add a new property in the rule at the provided index in the rule view. + * + * @param {RuleView} view + * @param {Number} index + * The index of the rule in which we should add a new property. + * @param {String} name + * The name of the new property. + * @param {String} value + * The value of the new property. + */ +async function addNewProperty(view, index, name, value) { + const idRuleEditor = getRuleViewRuleEditor(view, index); + info(`Adding new property "${name}: ${value};"`); + + const onRuleViewChanged = view.once("ruleview-changed"); + idRuleEditor.addProperty(name, value, "", true); + await onRuleViewChanged; + + const textProps = idRuleEditor.rule.textProps; + const lastProperty = textProps[textProps.length - 1]; + is(lastProperty.name, name, "Last property has the expected name"); + is(lastProperty.value, value, "Last property has the expected value"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_add-rule-namespace-elements.js b/devtools/client/inspector/rules/test/browser_rules_add-rule-namespace-elements.js new file mode 100644 index 0000000000..ef3a958875 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-rule-namespace-elements.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the behaviour of adding a new rule using the add rule button +// on namespaced elements. + +const XHTML = ` + <!DOCTYPE html> + <html xmlns="http://www.w3.org/1999/xhtml" + xmlns:svg="http://www.w3.org/2000/svg"> + <body> + <svg:svg width="100" height="100"> + <svg:clipPath> + <svg:rect x="0" y="0" width="10" height="5"></svg:rect> + </svg:clipPath> + <svg:circle cx="0" cy="0" r="5"></svg:circle> + </svg:svg> + </body> + </html> +`; +const TEST_URI = "data:application/xhtml+xml;charset=utf-8," + encodeURI(XHTML); + +const TEST_DATA = [ + { node: "clipPath", expected: "clipPath" }, + { node: "rect", expected: "rect" }, + { node: "circle", expected: "circle" }, +]; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + + for (const data of TEST_DATA) { + const { node, expected } = data; + await selectNode(node, inspector); + await addNewRuleAndDismissEditor(inspector, view, expected, 1); + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_add-rule-pseudo-class.js b/devtools/client/inspector/rules/test/browser_rules_add-rule-pseudo-class.js new file mode 100644 index 0000000000..2963da9034 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-rule-pseudo-class.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests adding a rule with pseudo class locks on. + +const TEST_URI = "<p id='element'>Test element</p>"; + +const EXPECTED_SELECTOR = "#element"; +const TEST_DATA = [ + [], + [":hover"], + [":hover", ":active"], + [":hover", ":active", ":focus"], + [":active"], + [":active", ":focus"], + [":focus"], + [":focus-within"], + [":hover", ":focus-within"], + [":hover", ":active", ":focus-within"], +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#element", inspector); + + for (const data of TEST_DATA) { + await runTestData(inspector, view, data); + } +}); + +async function runTestData(inspector, view, pseudoClasses) { + await setPseudoLocks(inspector, view, pseudoClasses); + + const expected = EXPECTED_SELECTOR + pseudoClasses.join(""); + await addNewRuleAndDismissEditor(inspector, view, expected, 1); + + await resetPseudoLocks(inspector, view); +} + +async function setPseudoLocks(inspector, view, pseudoClasses) { + if (!pseudoClasses.length) { + return; + } + + for (const pseudoClass of pseudoClasses) { + const checkbox = getPseudoClassCheckbox(view, pseudoClass); + if (checkbox) { + checkbox.click(); + } + await inspector.once("rule-view-refreshed"); + } +} + +async function resetPseudoLocks(inspector, view) { + for (const checkbox of view.pseudoClassCheckboxes) { + if (checkbox.checked) { + checkbox.click(); + await inspector.once("rule-view-refreshed"); + } + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_add-rule-then-property-edit-selector.js b/devtools/client/inspector/rules/test/browser_rules_add-rule-then-property-edit-selector.js new file mode 100644 index 0000000000..e9647f8351 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-rule-then-property-edit-selector.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the behaviour of adding a new rule to the rule view, adding a new +// property and editing the selector. + +const TEST_URI = ` + <style type="text/css"> + #testid { + text-align: center; + } + </style> + <div id="testid">Styled Node</div> + <span>This is a span</span> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + await addNewRuleAndDismissEditor(inspector, view, "#testid", 1); + + info("Adding a new property to the new rule"); + await testAddingProperty(view, 1); + + info("Editing existing selector field"); + await testEditSelector(view, "span"); + + info("Selecting the modified element"); + await selectNode("span", inspector); + + info("Check new rule and property exist in the modified element"); + await checkModifiedElement(view, "span", 1); +}); + +function testAddingProperty(view, index) { + const ruleEditor = getRuleViewRuleEditor(view, index); + ruleEditor.addProperty("font-weight", "bold", "", true); + const textProps = ruleEditor.rule.textProps; + const lastRule = textProps[textProps.length - 1]; + is(lastRule.name, "font-weight", "Last rule name is font-weight"); + is(lastRule.value, "bold", "Last rule value is bold"); +} + +async function testEditSelector(view, name) { + const idRuleEditor = getRuleViewRuleEditor(view, 1); + + info("Focusing an existing selector name in the rule-view"); + const editor = await focusEditableField(view, idRuleEditor.selectorText); + + is( + inplaceEditor(idRuleEditor.selectorText), + editor, + "The selector editor got focused" + ); + + info("Entering a new selector name: " + name); + editor.input.value = name; + + info("Waiting for rule view to update"); + const onRuleViewChanged = once(view, "ruleview-changed"); + + info("Entering the commit key"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + is(view._elementStyle.rules.length, 3, "Should have 3 rules."); +} + +function checkModifiedElement(view, name, index) { + is(view._elementStyle.rules.length, 2, "Should have 2 rules."); + ok(getRuleViewRule(view, name), "Rule with " + name + " selector exists."); + + const idRuleEditor = getRuleViewRuleEditor(view, index); + const textProps = idRuleEditor.rule.textProps; + const lastRule = textProps[textProps.length - 1]; + is(lastRule.name, "font-weight", "Last rule name is font-weight"); + is(lastRule.value, "bold", "Last rule value is bold"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_add-rule-with-menu.js b/devtools/client/inspector/rules/test/browser_rules_add-rule-with-menu.js new file mode 100644 index 0000000000..b3b23a6ae9 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-rule-with-menu.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the a new CSS rule can be added using the context menu. + +const TEST_URI = '<div id="testid">Test Node</div>'; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await selectNode("#testid", inspector); + await addNewRuleFromContextMenu(inspector, view); + await testNewRule(view); +}); + +async function addNewRuleFromContextMenu(inspector, view) { + info("Waiting for context menu to be shown"); + + const allMenuItems = openStyleContextMenuAndGetAllItems(view, view.element); + const menuitemAddRule = allMenuItems.find( + item => + item.label === + STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.addNewRule") + ); + + ok(menuitemAddRule.visible, "Add rule is visible"); + + info("Adding the new rule and expecting a ruleview-changed event"); + const onRuleViewChanged = view.once("ruleview-changed"); + menuitemAddRule.click(); + await onRuleViewChanged; +} + +function testNewRule(view) { + const ruleEditor = getRuleViewRuleEditor(view, 1); + const editor = ruleEditor.selectorText.ownerDocument.activeElement; + is(editor.value, "#testid", "Selector editor value is as expected"); + + info("Escaping from the selector field the change"); + EventUtils.synthesizeKey("KEY_Escape"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_add-rule.js b/devtools/client/inspector/rules/test/browser_rules_add-rule.js new file mode 100644 index 0000000000..19315e33e2 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_add-rule.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests adding a new rule using the add rule button. + +const TEST_URI = ` + <style type="text/css"> + .testclass { + text-align: center; + } + </style> + <div id="testid" class="testclass">Styled Node</div> + <span class="testclass2">This is a span</span> + <span class="class1 class2">Multiple classes</span> + <span class="class3 class4">Multiple classes</span> + <p>Empty<p> + <h1 class="asd@@@@a!!!!:::@asd">Invalid characters in class</h1> + <h2 id="asd@@@a!!2a">Invalid characters in id</h2> + <svg viewBox="0 0 10 10"> + <circle cx="5" cy="5" r="5" fill="blue"></circle> + </svg> +`; + +const TEST_DATA = [ + { node: "#testid", expected: "#testid" }, + { node: ".testclass2", expected: ".testclass2" }, + { node: ".class1.class2", expected: ".class1.class2" }, + { node: ".class3.class4", expected: ".class3.class4" }, + { node: "p", expected: "p" }, + { node: "h1", expected: ".asd\\@\\@\\@\\@a\\!\\!\\!\\!\\:\\:\\:\\@asd" }, + { node: "h2", expected: "#asd\\@\\@\\@a\\!\\!2a" }, + { node: "circle", expected: "circle" }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + for (const data of TEST_DATA) { + const { node, expected } = data; + await selectNode(node, inspector); + await addNewRuleAndDismissEditor(inspector, view, expected, 1); + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_authored.js b/devtools/client/inspector/rules/test/browser_rules_authored.js new file mode 100644 index 0000000000..406e03d69c --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_authored.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for as-authored styles. + +async function createTestContent(style) { + const html = `<style type="text/css"> + ${style} + </style> + <div id="testid" class="testclass">Styled Node</div>`; + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(html)); + + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + return view; +} + +add_task(async function () { + const view = await createTestContent( + "#testid {" + + // Invalid property. + " something: random;" + + // Invalid value. + " color: orang;" + + // Override. + " background-color: blue;" + + " background-color: #f06;" + + "} " + ); + + const elementStyle = view._elementStyle; + + const expected = [ + { name: "something", overridden: true, isNameValid: false, isValid: false }, + { name: "color", overridden: true, isNameValid: true, isValid: false }, + { + name: "background-color", + overridden: true, + isNameValid: true, + isValid: true, + }, + { + name: "background-color", + overridden: false, + isNameValid: true, + isValid: true, + }, + ]; + + const rule = elementStyle.rules[1]; + + for (let i = 0; i < expected.length; ++i) { + const prop = rule.textProps[i]; + is(prop.name, expected[i].name, "Check name for prop " + i); + is( + prop.overridden, + expected[i].overridden, + "Check overridden for prop " + i + ); + is( + prop.isNameValid(), + expected[i].isNameValid, + "Check if property name is valid for prop " + i + ); + is( + prop.isValid(), + expected[i].isValid, + "Check if whole declaration is valid for prop " + i + ); + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_authored_color.js b/devtools/client/inspector/rules/test/browser_rules_authored_color.js new file mode 100644 index 0000000000..ea163d509d --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_authored_color.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for as-authored color styles. + +/** + * Array of test color objects: + * {String} name: name of the used & expected color format. + * {String} id: id of the element that will be created to test this color. + * {String} color: initial value of the color property applied to the test element. + * {String} result: expected value of the color property after edition. + */ +const colors = [ + { name: "hex", id: "test1", color: "#f06", result: "#0f0" }, + { + name: "rgb", + id: "test2", + color: "rgb(0,128,250)", + result: "rgb(0, 255, 0)", + }, + // Test case preservation. + { name: "hex", id: "test3", color: "#F06", result: "#0F0" }, +]; + +add_task(async function () { + Services.prefs.setCharPref("devtools.defaultColorUnit", "authored"); + + let html = ""; + for (const { color, id } of colors) { + html += `<div id="${id}" style="color: ${color}">Styled Node</div>`; + } + + const tab = await addTab( + "data:text/html;charset=utf-8," + encodeURIComponent(html) + ); + + const { inspector, view } = await openRuleView(); + + for (const color of colors) { + const selector = "#" + color.id; + await selectNode(selector, inspector); + + const swatch = getRuleViewProperty( + view, + "element", + "color" + ).valueSpan.querySelector(".ruleview-colorswatch"); + const cPicker = view.tooltips.getTooltip("colorPicker"); + const onColorPickerReady = cPicker.once("ready"); + swatch.click(); + await onColorPickerReady; + + await simulateColorPickerChange(view, cPicker, [0, 255, 0, 1], { + selector, + name: "color", + value: "rgb(0, 255, 0)", + }); + + const spectrum = cPicker.spectrum; + const onHidden = cPicker.tooltip.once("hidden"); + // Validating the color change ends up updating the rule view twice + const onRuleViewChanged = waitForNEvents(view, "ruleview-changed", 2); + focusAndSendKey(spectrum.element.ownerDocument.defaultView, "RETURN"); + await onHidden; + await onRuleViewChanged; + + is( + getRuleViewPropertyValue(view, "element", "color"), + color.result, + "changing the color preserved the unit for " + color.name + ); + } + + await gDevTools.closeToolboxForTab(tab); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_authored_override.js b/devtools/client/inspector/rules/test/browser_rules_authored_override.js new file mode 100644 index 0000000000..d8067d5dc2 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_authored_override.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for as-authored styles. + +async function createTestContent(style) { + const html = `<style type="text/css"> + ${style} + </style> + <div id="testid" class="testclass">Styled Node</div>`; + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(html)); + + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + return view; +} + +add_task(async function () { + const gradientText1 = "(orange, blue);"; + const gradientText2 = "(pink, teal);"; + + const view = await createTestContent( + "#testid {" + + " background-image: linear-gradient" + + gradientText1 + + " background-image: -ms-linear-gradient" + + gradientText2 + + " background-image: linear-gradient" + + gradientText2 + + "} " + ); + + const elementStyle = view._elementStyle; + const rule = elementStyle.rules[1]; + + // Initially the last property should be active. + for (let i = 0; i < 3; ++i) { + const prop = rule.textProps[i]; + is(prop.name, "background-image", "check the property name"); + is(prop.overridden, i !== 2, "check overridden for " + i); + } + + await togglePropStatus(view, rule.textProps[2]); + + // Now the first property should be active. + for (let i = 0; i < 3; ++i) { + const prop = rule.textProps[i]; + is( + prop.overridden || !prop.enabled, + i !== 0, + "post-change check overridden for " + i + ); + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_blob_stylesheet.js b/devtools/client/inspector/rules/test/browser_rules_blob_stylesheet.js new file mode 100644 index 0000000000..4042695eaa --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_blob_stylesheet.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the rule-view content is correct for stylesheet generated +// with createObjectURL(cssBlob) +const TEST_URL = URL_ROOT + "doc_blob_stylesheet.html"; + +add_task(async function () { + await addTab(TEST_URL); + const { inspector, view } = await openRuleView(); + + await selectNode("h1", inspector); + is( + view.element.querySelectorAll("#noResults").length, + 0, + "The no-results element is not displayed" + ); + + is( + view.element.querySelectorAll(".ruleview-rule").length, + 2, + "There are 2 displayed rules" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_class_panel_add.js b/devtools/client/inspector/rules/test/browser_rules_class_panel_add.js new file mode 100644 index 0000000000..dfba3a63bf --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_class_panel_add.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that classes can be added in the class panel + +// This array contains the list of test cases. Each test case contains these properties: +// - {String} textEntered The text to be entered in the field +// - {Boolean} expectNoMutation Set to true if we shouldn't wait for a DOM mutation +// - {Array} expectedClasses The expected list of classes to be applied to the DOM and to +// be found in the class panel +const TEST_ARRAY = [ + { + textEntered: "", + expectNoMutation: true, + expectedClasses: [], + }, + { + textEntered: "class", + expectedClasses: ["class"], + }, + { + textEntered: "class", + expectNoMutation: true, + expectedClasses: ["class"], + }, + { + textEntered: "a a a a a a a a a a", + expectedClasses: ["class", "a"], + }, + { + textEntered: "class2 class3", + expectedClasses: ["class", "a", "class2", "class3"], + }, + { + textEntered: " ", + expectNoMutation: true, + expectedClasses: ["class", "a", "class2", "class3"], + }, + { + textEntered: " class4", + expectedClasses: ["class", "a", "class2", "class3", "class4"], + }, + { + textEntered: " \t class5 \t \t\t ", + expectedClasses: ["class", "a", "class2", "class3", "class4", "class5"], + }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8,"); + const { inspector, view } = await openRuleView(); + + info("Open the class panel"); + view.showClassPanel(); + + const textField = inspector.panelDoc.querySelector( + "#ruleview-class-panel .add-class" + ); + ok(textField, "The input field exists in the class panel"); + + textField.focus(); + + let onMutation; + for (const { textEntered, expectNoMutation, expectedClasses } of TEST_ARRAY) { + if (!expectNoMutation) { + onMutation = inspector.once("markupmutation"); + } + + info(`Enter the test string in the field: ${textEntered}`); + for (const key of textEntered.split("")) { + const onPreviewMutation = inspector.once("markupmutation"); + EventUtils.synthesizeKey(key, {}, view.styleWindow); + await onPreviewMutation; + } + + info("Submit the change and wait for the textfield to become empty"); + const onEmpty = waitForFieldToBeEmpty(textField); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + + if (!expectNoMutation) { + info("Wait for the DOM to change"); + await onMutation; + } + + await onEmpty; + + info("Check the state of the DOM node"); + const className = await getContentPageElementAttribute("body", "class"); + const expectedClassName = expectedClasses.length + ? expectedClasses.join(" ") + : null; + is(className, expectedClassName, "The DOM node has the right className"); + + info("Check the content of the class panel"); + checkClassPanelContent( + view, + expectedClasses.map(name => { + return { name, state: true }; + }) + ); + } +}); + +function waitForFieldToBeEmpty(textField) { + return waitForSuccess(() => !textField.value); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_class_panel_autocomplete.js b/devtools/client/inspector/rules/test/browser_rules_class_panel_autocomplete.js new file mode 100644 index 0000000000..9cd7993244 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_class_panel_autocomplete.js @@ -0,0 +1,266 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the autocomplete for the class panel input behaves as expected. The test also +// checks that we're using the cache to retrieve the data when we can do so, and that the +// cache gets cleared, and we're getting data from the server, when there's mutation on +// the page. + +const TEST_URI = `${URL_ROOT}doc_class_panel_autocomplete.html`; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + const { addEl: textInput } = view.classListPreviewer; + await selectNode("#auto-div-id-3", inspector); + + info("Open the class panel"); + view.showClassPanel(); + + textInput.focus(); + + info("Type a letter and check that the popup has the expected items"); + const allClasses = [ + "auto-body-class-1", + "auto-body-class-2", + "auto-bold", + "auto-cssom-primary-color", + "auto-div-class-1", + "auto-div-class-2", + "auto-html-class-1", + "auto-html-class-2", + "auto-inline-class-1", + "auto-inline-class-2", + "auto-inline-class-3", + "auto-inline-class-4", + "auto-inline-class-5", + "auto-stylesheet-class-1", + "auto-stylesheet-class-2", + "auto-stylesheet-class-3", + "auto-stylesheet-class-4", + "auto-stylesheet-class-5", + ]; + + const { autocompletePopup } = view.classListPreviewer; + let onPopupOpened = autocompletePopup.once("popup-opened"); + EventUtils.synthesizeKey("a", {}, view.styleWindow); + await waitForClassApplied("auto-body-class-1", "#auto-div-id-3"); + await onPopupOpened; + await checkAutocompleteItems( + autocompletePopup, + allClasses, + "The autocomplete popup has all the classes used in the DOM and in stylesheets" + ); + + info( + "Test that typing more letters filters the autocomplete popup and uses the cache mechanism" + ); + EventUtils.sendString("uto-b", view.styleWindow); + await waitForClassApplied("auto-body-class-1", "#auto-div-id-3"); + + await checkAutocompleteItems( + autocompletePopup, + allClasses.filter(cls => cls.startsWith("auto-b")), + "The autocomplete popup was filtered with the content of the input" + ); + ok(true, "The results were retrieved from the cache mechanism"); + + info("Test that autocomplete shows up-to-date results"); + // Modify the content page and assert that the new class is displayed in the + // autocomplete if the user types a new letter. + const onNewMutation = inspector.inspectorFront.walker.once("new-mutations"); + await ContentTask.spawn(gBrowser.selectedBrowser, null, async function () { + content.document.body.classList.add("auto-body-added-by-script"); + }); + await onNewMutation; + await waitForClassApplied("auto-body-added-by-script", "body"); + + // close & reopen the autocomplete so it picks up the added to another element while autocomplete was opened + let onPopupClosed = autocompletePopup.once("popup-closed"); + EventUtils.synthesizeKey("KEY_Escape", {}, view.styleWindow); + await onPopupClosed; + + // input is now auto-body + onPopupOpened = autocompletePopup.once("popup-opened"); + EventUtils.sendString("ody", view.styleWindow); + await onPopupOpened; + await checkAutocompleteItems( + autocompletePopup, + [ + ...allClasses.filter(cls => cls.startsWith("auto-body")), + "auto-body-added-by-script", + ].sort(), + "The autocomplete popup was filtered with the content of the input" + ); + + info( + "Test that typing a letter that won't match any of the item closes the popup" + ); + // input is now auto-bodyy + onPopupClosed = autocompletePopup.once("popup-closed"); + EventUtils.synthesizeKey("y", {}, view.styleWindow); + await waitForClassApplied("auto-bodyy", "#auto-div-id-3"); + await onPopupClosed; + ok(true, "The popup was closed as expected"); + await checkAutocompleteItems(autocompletePopup, [], "The popup was cleared"); + + info("Clear the input and try to autocomplete again"); + textInput.select(); + EventUtils.synthesizeKey("KEY_Backspace", {}, view.styleWindow); + // Wait a bit so the debounced function can be executed + await wait(200); + + onPopupOpened = autocompletePopup.once("popup-opened"); + EventUtils.synthesizeKey("a", {}, view.styleWindow); + await onPopupOpened; + + await checkAutocompleteItems( + autocompletePopup, + [...allClasses, "auto-body-added-by-script"].sort(), + "The autocomplete popup was updated with the new class added to the DOM" + ); + + info("Test keyboard shortcut when the popup is displayed"); + // Escape to hide + onPopupClosed = autocompletePopup.once("popup-closed"); + EventUtils.synthesizeKey("KEY_Escape", {}, view.styleWindow); + await onPopupClosed; + ok(true, "The popup was closed when hitting escape"); + + // Ctrl + space to show again + onPopupOpened = autocompletePopup.once("popup-opened"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, view.styleWindow); + await onPopupOpened; + ok(true, "Popup was opened again with Ctrl+Space"); + await checkAutocompleteItems( + autocompletePopup, + [...allClasses, "auto-body-added-by-script"].sort() + ); + + // Arrow left to hide + onPopupClosed = autocompletePopup.once("popup-closed"); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, view.styleWindow); + await onPopupClosed; + ok(true, "The popup was closed as when hitting ArrowLeft"); + + // Arrow right and Ctrl + space to show again, and Arrow Right to accept + onPopupOpened = autocompletePopup.once("popup-opened"); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, view.styleWindow); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, view.styleWindow); + await onPopupOpened; + + onPopupClosed = autocompletePopup.once("popup-closed"); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, view.styleWindow); + await waitForClassApplied("auto-body-added-by-script", "#auto-div-id-3"); + await onPopupClosed; + is( + textInput.value, + "auto-body-added-by-script", + "ArrowRight puts the selected item in the input and closes the popup" + ); + + // Backspace to show the list again + onPopupOpened = autocompletePopup.once("popup-opened"); + EventUtils.synthesizeKey("KEY_Backspace", {}, view.styleWindow); + await waitForClassApplied("auto-body-added-by-script", "#auto-div-id-3"); + await onPopupOpened; + is( + textInput.value, + "auto-body-added-by-scrip", + "ArrowRight puts the selected item in the input and closes the popup" + ); + await checkAutocompleteItems( + autocompletePopup, + ["auto-body-added-by-script"], + "The autocomplete does show the matching items after hitting backspace" + ); + + // Enter to accept + onPopupClosed = autocompletePopup.once("popup-closed"); + EventUtils.synthesizeKey("KEY_Enter", {}, view.styleWindow); + await waitForClassRemoved("auto-body-added-by-scrip"); + await onPopupClosed; + is( + textInput.value, + "auto-body-added-by-script", + "Enter puts the selected item in the input and closes the popup" + ); + + // Backspace to show again + onPopupOpened = autocompletePopup.once("popup-opened"); + EventUtils.synthesizeKey("KEY_Backspace", {}, view.styleWindow); + await waitForClassApplied("auto-body-added-by-script", "#auto-div-id-3"); + await onPopupOpened; + is( + textInput.value, + "auto-body-added-by-scrip", + "ArrowRight puts the selected item in the input and closes the popup" + ); + await checkAutocompleteItems( + autocompletePopup, + ["auto-body-added-by-script"], + "The autocomplete does show the matching items after hitting backspace" + ); + + // Tab to accept + onPopupClosed = autocompletePopup.once("popup-closed"); + EventUtils.synthesizeKey("KEY_Tab", {}, view.styleWindow); + await onPopupClosed; + is( + textInput.value, + "auto-body-added-by-script", + "Tab puts the selected item in the input and closes the popup" + ); + await waitForClassRemoved("auto-body-added-by-scrip"); +}); + +async function checkAutocompleteItems( + autocompletePopup, + expectedItems, + assertionMessage +) { + await waitForSuccess( + () => + getAutocompleteItems(autocompletePopup).length === expectedItems.length + ); + const items = getAutocompleteItems(autocompletePopup); + const formatList = list => `\n${list.join("\n")}\n`; + is(formatList(items), formatList(expectedItems), assertionMessage); +} + +function getAutocompleteItems(autocompletePopup) { + return Array.from(autocompletePopup._panel.querySelectorAll("li")).map( + el => el.textContent + ); +} + +async function waitForClassApplied(cls, selector) { + info("Wait for class to be applied: " + cls); + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [cls, selector], + async (_cls, _selector) => { + return ContentTaskUtils.waitForCondition(() => + content.document.querySelector(_selector).classList.contains(_cls) + ); + } + ); + // Wait for debounced functions to be executed + await wait(200); +} + +async function waitForClassRemoved(cls) { + info("Wait for class to be removed: " + cls); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [cls], async _cls => { + return ContentTaskUtils.waitForCondition( + () => + !content.document + .querySelector("#auto-div-id-3") + .classList.contains(_cls) + ); + }); + // Wait for debounced functions to be executed + await wait(200); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_class_panel_content.js b/devtools/client/inspector/rules/test/browser_rules_class_panel_content.js new file mode 100644 index 0000000000..52d490b1d0 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_class_panel_content.js @@ -0,0 +1,124 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that class panel shows the right content when selecting various nodes. + +// This array contains the list of test cases. Each test case contains these properties: +// - {String} inputClassName The className on a node +// - {Array} expectedClasses The expected list of classes in the class panel +const TEST_ARRAY = [ + { + inputClassName: "", + expectedClasses: [], + }, + { + inputClassName: " a a a a a a a a a", + expectedClasses: ["a"], + }, + { + inputClassName: "c1 c2 c3 c4 c5", + expectedClasses: ["c1", "c2", "c3", "c4", "c5"], + }, + { + inputClassName: "a a b b c c a a b b c c", + expectedClasses: ["a", "b", "c"], + }, + { + inputClassName: + "ajdhfkasjhdkjashdkjghaskdgkauhkbdhvliashdlghaslidghasldgliashdglhasli", + expectedClasses: [ + "ajdhfkasjhdkjashdkjghaskdgkauhkbdhvliashdlghaslidghasldgliashdglhasli", + ], + }, + { + inputClassName: + "c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 " + + "c10 c11 c12 c13 c14 c15 c16 c17 c18 c19 " + + "c20 c21 c22 c23 c24 c25 c26 c27 c28 c29 " + + "c30 c31 c32 c33 c34 c35 c36 c37 c38 c39 " + + "c40 c41 c42 c43 c44 c45 c46 c47 c48 c49", + expectedClasses: [ + "c0", + "c1", + "c2", + "c3", + "c4", + "c5", + "c6", + "c7", + "c8", + "c9", + "c10", + "c11", + "c12", + "c13", + "c14", + "c15", + "c16", + "c17", + "c18", + "c19", + "c20", + "c21", + "c22", + "c23", + "c24", + "c25", + "c26", + "c27", + "c28", + "c29", + "c30", + "c31", + "c32", + "c33", + "c34", + "c35", + "c36", + "c37", + "c38", + "c39", + "c40", + "c41", + "c42", + "c43", + "c44", + "c45", + "c46", + "c47", + "c48", + "c49", + ], + }, + { + inputClassName: " \n \n class1 \t class2 \t\tclass3\t", + expectedClasses: ["class1", "class2", "class3"], + }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8,<div>"); + const { inspector, view } = await openRuleView(); + + await selectNode("div", inspector); + + info("Open the class panel"); + view.showClassPanel(); + + for (const { inputClassName, expectedClasses } of TEST_ARRAY) { + info(`Apply the '${inputClassName}' className to the node`); + const onMutation = inspector.once("markupmutation"); + await setContentPageElementAttribute("div", "class", inputClassName); + await onMutation; + + info("Check the content of the class panel"); + checkClassPanelContent( + view, + expectedClasses.map(name => { + return { name, state: true }; + }) + ); + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_class_panel_edit.js b/devtools/client/inspector/rules/test/browser_rules_class_panel_edit.js new file mode 100644 index 0000000000..f43a5fd487 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_class_panel_edit.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that classes can be toggled in the class panel + +add_task(async function () { + await addTab("data:text/html;charset=utf-8,<body class='class1 class2'>"); + const { view } = await openRuleView(); + + info("Open the class panel"); + view.showClassPanel(); + + info( + "Click on class1 and check that the checkbox is unchecked and the DOM is updated" + ); + await toggleClassPanelCheckBox(view, "class1"); + checkClassPanelContent(view, [ + { name: "class1", state: false }, + { name: "class2", state: true }, + ]); + let newClassName = await getContentPageElementAttribute("body", "class"); + is(newClassName, "class2", "The class attribute has been updated in the DOM"); + + info("Click on class2 and check the same thing"); + await toggleClassPanelCheckBox(view, "class2"); + checkClassPanelContent(view, [ + { name: "class1", state: false }, + { name: "class2", state: false }, + ]); + newClassName = await getContentPageElementAttribute("body", "class"); + is(newClassName, "", "The class attribute has been updated in the DOM"); + + info("Click on class2 and checks that the class is added again"); + await toggleClassPanelCheckBox(view, "class2"); + checkClassPanelContent(view, [ + { name: "class1", state: false }, + { name: "class2", state: true }, + ]); + newClassName = await getContentPageElementAttribute("body", "class"); + is(newClassName, "class2", "The class attribute has been updated in the DOM"); + + info("And finally, click on class1 again and checks it is added again"); + await toggleClassPanelCheckBox(view, "class1"); + checkClassPanelContent(view, [ + { name: "class1", state: true }, + { name: "class2", state: true }, + ]); + newClassName = await getContentPageElementAttribute("body", "class"); + is( + newClassName, + "class1 class2", + "The class attribute has been updated in the DOM" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_class_panel_invalid_nodes.js b/devtools/client/inspector/rules/test/browser_rules_class_panel_invalid_nodes.js new file mode 100644 index 0000000000..24a0dfbcfc --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_class_panel_invalid_nodes.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the class panel shows a message when invalid nodes are selected. +// text nodes, pseudo-elements, DOCTYPE, comment nodes. + +add_task(async function () { + await addTab(`data:text/html;charset=utf-8, + <body> + <style>div::after {content: "test";}</style> + <!-- comment --> + Some text + <div></div> + </body>`); + + info("Open the class panel"); + const { inspector, view } = await openRuleView(); + view.showClassPanel(); + + info("Selecting the DOCTYPE node"); + const { nodes } = await inspector.walker.children(inspector.walker.rootNode); + await selectNode(nodes[0], inspector); + checkMessageIsDisplayed(view); + + info("Selecting the comment node"); + const styleNode = await getNodeFront("style", inspector); + const commentNode = await inspector.walker.nextSibling(styleNode); + await selectNode(commentNode, inspector); + checkMessageIsDisplayed(view); + + info("Selecting the text node"); + const textNode = await inspector.walker.nextSibling(commentNode); + await selectNode(textNode, inspector); + checkMessageIsDisplayed(view); + + info("Selecting the ::after pseudo-element"); + const divNode = await getNodeFront("div", inspector); + const pseudoElement = (await inspector.walker.children(divNode)).nodes[0]; + await selectNode(pseudoElement, inspector); + checkMessageIsDisplayed(view); +}); + +function checkMessageIsDisplayed(view) { + ok( + view.classListPreviewer.classesEl.querySelector(".no-classes"), + "The message is displayed" + ); + checkClassPanelContent(view, []); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_class_panel_mutation.js b/devtools/client/inspector/rules/test/browser_rules_class_panel_mutation.js new file mode 100644 index 0000000000..4d3340b2ea --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_class_panel_mutation.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that class panel updates on markup mutations + +add_task(async function () { + await addTab("data:text/html;charset=utf-8,<div class='c1 c2'>"); + const { inspector, view } = await openRuleView(); + + await selectNode("div", inspector); + + info("Open the class panel"); + view.showClassPanel(); + + info("Trigger an unrelated mutation on the div (id attribute change)"); + let onMutation = view.inspector.once("markupmutation"); + await setContentPageElementAttribute("div", "id", "test-id"); + await onMutation; + + info("Check that the panel still contains the right classes"); + checkClassPanelContent(view, [ + { name: "c1", state: true }, + { name: "c2", state: true }, + ]); + + info("Trigger a class mutation on a different, unknown, node"); + onMutation = view.inspector.once("markupmutation"); + await setContentPageElementAttribute("body", "class", "test-class"); + await onMutation; + + info("Check that the panel still contains the right classes"); + checkClassPanelContent(view, [ + { name: "c1", state: true }, + { name: "c2", state: true }, + ]); + + info("Trigger a class mutation on the current node"); + onMutation = view.inspector.once("markupmutation"); + await setContentPageElementAttribute("div", "class", "c3 c4"); + await onMutation; + + info("Check that the panel now contains the new classes"); + checkClassPanelContent(view, [ + { name: "c3", state: true }, + { name: "c4", state: true }, + ]); + + info("Change the state of one of the new classes"); + await toggleClassPanelCheckBox(view, "c4"); + checkClassPanelContent(view, [ + { name: "c3", state: true }, + { name: "c4", state: false }, + ]); + + info("Select another node"); + await selectNode("body", inspector); + + info("Trigger a class mutation on the div"); + onMutation = view.inspector.once("markupmutation"); + await setContentPageElementAttribute("div", "class", "c5 c6 c7"); + await onMutation; + + info( + "Go back to the previous node and check the content of the class panel." + + "Even if hidden, it should have refreshed when we changed the DOM" + ); + await selectNode("div", inspector); + checkClassPanelContent(view, [ + { name: "c5", state: true }, + { name: "c6", state: true }, + { name: "c7", state: true }, + ]); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_class_panel_state_preserved.js b/devtools/client/inspector/rules/test/browser_rules_class_panel_state_preserved.js new file mode 100644 index 0000000000..c4c20e3976 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_class_panel_state_preserved.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that class states are preserved when switching to other nodes + +add_task(async function () { + await addTab( + "data:text/html;charset=utf-8,<body class='class1 class2 class3'><div>" + ); + const { inspector, view } = await openRuleView(); + + info("Open the class panel"); + view.showClassPanel(); + + info("With the <body> selected, uncheck class2 and class3 in the panel"); + await toggleClassPanelCheckBox(view, "class2"); + await toggleClassPanelCheckBox(view, "class3"); + + info("Now select the <div> so the panel gets refreshed"); + await selectNode("div", inspector); + is( + view.classPanel.querySelectorAll("[type=checkbox]").length, + 0, + "The panel content doesn't contain any checkboxes anymore" + ); + + info("Select the <body> again"); + await selectNode("body", inspector); + const checkBoxes = view.classPanel.querySelectorAll("[type=checkbox]"); + + is(checkBoxes[0].dataset.name, "class1", "The first checkbox is class1"); + is(checkBoxes[0].checked, true, "The first checkbox is still checked"); + + is(checkBoxes[1].dataset.name, "class2", "The second checkbox is class2"); + is(checkBoxes[1].checked, false, "The second checkbox is still unchecked"); + + is(checkBoxes[2].dataset.name, "class3", "The third checkbox is class3"); + is(checkBoxes[2].checked, false, "The third checkbox is still unchecked"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_class_panel_toggle.js b/devtools/client/inspector/rules/test/browser_rules_class_panel_toggle.js new file mode 100644 index 0000000000..42b411481f --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_class_panel_toggle.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 class panel can be toggled. + +add_task(async function () { + await addTab("data:text/html;charset=utf-8,<body class='class1 class2'>"); + const { inspector, view } = await openRuleView(); + + info("Check that the toggle button exists"); + const button = inspector.panelDoc.querySelector("#class-panel-toggle"); + ok(button, "The class panel toggle button exists"); + is(view.classToggle, button, "The rule-view refers to the right element"); + + info("Check that the panel exists and is hidden by default"); + const panel = inspector.panelDoc.querySelector("#ruleview-class-panel"); + ok(panel, "The class panel exists"); + is(view.classPanel, panel, "The rule-view refers to the right element"); + ok(panel.hasAttribute("hidden"), "The panel is hidden"); + + info("Click on the button to show the panel"); + button.click(); + ok(!panel.hasAttribute("hidden"), "The panel is shown"); + ok(button.classList.contains("checked"), "The button is checked"); + + info("Click again to hide the panel"); + button.click(); + ok(panel.hasAttribute("hidden"), "The panel is hidden"); + ok(!button.classList.contains("checked"), "The button is unchecked"); + + info("Open the pseudo-class panel first, then the class panel"); + view.pseudoClassToggle.click(); + ok( + !view.pseudoClassPanel.hasAttribute("hidden"), + "The pseudo-class panel is shown" + ); + button.click(); + ok(!panel.hasAttribute("hidden"), "The panel is shown"); + ok( + view.pseudoClassPanel.hasAttribute("hidden"), + "The pseudo-class panel is hidden" + ); + + info("Click again on the pseudo-class button"); + view.pseudoClassToggle.click(); + ok(panel.hasAttribute("hidden"), "The panel is hidden"); + ok( + !view.pseudoClassPanel.hasAttribute("hidden"), + "The pseudo-class panel is shown" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_colorUnit.js b/devtools/client/inspector/rules/test/browser_rules_colorUnit.js new file mode 100644 index 0000000000..d7d92f8a8f --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorUnit.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that color selection respects the user pref. + +const TEST_URI = ` + <style type='text/css'> + #testid { + color: blue; + } + </style> + <div id='testid' class='testclass'>Styled Node</div> +`; + +add_task(async function () { + const TESTS = [ + { name: "hex", result: "#0f0" }, + { name: "rgb", result: "rgb(0, 255, 0)" }, + ]; + + for (const { name, result } of TESTS) { + info("starting test for " + name); + Services.prefs.setCharPref("devtools.defaultColorUnit", name); + + const tab = await addTab( + "data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI) + ); + const { inspector, view } = await openRuleView(); + + await selectNode("#testid", inspector); + await basicTest(view, name, result); + + await gDevTools.closeToolboxForTab(tab); + gBrowser.removeCurrentTab(); + } +}); + +async function basicTest(view, name, result) { + const cPicker = view.tooltips.getTooltip("colorPicker"); + const swatch = getRuleViewProperty( + view, + "#testid", + "color" + ).valueSpan.querySelector(".ruleview-colorswatch"); + const onColorPickerReady = cPicker.once("ready"); + swatch.click(); + await onColorPickerReady; + + await simulateColorPickerChange(view, cPicker, [0, 255, 0, 1], { + selector: "#testid", + name: "color", + value: "rgb(0, 255, 0)", + }); + + const spectrum = cPicker.spectrum; + const onHidden = cPicker.tooltip.once("hidden"); + // Validating the color change ends up updating the rule view twice + const onRuleViewChanged = waitForNEvents(view, "ruleview-changed", 2); + focusAndSendKey(spectrum.element.ownerDocument.defaultView, "RETURN"); + await onHidden; + await onRuleViewChanged; + + is( + getRuleViewPropertyValue(view, "#testid", "color"), + result, + "changing the color used the " + name + " unit" + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_color_scheme_simulation.js b/devtools/client/inspector/rules/test/browser_rules_color_scheme_simulation.js new file mode 100644 index 0000000000..172acebcaf --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_color_scheme_simulation.js @@ -0,0 +1,159 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test color scheme simulation. +const TEST_URI = URL_ROOT_SSL + "doc_media_queries.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view, toolbox } = await openRuleView(); + + info("Check that the color scheme simulation buttons exist"); + const lightButton = inspector.panelDoc.querySelector( + "#color-scheme-simulation-light-toggle" + ); + const darkButton = inspector.panelDoc.querySelector( + "#color-scheme-simulation-dark-toggle" + ); + ok(lightButton, "The light color scheme simulation button exists"); + ok(darkButton, "The dark color scheme simulation button exists"); + + is( + isButtonChecked(lightButton), + false, + "At first, the light button isn't checked" + ); + is( + isButtonChecked(darkButton), + false, + "At first, the dark button isn't checked" + ); + + // Define functions checking if the rule view display the expected property. + const divHasDefaultStyling = async () => + (await getPropertiesForRuleIndex(view, 1)).has("background-color:yellow"); + const divHasDarkSchemeStyling = async () => + (await getPropertiesForRuleIndex(view, 1)).has("background-color:darkblue"); + const iframeElHasDefaultStyling = async () => + (await getPropertiesForRuleIndex(view, 1)).has("background:cyan"); + const iframeHasDarkSchemeStyling = async () => + (await getPropertiesForRuleIndex(view, 1)).has("background:darkred"); + + info( + "Select the div that will change according to conditions in prefered color scheme" + ); + await selectNode("div", inspector); + ok( + await divHasDefaultStyling(), + "The rule view shows the expected initial rule" + ); + + info("Click on the dark button"); + darkButton.click(); + await waitFor(() => isButtonChecked(darkButton)); + ok(true, "The dark button is checked"); + is( + isButtonChecked(lightButton), + false, + "the light button state didn't change when enabling dark mode" + ); + + await waitFor(() => divHasDarkSchemeStyling()); + is( + getRuleViewAncestorRulesDataTextByIndex(view, 1), + "@media (prefers-color-scheme: dark)", + "The rules view was updated with the rule from the dark scheme media query" + ); + + info("Select the node from the remote iframe"); + await selectNodeInFrames(["iframe", "html"], inspector); + + ok( + await iframeHasDarkSchemeStyling(), + "The simulation is also applied on the remote iframe" + ); + is( + getRuleViewAncestorRulesDataTextByIndex(view, 1), + "@media (prefers-color-scheme: dark)", + "The prefers-color-scheme media query is displayed" + ); + + info("Select the top level div again"); + await selectNode("div", inspector); + + info("Click the light button simulate light mode"); + lightButton.click(); + await waitFor(() => isButtonChecked(lightButton)); + ok(true, "The button has the expected light state"); + // TODO: Actually simulate light mode. This might require to set the OS-level preference + // to dark as the default state might consume the rule from the like scheme media query. + + is( + isButtonChecked(darkButton), + false, + "the dark button was unchecked when enabling light mode" + ); + + await waitFor(() => divHasDefaultStyling()); + + info("Click the light button to disable simulation"); + lightButton.click(); + await waitFor(() => !isButtonChecked(lightButton)); + ok(true, "The button isn't checked anymore"); + await waitFor(() => divHasDefaultStyling()); + ok(true, "We're not simulating color-scheme anymore"); + + info("Select the node from the remote iframe again"); + await selectNodeInFrames(["iframe", "html"], inspector); + await waitFor(() => iframeElHasDefaultStyling()); + ok(true, "The simulation stopped on the remote iframe as well"); + + info("Check that reloading keep the selected simulation"); + await selectNode("div", inspector); + darkButton.click(); + await waitFor(() => divHasDarkSchemeStyling()); + + await navigateTo(TEST_URI); + await selectNode("div", inspector); + await waitFor(() => view.element.children[1]); + ok( + await divHasDarkSchemeStyling(), + "dark mode is still simulated after reloading the page" + ); + is( + getRuleViewAncestorRulesDataTextByIndex(view, 1), + "@media (prefers-color-scheme: dark)", + "The prefers-color-scheme media query is displayed on the rule after reloading" + ); + + await selectNodeInFrames(["iframe", "html"], inspector); + await waitFor(() => iframeHasDarkSchemeStyling()); + ok(true, "simulation is still applied to the iframe after reloading"); + is( + getRuleViewAncestorRulesDataTextByIndex(view, 1), + "@media (prefers-color-scheme: dark)", + "The prefers-color-scheme media query is still displayed on the rule for the element in iframe after reloading" + ); + + info("Check that closing DevTools reset the simulation"); + await toolbox.destroy(); + const matchesPrefersDarkColorSchemeMedia = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + const { matches } = content.matchMedia("(prefers-color-scheme: dark)"); + return matches; + } + ); + is( + matchesPrefersDarkColorSchemeMedia, + false, + "color scheme simulation is disabled after closing DevTools" + ); +}); + +function isButtonChecked(el) { + return el.classList.contains("checked"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_color_scheme_simulation_bfcache.js b/devtools/client/inspector/rules/test/browser_rules_color_scheme_simulation_bfcache.js new file mode 100644 index 0000000000..c4995a3cb3 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_color_scheme_simulation_bfcache.js @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test color scheme simulation. +const TEST_URI = URL_ROOT_SSL + "doc_media_queries.html"; + +add_task(async function testBfCacheNavigationWithDevTools() { + await addTab(TEST_URI); + const { inspector, toolbox } = await openRuleView(); + + is(await isSimulationEnabled(), false, "color scheme simulation is disabled"); + + const darkButton = inspector.panelDoc.querySelector( + "#color-scheme-simulation-dark-toggle" + ); + ok(darkButton, "The dark color scheme simulation button exists"); + + info("Click on the dark button"); + darkButton.click(); + await waitFor(async () => isSimulationEnabled()); + is(await isSimulationEnabled(), true, "color scheme simulation is enabled"); + + info("Navigate to a different URL and disable the color simulation"); + await navigateTo(TEST_URI + "?someparameter"); + darkButton.click(); + await waitFor(async () => !(await isSimulationEnabled())); + is(await isSimulationEnabled(), false, "color scheme simulation is disabled"); + + info( + "Perform a bfcache navigation and check that the simulation is still disabled" + ); + const waitForDevToolsReload = await watchForDevToolsReload( + gBrowser.selectedBrowser + ); + gBrowser.goBack(); + await waitForDevToolsReload(); + is(await isSimulationEnabled(), false, "color scheme simulation is disabled"); + + await toolbox.destroy(); +}); + +add_task(async function testBfCacheNavigationAfterClosingDevTools() { + await addTab(TEST_URI); + const { inspector, toolbox } = await openRuleView(); + + is(await isSimulationEnabled(), false, "color scheme simulation is disabled"); + + const darkButton = inspector.panelDoc.querySelector( + "#color-scheme-simulation-dark-toggle" + ); + ok(darkButton, "The dark color scheme simulation button exists"); + + info("Click on the dark button"); + darkButton.click(); + await waitFor(async () => isSimulationEnabled()); + is(await isSimulationEnabled(), true, "color scheme simulation is enabled"); + + // Wait for the iframe target to be processed before destroying the toolbox, + // to avoid unhandled promise rejections. + // The iframe URL starts with https://example.org/document-builder.sjs + let onIframeProcessed; + + // Do not wait for the additional target in the noeft-nofis flavor. + const isNoEFTNoFis = !isFissionEnabled() && !isEveryFrameTargetEnabled(); + if (!isNoEFTNoFis) { + const iframeURL = "https://example.org/document-builder.sjs"; + onIframeProcessed = waitForTargetProcessed(toolbox.commands, targetFront => + targetFront.url.startsWith(iframeURL) + ); + } + + info("Navigate to a different URL"); + await navigateTo(TEST_URI + "?someparameter"); + + info("Wait for the iframe target to be processed by target-command"); + await onIframeProcessed; + + info("Close DevTools to disable the simulation"); + await toolbox.destroy(); + await waitFor(async () => !(await isSimulationEnabled())); + is(await isSimulationEnabled(), false, "color scheme simulation is disabled"); + + info( + "Perform a bfcache navigation and check that the simulation is still disabled" + ); + const awaitPageShow = BrowserTestUtils.waitForContentEvent( + gBrowser.selectedBrowser, + "pageshow" + ); + gBrowser.goBack(); + await awaitPageShow; + + is(await isSimulationEnabled(), false, "color scheme simulation is disabled"); +}); + +function isSimulationEnabled() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const { matches } = content.matchMedia("(prefers-color-scheme: dark)"); + return matches; + }); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_color_scheme_simulation_meta.js b/devtools/client/inspector/rules/test/browser_rules_color_scheme_simulation_meta.js new file mode 100644 index 0000000000..ba8163f1c0 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_color_scheme_simulation_meta.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This test page should follow the overall color scheme. +// Default colors / background colors should change depending on the scheme. +const TEST_URI = `https://example.com/document-builder.sjs?html= + <!DOCTYPE html> + <html lang=en> + <meta charset=utf-8> + <meta name=color-scheme content="dark light"> + Hello! +`; +add_task(async function () { + await addTab(TEST_URI); + const { inspector } = await openRuleView(); + + // Retrieve light and dark scheme buttons. + const lightButton = inspector.panelDoc.querySelector( + "#color-scheme-simulation-light-toggle" + ); + const darkButton = inspector.panelDoc.querySelector( + "#color-scheme-simulation-dark-toggle" + ); + + // Read the color scheme to know if we should click on the light or dark button + // to trigger a change. + info("Retrieve the default color scheme"); + let isDarkScheme = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + return content.matchMedia("(prefers-color-scheme: dark)").matches; + } + ); + + // Clicks on the simulation button which triggers a color-scheme change. + // If current scheme is light, click on dark and vice-versa. + function toggleScheme() { + info(`Switch color scheme to ${isDarkScheme ? "light" : "dark"} mode`); + isDarkScheme ? lightButton.click() : darkButton.click(); + isDarkScheme = !isDarkScheme; + } + + info("Retrieve the initial (text) color of the page"); + const initialColor = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + return content.getComputedStyle(content.document.body).color; + } + ); + + toggleScheme(); + + info("Wait until the color of the page changes"); + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [initialColor], + _initialColor => { + return ContentTaskUtils.waitForCondition(() => { + const newColor = content.getComputedStyle(content.document.body).color; + return newColor !== _initialColor; + }); + } + ); + + toggleScheme(); + + info("Wait until the color of the page goes back to the initial value"); + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [initialColor], + _initialColor => { + return ContentTaskUtils.waitForCondition(() => { + const newColor = content.getComputedStyle(content.document.body).color; + return newColor === _initialColor; + }); + } + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_color_scheme_simulation_rdm.js b/devtools/client/inspector/rules/test/browser_rules_color_scheme_simulation_rdm.js new file mode 100644 index 0000000000..5045b9c675 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_color_scheme_simulation_rdm.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test color scheme simulation when RDM is toggled +const TEST_URI = URL_ROOT + "doc_media_queries.html"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + + info("Check that the color scheme simulation buttons exist"); + const darkButton = inspector.panelDoc.querySelector( + "#color-scheme-simulation-dark-toggle" + ); + + // Define functions checking if the rule view display the expected property. + const divHasDefaultStyling = async () => + (await getPropertiesForRuleIndex(view, 1)).has("background-color:yellow"); + const divHasDarkSchemeStyling = async () => + (await getPropertiesForRuleIndex(view, 1)).has("background-color:darkblue"); + + info( + "Select the div that will change according to conditions in prefered color scheme" + ); + await selectNode("div", inspector); + ok( + await divHasDefaultStyling(), + "The rule view shows the expected initial rule" + ); + + info("Open responsive design mode"); + await openRDM(tab); + + info("Click on the dark button"); + darkButton.click(); + await waitFor(() => isButtonChecked(darkButton)); + ok(true, "The dark button is checked"); + + await waitFor(() => divHasDarkSchemeStyling()); + ok( + true, + "The rules view was updated with the rule view from the dark scheme media query" + ); + + info("Close responsive design mode"); + await closeRDM(tab); + + info("Wait for a bit before checking dark mode is still enabled"); + await wait(1000); + ok(isButtonChecked(darkButton), "button is still checked"); + ok( + await divHasDarkSchemeStyling(), + "dark mode color-scheme simulation is still enabled" + ); + + info("Click the button to disable simulation"); + darkButton.click(); + await waitFor(() => !isButtonChecked(darkButton)); + ok(true, "The button isn't checked anymore"); + await waitFor(() => divHasDefaultStyling()); + ok(true, "We're not simulating color-scheme anymore"); + + info("Check that enabling dark-mode simulation before RDM does work as well"); + darkButton.click(); + await waitFor(() => isButtonChecked(darkButton)); + await waitFor(() => divHasDarkSchemeStyling()); + ok( + true, + "The rules view was updated with the rule view from the dark scheme media query" + ); + + info("Open responsive design mode again"); + await openRDM(tab); + + info("Click the button to disable simulation while RDM is still opened"); + darkButton.click(); + await waitFor(() => !isButtonChecked(darkButton)); + ok(true, "The button isn't checked anymore"); + await waitFor(() => divHasDefaultStyling()); + ok(true, "We're not simulating color-scheme anymore"); + + info("Close responsive design mode"); + await closeRDM(tab); +}); + +function isButtonChecked(el) { + return el.classList.contains("checked"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-and-image-tooltip_01.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-and-image-tooltip_01.js new file mode 100644 index 0000000000..3a2c553f13 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-and-image-tooltip_01.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that after a color change, the image preview tooltip in the same +// property is displayed and positioned correctly. +// See bug 979292 + +const TEST_URI = ` + <style type="text/css"> + body { + background: url("chrome://branding/content/icon64.png"), linear-gradient(white, #F06 400px); + } + </style> + Testing the color picker tooltip! +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { view } = await openRuleView(); + + // Bug 1767679 - Use { wait: true } to avoid frequent intermittents on linux. + const property = await getRuleViewProperty(view, "body", "background", { + wait: true, + }); + + const value = property.valueSpan; + const swatch = value.querySelectorAll(".ruleview-colorswatch")[0]; + const url = value.querySelector(".theme-link"); + await testImageTooltipAfterColorChange(swatch, url, view); +}); + +async function testImageTooltipAfterColorChange(swatch, url, ruleView) { + info("First, verify that the image preview tooltip works"); + let previewTooltip = await assertShowPreviewTooltip(ruleView, url); + await assertTooltipHiddenOnMouseOut(previewTooltip, url); + + info("Open the color picker tooltip and change the color"); + const picker = ruleView.tooltips.getTooltip("colorPicker"); + const onColorPickerReady = picker.once("ready"); + swatch.click(); + await onColorPickerReady; + + await simulateColorPickerChange(ruleView, picker, [0, 0, 0, 1], { + selector: "body", + name: "background-image", + value: + 'url("chrome://branding/content/icon64.png"), linear-gradient(rgb(0, 0, 0), rgb(255, 0, 102) 400px)', + }); + + const spectrum = picker.spectrum; + const onHidden = picker.tooltip.once("hidden"); + + // On "RETURN", `ruleview-changed` is triggered when the SwatchBasedEditorTooltip calls + // its `commit` method, and then another event is emitted when the editor is hidden. + const onModifications = waitForNEvents(ruleView, "ruleview-changed", 2); + focusAndSendKey(spectrum.element.ownerDocument.defaultView, "RETURN"); + await onHidden; + await onModifications; + + info("Verify again that the image preview tooltip works"); + // After a color change, the property is re-populated, we need to get the new + // dom node + url = getRuleViewProperty( + ruleView, + "body", + "background" + ).valueSpan.querySelector(".theme-link"); + previewTooltip = await assertShowPreviewTooltip(ruleView, url); + + await assertTooltipHiddenOnMouseOut(previewTooltip, url); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-and-image-tooltip_02.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-and-image-tooltip_02.js new file mode 100644 index 0000000000..3262349274 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-and-image-tooltip_02.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that after a color change, opening another tooltip, like the image +// preview doesn't revert the color change in the rule view. +// This used to happen when the activeSwatch wasn't reset when the colorpicker +// would hide. +// See bug 979292 + +const TEST_URI = ` + <style type="text/css"> + body { + background: red url("chrome://branding/content/icon64.png") + no-repeat center center; + } + </style> + Testing the color picker tooltip! +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { view } = await openRuleView(); + await testColorChangeIsntRevertedWhenOtherTooltipIsShown(view); +}); + +async function testColorChangeIsntRevertedWhenOtherTooltipIsShown(ruleView) { + let swatch = getRuleViewProperty( + ruleView, + "body", + "background" + ).valueSpan.querySelector(".ruleview-colorswatch"); + + info("Open the color picker tooltip and change the color"); + const picker = ruleView.tooltips.getTooltip("colorPicker"); + const onColorPickerReady = picker.once("ready"); + swatch.click(); + await onColorPickerReady; + + await simulateColorPickerChange(ruleView, picker, [0, 0, 0, 1], { + selector: "body", + name: "background-color", + value: "rgb(0, 0, 0)", + }); + + const spectrum = picker.spectrum; + + const onModifications = waitForNEvents(ruleView, "ruleview-changed", 2); + const onHidden = picker.tooltip.once("hidden"); + focusAndSendKey(spectrum.element.ownerDocument.defaultView, "RETURN"); + await onHidden; + await onModifications; + + info("Open the image preview tooltip"); + const value = getRuleViewProperty(ruleView, "body", "background").valueSpan; + const url = value.querySelector(".theme-link"); + const previewTooltip = await assertShowPreviewTooltip(ruleView, url); + + info("Image tooltip is shown, verify that the swatch is still correct"); + swatch = value.querySelector(".ruleview-colorswatch"); + is(swatch.style.backgroundColor, "black", "The swatch's color is correct"); + is(swatch.nextSibling.textContent, "black", "The color name is correct"); + + await assertTooltipHiddenOnMouseOut(previewTooltip, url); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-appears-on-swatch-click-or-keyboard-activation.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-appears-on-swatch-click-or-keyboard-activation.js new file mode 100644 index 0000000000..0a44f4813f --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-appears-on-swatch-click-or-keyboard-activation.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that color pickers appear when clicking or using keyboard on color swatches. + +const TEST_URI = ` + <style type="text/css"> + body { + color: red; + background-color: #ededed; + background-image: url(chrome://branding/content/icon64.png); + border: 2em solid rgba(120, 120, 120, .5); + } + </style> + Testing the color picker tooltip! +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { view } = await openRuleView(); + + const propertiesToTest = ["color", "background-color", "border"]; + + for (const property of propertiesToTest) { + info(`Test that the colorpicker appears on swatch click for ${property}`); + await testColorPickerAppearsOnColorSwatchActivation(view, property); + + info( + `Test that swatch is focusable and colorpicker can be activated with a keyboard for ${property}` + ); + await testColorPickerAppearsOnColorSwatchActivation(view, property, true); + } +}); + +async function testColorPickerAppearsOnColorSwatchActivation( + view, + property, + withKeyboard = false +) { + const value = getRuleViewProperty(view, "body", property).valueSpan; + const swatch = value.querySelector(".ruleview-colorswatch"); + + const cPicker = view.tooltips.getTooltip("colorPicker"); + ok(cPicker, "The rule-view has an expected colorPicker widget"); + + const cPickerPanel = cPicker.tooltip.panel; + ok(cPickerPanel, "The XUL panel for the color picker exists"); + + const onColorPickerReady = cPicker.once("ready"); + if (withKeyboard) { + // Focus on the property value span + const doc = value.ownerDocument; + value.focus(); + + // Tab to focus on the color swatch + EventUtils.sendKey("Tab"); + is(doc.activeElement, swatch, "Swatch successfully receives focus."); + + // Press enter on the swatch to simulate click and open color picker + EventUtils.sendKey("Return"); + } else { + swatch.click(); + } + await onColorPickerReady; + + info("The color picker was displayed"); + ok(!inplaceEditor(swatch.parentNode), "The inplace editor wasn't displayed"); + + await hideTooltipAndWaitForRuleViewChanged(cPicker, view); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-commit-on-ENTER.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-commit-on-ENTER.js new file mode 100644 index 0000000000..5649d050d2 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-commit-on-ENTER.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that a color change in the color picker is committed when ENTER is +// pressed. + +const TEST_URI = ` + <style type="text/css"> + body { + border: 2em solid rgba(120, 120, 120, .5); + } + </style> + Testing the color picker tooltip! +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { view } = await openRuleView(); + + const swatch = getRuleViewProperty( + view, + "body", + "border" + ).valueSpan.querySelector(".ruleview-colorswatch"); + await testPressingEnterCommitsChanges(swatch, view); +}); + +async function testPressingEnterCommitsChanges(swatch, ruleView) { + const cPicker = ruleView.tooltips.getTooltip("colorPicker"); + + const onColorPickerReady = cPicker.once("ready"); + swatch.click(); + await onColorPickerReady; + + await simulateColorPickerChange(ruleView, cPicker, [0, 255, 0, 0.5], { + selector: "body", + name: "border-left-color", + value: "rgba(0, 255, 0, 0.5)", + }); + + is( + swatch.style.backgroundColor, + "rgba(0, 255, 0, 0.5)", + "The color swatch's background was updated" + ); + is( + getRuleViewProperty(ruleView, "body", "border").valueSpan.textContent, + "2em solid rgba(0, 255, 0, 0.5)", + "The text of the border css property was updated" + ); + + const onModified = ruleView.once("ruleview-changed"); + const spectrum = cPicker.spectrum; + const onHidden = cPicker.tooltip.once("hidden"); + focusAndSendKey(spectrum.element.ownerDocument.defaultView, "RETURN"); + await onHidden; + await onModified; + + is( + await getComputedStyleProperty("body", null, "border-left-color"), + "rgba(0, 255, 0, 0.5)", + "The element's border was kept after RETURN" + ); + is( + swatch.style.backgroundColor, + "rgba(0, 255, 0, 0.5)", + "The color swatch's background was kept after RETURN" + ); + is( + getRuleViewProperty(ruleView, "body", "border").valueSpan.textContent, + "2em solid rgba(0, 255, 0, 0.5)", + "The text of the border css property was kept after RETURN" + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-contrast-ratio.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-contrast-ratio.js new file mode 100644 index 0000000000..a0280e7106 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-contrast-ratio.js @@ -0,0 +1,232 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests color pickers displays expected contrast ratio information. + +const TEST_URI = ` + <style type="text/css"> + :root { + --title-color: #000; + } + + body { + color: #eee; + background-color: #eee; + } + + h1 { + color: var(--title-color); + } + + div { + color: var(--title-color); + /* Try to to have consistent results over different platforms: + - using hardstop-ish gradient so the min and max contrast are computed against white and black background + - having min-content width will make sure we get the gradient only cover the text, and not the whole screen width + */ + background-image: linear-gradient(to right, black, white); + width: min-content; + font-size: 100px; + } + + section { + color: color-mix(in srgb, blue, var(--title-color) 50%); + } + </style> + <h1>Testing the color picker contrast ratio data</h1> + <div>————</div> + <section>mixed colors</section> +`; + +add_task(async function () { + await pushPref("layout.css.color-mix.enabled", true); + + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await checkColorPickerConstrastData({ + view, + label: "Displays contrast information on color property", + ruleViewPropertyEl: getRuleViewProperty(view, "body", "color"), + expectVisibleContrast: true, + expectedContrastValueResult: "FAIL", + expectedContrastValueTitle: + "Does not meet WCAG standards for accessible text. Calculated against background: rgba(238,238,238,1)", + expectedContrastValueScore: "1.00", + }); + + await checkColorPickerConstrastData({ + view, + label: "Does not display contrast information on background-color property", + ruleViewPropertyEl: getRuleViewProperty(view, "body", "background-color"), + expectVisibleContrast: false, + }); + + await selectNode("h1", inspector); + await checkColorPickerConstrastData({ + view, + label: "Displays contrast information on color from CSS variable", + ruleViewPropertyEl: getRuleViewProperty(view, "h1", "color"), + expectVisibleContrast: true, + expectedContrastValueResult: "AAA", + expectedContrastValueTitle: + "Meets WCAG AAA standards for accessible text. Calculated against background: rgba(238,238,238,1)", + expectedContrastValueScore: "18.10", + }); + + await selectNode("div", inspector); + await checkColorPickerConstrastData({ + view, + label: + "Displays range contrast information on color against gradient background", + ruleViewPropertyEl: getRuleViewProperty(view, "div", "color"), + expectVisibleContrast: true, + expectContrastRange: true, + expectedMinContrastValueResult: "FAIL", + expectedMinContrastValueTitle: + "Does not meet WCAG standards for accessible text. Calculated against background: rgba(0,0,0,1)", + expectedMinContrastValueScore: "1.00", + expectedMaxContrastValueResult: "AAA", + expectedMaxContrastValueTitle: + "Meets WCAG AAA standards for accessible text. Calculated against background: rgba(255,255,255,1)", + expectedMaxContrastValueScore: "19.77", + }); + + await selectNode("section", inspector); + await checkColorPickerConstrastData({ + view, + label: + "Does not displays contrast information on color within color-mix function (#1)", + ruleViewPropertyEl: getRuleViewProperty(view, "section", "color"), + swatchIndex: 0, + expectVisibleContrast: false, + }); + await checkColorPickerConstrastData({ + view, + label: + "Does not displays contrast information on color within color-mix function (#2)", + ruleViewPropertyEl: getRuleViewProperty(view, "section", "color"), + swatchIndex: 1, + expectVisibleContrast: false, + }); +}); + +async function checkColorPickerConstrastData({ + view, + ruleViewPropertyEl, + label, + swatchIndex = 0, + expectVisibleContrast, + expectedContrastValueResult, + expectedContrastValueTitle, + expectedContrastValueScore, + expectContrastRange = false, + expectedMinContrastValueResult, + expectedMinContrastValueTitle, + expectedMinContrastValueScore, + expectedMaxContrastValueResult, + expectedMaxContrastValueTitle, + expectedMaxContrastValueScore, +}) { + info(`Checking color picker: "${label}"`); + const cPicker = view.tooltips.getTooltip("colorPicker"); + ok(cPicker, "The rule-view has an expected colorPicker widget"); + const cPickerPanel = cPicker.tooltip.panel; + ok(cPickerPanel, "The XUL panel for the color picker exists"); + + const colorSwatch = ruleViewPropertyEl.valueSpan.querySelectorAll( + ".ruleview-colorswatch" + )[swatchIndex]; + + const onColorPickerReady = cPicker.once("ready"); + colorSwatch.click(); + await onColorPickerReady; + ok(true, "The color picker was displayed"); + + const contrastEl = cPickerPanel.querySelector(".spectrum-color-contrast"); + + if (!expectVisibleContrast) { + ok( + !contrastEl.classList.contains("visible"), + "Contrast information is not displayed, as expected" + ); + await hideTooltipAndWaitForRuleViewChanged(cPicker, view); + return; + } + + ok( + contrastEl.classList.contains("visible"), + "Contrast information is displayed" + ); + is( + contrastEl.classList.contains("range"), + expectContrastRange, + `Contrast information ${ + expectContrastRange ? "has" : "does not have" + } a result range` + ); + + if (expectContrastRange) { + const minContrastValueEl = contrastEl.querySelector( + ".contrast-ratio-range .contrast-ratio-min .accessibility-contrast-value" + ); + ok( + minContrastValueEl.classList.contains(expectedMinContrastValueResult), + `min contrast value has expected "${expectedMinContrastValueResult}" class` + ); + // Scores vary from platform to platform, disable for now. + // This should be re-enabled as part of Bug 1780736 + // is( + // minContrastValueEl.title, + // expectedMinContrastValueTitle, + // "min contrast value has expected title" + // ); + // is( + // minContrastValueEl.textContent, + // expectedMinContrastValueScore, + // "min contrast value shows expected score" + // ); + + const maxContrastValueEl = contrastEl.querySelector( + ".contrast-ratio-range .contrast-ratio-max .accessibility-contrast-value" + ); + ok( + maxContrastValueEl.classList.contains(expectedMaxContrastValueResult), + `max contrast value has expected "${expectedMaxContrastValueResult}" class` + ); + // Scores vary from platform to platform, disable for now. + // This should be re-enabled as part of Bug 1780736 + // is( + // maxContrastValueEl.title, + // expectedMaxContrastValueTitle, + // "max contrast value has expected title" + // ); + // is( + // maxContrastValueEl.textContent, + // expectedMaxContrastValueScore, + // "max contrast value shows expected score" + // ); + } else { + const contrastValueEl = contrastEl.querySelector( + ".accessibility-contrast-value" + ); + ok( + contrastValueEl.classList.contains(expectedContrastValueResult), + `contrast value has expected "${expectedContrastValueResult}" class` + ); + is( + contrastValueEl.title, + expectedContrastValueTitle, + "contrast value has expected title" + ); + is( + contrastValueEl.textContent, + expectedContrastValueScore, + "contrast value shows expected score" + ); + } + + await hideTooltipAndWaitForRuleViewChanged(cPicker, view); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-edit-gradient.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-edit-gradient.js new file mode 100644 index 0000000000..c5eef9d670 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-edit-gradient.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that changing a color in a gradient css declaration using the tooltip +// color picker works. + +const TEST_URI = ` + <style type="text/css"> + body { + background-image: linear-gradient(to left, #f06 25%, #333 95%, #000 100%); + } + </style> + Updating a gradient declaration with the color picker tooltip +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { view } = await openRuleView(); + + info("Testing that the colors in gradient properties are parsed correctly"); + testColorParsing(view); + + info("Testing that changing one of the colors of a gradient property works"); + await testPickingNewColor(view); +}); + +function testColorParsing(view) { + const ruleEl = getRuleViewProperty(view, "body", "background-image"); + ok(ruleEl, "The background-image gradient declaration was found"); + + const swatchEls = ruleEl.valueSpan.querySelectorAll(".ruleview-colorswatch"); + ok(swatchEls, "The color swatch elements were found"); + is(swatchEls.length, 3, "There are 3 color swatches"); + + const colorEls = ruleEl.valueSpan.querySelectorAll(".ruleview-color"); + ok(colorEls, "The color elements were found"); + is(colorEls.length, 3, "There are 3 color values"); + + const colors = ["#f06", "#333", "#000"]; + for (let i = 0; i < colors.length; i++) { + is(colorEls[i].textContent, colors[i], "The right color value was found"); + } +} + +async function testPickingNewColor(view) { + // Grab the first color swatch and color in the gradient + const ruleEl = getRuleViewProperty(view, "body", "background-image"); + const swatchEl = ruleEl.valueSpan.querySelector(".ruleview-colorswatch"); + const colorEl = ruleEl.valueSpan.querySelector(".ruleview-color"); + + info("Get the color picker tooltip and clicking on the swatch to show it"); + const cPicker = view.tooltips.getTooltip("colorPicker"); + const onColorPickerReady = cPicker.once("ready"); + swatchEl.click(); + await onColorPickerReady; + + const change = { + selector: "body", + name: "background-image", + value: + "linear-gradient(to left, rgb(1, 1, 1) 25%, " + + "rgb(51, 51, 51) 95%, rgb(0, 0, 0) 100%)", + }; + await simulateColorPickerChange(view, cPicker, [1, 1, 1, 1], change); + + is( + swatchEl.style.backgroundColor, + "rgb(1, 1, 1)", + "The color swatch's background was updated" + ); + is(colorEl.textContent, "#010101", "The color text was updated"); + is( + await getComputedStyleProperty("body", null, "background-image"), + "linear-gradient(to left, rgb(1, 1, 1) 25%, rgb(51, 51, 51) 95%, " + + "rgb(0, 0, 0) 100%)", + "The gradient has been updated correctly" + ); + + await hideTooltipAndWaitForRuleViewChanged(cPicker, view); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-element-without-quads.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-element-without-quads.js new file mode 100644 index 0000000000..f8012ab56b --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-element-without-quads.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = ` + <style type="text/css"> + button { + color: tomato; + background-color: green; + } + </style> + <!-- The space as text context for the button is mandatory here, so that the + firstChild of the button is an whitespace text node --> + <button id="button-with-no-quads"> </button> +`; + +/** + * Check that we can still open the color picker on elements for which getQuads + * returns an empty array, which is used to compute the background-color + * contrast. + */ +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + + const hasEmptyQuads = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + const button = content.document.querySelector("button"); + const quads = button.firstChild.getBoxQuads({ + box: "content", + relativeTo: content.document, + createFramesForSuppressedWhitespace: false, + }); + return quads.length === 0; + } + ); + ok(hasEmptyQuads, "The test element has empty quads"); + + const { inspector, view } = await openRuleView(); + + await selectNode("button", inspector); + + const ruleEl = getRuleViewProperty(view, "button", "color"); + ok(ruleEl, "The color declaration was found"); + + const swatchEl = ruleEl.valueSpan.querySelector(".ruleview-colorswatch"); + const colorEl = ruleEl.valueSpan.querySelector(".ruleview-color"); + is(colorEl.textContent, "tomato", "The right color value was found"); + + info("Open the color picker"); + const cPicker = view.tooltips.getTooltip("colorPicker"); + const onColorPickerReady = cPicker.once("ready"); + swatchEl.click(); + await onColorPickerReady; + + info("Check that the background color of the button was correctly detected"); + const contrastEl = cPicker.tooltip.container.querySelector( + ".contrast-value-and-swatch.contrast-ratio-single" + ); + ok( + contrastEl.style.cssText.includes( + "--accessibility-contrast-bg: rgba(0,128,0,1)" + ), + "The background color contains the expected value" + ); + + info("Check that the color picker can be used"); + await simulateColorPickerChange(view, cPicker, [255, 0, 0, 1], { + selector: "button", + name: "color", + value: "rgb(255, 0, 0)", + }); + + await hideTooltipAndWaitForRuleViewChanged(cPicker, view); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-hides-element-picker.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-hides-element-picker.js new file mode 100644 index 0000000000..53442989c4 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-hides-element-picker.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that on selecting colorpicker eyedropper stops picker +// if the picker is already selected. + +const TEST_URI = `<style>body{background:red}</style>`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + + const { toolbox, view } = await openRuleView(); + const pickerStopped = toolbox.nodePicker.once("picker-stopped"); + + await startPicker(toolbox); + + info("Get the background property from the rule-view"); + const property = getRuleViewProperty(view, "body", "background"); + const swatch = property.valueSpan.querySelector(".ruleview-colorswatch"); + ok(swatch, "Color swatch is displayed for the background property"); + + info("Open the eyedropper from the colorpicker tooltip"); + await openEyedropper(view, swatch); + + info("Waiting for the picker-stopped event to be fired"); + await pickerStopped; + + ok(true, "picker-stopped event fired after eyedropper was clicked"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-hides-on-tooltip.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-hides-on-tooltip.js new file mode 100644 index 0000000000..e1d290c005 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-hides-on-tooltip.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the color picker tooltip hides when an image tooltip appears. + +const TEST_URI = ` + <style type="text/css"> + body { + color: red; + background-color: #ededed; + background-image: url(chrome://branding/content/icon64.png); + border: 2em solid rgba(120, 120, 120, .5); + } + </style> + Testing the color picker tooltip! +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { view } = await openRuleView(); + + const swatch = getRuleViewProperty( + view, + "body", + "color" + ).valueSpan.querySelector(".ruleview-colorswatch"); + + const bgImageSpan = getRuleViewProperty( + view, + "body", + "background-image" + ).valueSpan; + const uriSpan = bgImageSpan.querySelector(".theme-link"); + + const colorPicker = view.tooltips.getTooltip("colorPicker"); + info("Showing the color picker tooltip by clicking on the color swatch"); + const onColorPickerReady = colorPicker.once("ready"); + swatch.click(); + await onColorPickerReady; + + info("Now showing the image preview tooltip to hide the color picker"); + const onHidden = colorPicker.tooltip.once("hidden"); + // Hiding the color picker refreshes the value. + const onRuleViewChanged = view.once("ruleview-changed"); + const previewTooltip = await assertShowPreviewTooltip(view, uriSpan); + await onHidden; + await onRuleViewChanged; + + await assertTooltipHiddenOnMouseOut(previewTooltip, uriSpan); + + ok(true, "The color picker closed when the image preview tooltip appeared"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-multiple-changes.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-multiple-changes.js new file mode 100644 index 0000000000..f641745fd2 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-multiple-changes.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the color in the colorpicker tooltip can be changed several times. +// without causing error in various cases: +// - simple single-color property (color) +// - color and image property (background-image) +// - overridden property +// See bug 979292 and bug 980225 + +const TEST_URI = ` + <style type="text/css"> + body { + color: green; + background: red url("chrome://branding/content/icon64.png") + no-repeat center center; + } + p { + color: blue; + } + </style> + <p>Testing the color picker tooltip!</p> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await testSimpleMultipleColorChanges(inspector, view); + await testComplexMultipleColorChanges(inspector, view); +}); + +async function testSimpleMultipleColorChanges(inspector, ruleView) { + await selectNode("p", inspector); + + info("Getting the <p> tag's color property"); + const swatch = getRuleViewProperty( + ruleView, + "p", + "color" + ).valueSpan.querySelector(".ruleview-colorswatch"); + + info("Opening the color picker"); + const picker = ruleView.tooltips.getTooltip("colorPicker"); + const onColorPickerReady = picker.once("ready"); + swatch.click(); + await onColorPickerReady; + + info("Changing the color several times"); + const colors = [ + { rgba: [0, 0, 0, 1], computed: "rgb(0, 0, 0)" }, + { rgba: [100, 100, 100, 1], computed: "rgb(100, 100, 100)" }, + { rgba: [200, 200, 200, 1], computed: "rgb(200, 200, 200)" }, + ]; + for (const { rgba, computed } of colors) { + await simulateColorPickerChange(ruleView, picker, rgba, { + selector: "p", + name: "color", + value: computed, + }); + } + + is( + await getComputedStyleProperty("p", null, "color"), + "rgb(200, 200, 200)", + "The color of the P tag is correct" + ); +} + +async function testComplexMultipleColorChanges(inspector, ruleView) { + await selectNode("body", inspector); + + info("Getting the <body> tag's color property"); + const swatch = getRuleViewProperty( + ruleView, + "body", + "background" + ).valueSpan.querySelector(".ruleview-colorswatch"); + + info("Opening the color picker"); + const picker = ruleView.tooltips.getTooltip("colorPicker"); + const onColorPickerReady = picker.once("ready"); + swatch.click(); + await onColorPickerReady; + + info("Changing the color several times"); + const colors = [ + { rgba: [0, 0, 0, 1], computed: "rgb(0, 0, 0)" }, + { rgba: [100, 100, 100, 1], computed: "rgb(100, 100, 100)" }, + { rgba: [200, 200, 200, 1], computed: "rgb(200, 200, 200)" }, + ]; + for (const { rgba, computed } of colors) { + await simulateColorPickerChange(ruleView, picker, rgba, { + selector: "body", + name: "background-color", + value: computed, + }); + } + + info("Closing the color picker"); + await hideTooltipAndWaitForRuleViewChanged(picker, ruleView); + + is( + await getComputedStyleProperty("p", null, "color"), + "rgb(200, 200, 200)", + "The color of the P tag is still correct" + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-release-outside-frame.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-release-outside-frame.js new file mode 100644 index 0000000000..f992e93094 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-release-outside-frame.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that color pickers stops following the pointer if the pointer is +// released outside the tooltip frame (bug 1160720). + +const TEST_URI = "<body style='color: red'>Test page for bug 1160720"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { view } = await openRuleView(); + + const cSwatch = getRuleViewProperty( + view, + "element", + "color" + ).valueSpan.querySelector(".ruleview-colorswatch"); + + const picker = await openColorPickerForSwatch(cSwatch, view); + const spectrum = picker.spectrum; + const change = spectrum.once("changed"); + + info("Pressing mouse down over color picker."); + const onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.synthesizeMouseAtCenter( + spectrum.dragger, + { + type: "mousedown", + }, + spectrum.dragger.ownerDocument.defaultView + ); + await onRuleViewChanged; + + const value = await change; + info(`Color changed to ${value} on mousedown.`); + + // If the mousemove below fails to detect that the button is no longer pressed + // the spectrum will update and emit changed event synchronously after calling + // synthesizeMouse so this handler is executed before the test ends. + spectrum.once("changed", newValue => { + is(newValue, value, "Value changed on mousemove without a button pressed."); + }); + + // Releasing the button pressed by mousedown above on top of a different frame + // does not make sense in this test as EventUtils doesn't preserve the context + // i.e. the buttons that were pressed down between events. + + info("Moving mouse over color picker without any buttons pressed."); + + EventUtils.synthesizeMouse( + spectrum.dragger, + 10, + 10, + { + // -1 = no buttons are pressed down + button: -1, + type: "mousemove", + }, + spectrum.dragger.ownerDocument.defaultView + ); +}); + +async function openColorPickerForSwatch(swatch, view) { + const cPicker = view.tooltips.getTooltip("colorPicker"); + ok(cPicker, "The rule-view has an expected colorPicker widget"); + + const cPickerPanel = cPicker.tooltip.panel; + ok(cPickerPanel, "The XUL panel for the color picker exists"); + + const onColorPickerReady = cPicker.once("ready"); + swatch.click(); + await onColorPickerReady; + + ok(true, "The color picker was shown on click of the color swatch"); + + return cPicker; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-revert-on-ESC.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-revert-on-ESC.js new file mode 100644 index 0000000000..47a39d0518 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-revert-on-ESC.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that a color change in the color picker is reverted when ESC is +// pressed. + +const TEST_URI = ` + <style type="text/css"> + body { + background-color: #EDEDED; + } + </style> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { view } = await openRuleView(); + await testPressingEscapeRevertsChanges(view); +}); + +async function testPressingEscapeRevertsChanges(view) { + const { swatch, propEditor, cPicker } = await openColorPickerAndSelectColor( + view, + 1, + 0, + [0, 0, 0, 1], + { + selector: "body", + name: "background-color", + value: "rgb(0, 0, 0)", + } + ); + + is( + swatch.style.backgroundColor, + "rgb(0, 0, 0)", + "The color swatch's background was updated" + ); + is( + propEditor.valueSpan.textContent, + "#000", + "The text of the background-color css property was updated" + ); + + const spectrum = cPicker.spectrum; + + info("Pressing ESCAPE to close the tooltip"); + const onHidden = cPicker.tooltip.once("hidden"); + const onModifications = view.once("ruleview-changed"); + EventUtils.sendKey("ESCAPE", spectrum.element.ownerDocument.defaultView); + await onHidden; + await onModifications; + + await waitForComputedStyleProperty( + "body", + null, + "background-color", + "rgb(237, 237, 237)" + ); + is( + propEditor.valueSpan.textContent, + "#EDEDED", + "Got expected property value." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-swatch-displayed.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-swatch-displayed.js new file mode 100644 index 0000000000..74b483172d --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-swatch-displayed.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that color swatches are displayed next to colors in the rule-view. + +const TEST_URI = ` + <style type="text/css"> + body { + color: red; + background-color: #ededed; + background-image: url(chrome://branding/content/icon64.png); + border: 2em solid rgba(120, 120, 120, .5); + } + * { + color: blue; + background: linear-gradient( + to right, + #f00, + #f008, + #00ff00, + #00ff0080, + rgb(31,170,217), + rgba(31,170,217,.5), + hsl(5, 5%, 5%), + hsla(5, 5%, 5%, 0.25), + #F00, + #F008, + #00FF00, + #00FF0080, + RGB(31,170,217), + RGBA(31,170,217,.5), + HSL(5, 5%, 5%), + HSLA(5, 5%, 5%, 0.25) + ), + radial-gradient( + red, + blue + ), + conic-gradient( + from 90deg at 0 0, + lemonchiffon, + peachpuff + ), + repeating-linear-gradient(blue, pink), + repeating-radial-gradient(limegreen, bisque), + repeating-conic-gradient(chocolate, olive); + box-shadow: inset 0 0 2px 20px red, inset 0 0 2px 40px blue; + filter: drop-shadow(2px 2px 2px salmon); + text-shadow: 2px 2px color-mix( + in srgb, + color-mix(in srgb, tomato 30%, #FA8072), + hsla(5, 5%, 5%, 0.25) 5% + ); + } + </style> + Testing the color picker tooltip! +`; + +// Tests that properties in the rule-view contain color swatches. +// Each entry in the test array should contain: +// { +// selector: the rule-view selector to look for the property in +// propertyName: the property to test +// nb: the number of color swatches this property should have +// } +const TESTS = [ + { selector: "body", propertyName: "color", nb: 1 }, + { selector: "body", propertyName: "background-color", nb: 1 }, + { selector: "body", propertyName: "border", nb: 1 }, + { selector: "*", propertyName: "color", nb: 1 }, + { selector: "*", propertyName: "background", nb: 26 }, + { selector: "*", propertyName: "box-shadow", nb: 2 }, + { selector: "*", propertyName: "filter", nb: 1 }, + { selector: "*", propertyName: "text-shadow", nb: 3 }, +]; + +add_task(async function () { + await pushPref("layout.css.color-mix.enabled", true); + + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { view } = await openRuleView(); + + for (const { selector, propertyName, nb } of TESTS) { + info( + "Looking for color swatches in property " + + propertyName + + " in selector " + + selector + ); + + const prop = ( + await getRuleViewProperty(view, selector, propertyName, { wait: true }) + ).valueSpan; + const swatches = prop.querySelectorAll(".ruleview-colorswatch"); + + ok(swatches.length, "Swatches found in the property"); + is(swatches.length, nb, "Correct number of swatches found in the property"); + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-works-with-css-vars.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-works-with-css-vars.js new file mode 100644 index 0000000000..6e28426b20 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-works-with-css-vars.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests color pickers work with CSS variables. + +const TEST_URI = ` + <style type="text/css"> + :root { + --main-bg-color: coral; + } + body { + color: red; + background-color: var(--main-bg-color); + border: 1px solid var(--main-bg-color); + } + </style> + Testing the color picker tooltip with CSS variables! +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { view } = await openRuleView(); + + const propertiesToTest = ["color", "background-color", "border"]; + + for (const property of propertiesToTest) { + info(`Test that the colorpicker appears on swatch click for ${property}`); + await testColorPickerAppearsOnColorSwatchActivation(view, property); + + info( + `Test that swatch is focusable and colorpicker can be activated with a keyboard for ${property}` + ); + await testColorPickerAppearsOnColorSwatchActivation(view, property, true); + } +}); + +async function testColorPickerAppearsOnColorSwatchActivation( + view, + property, + withKeyboard = false +) { + const value = ( + await getRuleViewProperty(view, "body", property, { wait: true }) + ).valueSpan; + const swatch = value.querySelector(".ruleview-colorswatch"); + + const cPicker = view.tooltips.getTooltip("colorPicker"); + ok(cPicker, "The rule-view has an expected colorPicker widget"); + + const cPickerPanel = cPicker.tooltip.panel; + ok(cPickerPanel, "The XUL panel for the color picker exists"); + + const onColorPickerReady = cPicker.once("ready"); + if (withKeyboard) { + // Focus on the property value span + const doc = value.ownerDocument; + value.focus(); + + // Tab to focus on the color swatch + EventUtils.sendKey("Tab"); + is(doc.activeElement, swatch, "Swatch successfully receives focus."); + + // Press enter on the swatch to simulate click and open color picker + EventUtils.sendKey("Return"); + } else { + swatch.click(); + } + await onColorPickerReady; + + info("The color picker was displayed"); + ok(!inplaceEditor(swatch.parentNode), "The inplace editor wasn't displayed"); + + await hideTooltipAndWaitForRuleViewChanged(cPicker, view); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_colorpicker-wrap-focus.js b/devtools/client/inspector/rules/test/browser_rules_colorpicker-wrap-focus.js new file mode 100644 index 0000000000..36ef6f15cb --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-wrap-focus.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that focus stays inside color picker on TAB and Shift + TAB + +const TEST_URI = ` + <style type="text/css"> + body { + color: red; + background-color: #ededed; + background-image: url(chrome://branding/content/icon64.png); + border: 2em solid rgba(120, 120, 120, .5); + } + </style> + Testing the color picker tooltip! +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { view } = await openRuleView(); + + info("Focus on the property value span"); + getRuleViewProperty(view, "body", "color").valueSpan.focus(); + + const cPicker = view.tooltips.getTooltip("colorPicker"); + const onColorPickerReady = cPicker.once("ready"); + + info( + "Tab to focus on the color swatch and press enter to simulate a click event" + ); + EventUtils.sendKey("Tab"); + EventUtils.sendKey("Return"); + + await onColorPickerReady; + const doc = cPicker.spectrum.element.ownerDocument; + ok( + doc.activeElement.classList.contains("spectrum-color"), + "Focus is initially on the spectrum dragger when color picker is shown." + ); + + info("Test that tabbing should move focus to the next focusable elements."); + testFocusOnTab(doc, "devtools-button"); + testFocusOnTab(doc, "spectrum-hue-input"); + testFocusOnTab(doc, "spectrum-alpha-input"); + testFocusOnTab(doc, "learn-more"); + + info( + "Test that tabbing on the last element wraps focus to the first element." + ); + testFocusOnTab(doc, "spectrum-color"); + + info( + "Test that shift tabbing on the first element wraps focus to the last element." + ); + testFocusOnTab(doc, "learn-more", true); + + info( + "Test that shift tabbing should move focus to the previous focusable elements." + ); + testFocusOnTab(doc, "spectrum-alpha-input", true); + testFocusOnTab(doc, "spectrum-hue-input", true); + testFocusOnTab(doc, "devtools-button", true); + testFocusOnTab(doc, "spectrum-color", true); + + await hideTooltipAndWaitForRuleViewChanged(cPicker, view); +}); + +function testFocusOnTab(doc, expectedClass, shiftKey = false) { + EventUtils.synthesizeKey("VK_TAB", { shiftKey }); + ok( + doc.activeElement.classList.contains(expectedClass), + "Focus is on the correct element." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_completion-existing-property_01.js b/devtools/client/inspector/rules/test/browser_rules_completion-existing-property_01.js new file mode 100644 index 0000000000..d9333fe40c --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_completion-existing-property_01.js @@ -0,0 +1,148 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that CSS property names are autocompleted and cycled correctly when +// editing an existing property in the rule view. + +// format : +// [ +// what key to press, +// expected input box value after keypress, +// is the popup open, +// is a suggestion selected in the popup, +// ] + +const OPEN = true, + SELECTED = true; +var testData = [ + ["VK_RIGHT", "font", !OPEN, !SELECTED], + ["-", "font-size", OPEN, SELECTED], + ["f", "font-family", OPEN, SELECTED], + ["VK_BACK_SPACE", "font-f", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "font-", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "font", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "fon", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "fo", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "f", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "", !OPEN, !SELECTED], + ["d", "display", OPEN, SELECTED], + ["VK_DOWN", "dominant-baseline", OPEN, SELECTED], + ["VK_DOWN", "d", OPEN, SELECTED], + ["VK_DOWN", "direction", OPEN, SELECTED], + ["VK_DOWN", "display", OPEN, SELECTED], + ["VK_UP", "direction", OPEN, SELECTED], + ["VK_UP", "d", OPEN, SELECTED], + ["VK_UP", "dominant-baseline", OPEN, SELECTED], + ["VK_UP", "display", OPEN, SELECTED], + ["VK_BACK_SPACE", "d", !OPEN, !SELECTED], + ["i", "display", OPEN, SELECTED], + ["s", "display", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "dis", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "di", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "d", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "", !OPEN, !SELECTED], + ["VK_HOME", "", !OPEN, !SELECTED], + ["VK_END", "", !OPEN, !SELECTED], + ["VK_PAGE_UP", "", !OPEN, !SELECTED], + ["VK_PAGE_DOWN", "", !OPEN, !SELECTED], + ["d", "display", OPEN, SELECTED], + ["VK_HOME", "display", !OPEN, !SELECTED], + ["VK_END", "display", !OPEN, !SELECTED], + // Press right key to ensure caret move to end of the input on Mac OS since + // Mac OS doesn't move caret after pressing HOME / END. + ["VK_RIGHT", "display", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "displa", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "displ", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "disp", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "dis", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "di", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "d", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "", !OPEN, !SELECTED], + ["f", "font-size", OPEN, SELECTED], + ["i", "filter", OPEN, SELECTED], + ["VK_LEFT", "filter", !OPEN, !SELECTED], + ["VK_LEFT", "filter", !OPEN, !SELECTED], + ["i", "fiilter", !OPEN, !SELECTED], + ["VK_ESCAPE", null, !OPEN, !SELECTED], +]; + +const TEST_URI = "<h1 style='font: 24px serif'>Header</h1>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { toolbox, inspector, view } = await openRuleView(); + + info("Test autocompletion after 1st page load"); + await runAutocompletionTest(toolbox, inspector, view); + + info("Test autocompletion after page navigation"); + await reloadBrowser(); + await runAutocompletionTest(toolbox, inspector, view); +}); + +async function runAutocompletionTest(toolbox, inspector, view) { + info("Selecting the test node"); + await selectNode("h1", inspector); + + info("Focusing the css property editable field"); + const propertyName = view.styleDocument.querySelectorAll( + ".ruleview-propertyname" + )[0]; + const editor = await focusEditableField(view, propertyName); + + info("Starting to test for css property completion"); + for (let i = 0; i < testData.length; i++) { + if (!testData[i].length) { + continue; + } + await testCompletion(testData[i], editor, view); + } +} + +async function testCompletion([key, completion, open, selected], editor, view) { + info("Pressing key " + key); + info("Expecting " + completion); + info("Is popup opened: " + open); + info("Is item selected: " + selected); + + // Listening for the right event that will tell us when the key has been + // entered and processed. + let onSuggest; + if (/(left|right|back_space|escape|home|end|page_up|page_down)/gi.test(key)) { + info( + "Adding event listener for " + + "left|right|back_space|escape|home|end|page_up|page_down keys" + ); + onSuggest = once(editor.input, "keypress"); + } else { + info("Waiting for after-suggest event on the editor"); + onSuggest = editor.once("after-suggest"); + } + + // Also listening for popup opened/closed events if needed. + const popupEvent = open ? "popup-opened" : "popup-closed"; + const onPopupEvent = + editor.popup.isOpen !== open ? once(editor.popup, popupEvent) : null; + + info("Synthesizing key " + key); + EventUtils.synthesizeKey(key, {}, view.styleWindow); + + // Flush the debounce for the preview text. + view.debounce.flush(); + + await onSuggest; + await onPopupEvent; + + info("Checking the state"); + if (completion !== null) { + is(editor.input.value, completion, "Correct value is autocompleted"); + } + if (!open) { + ok(!(editor.popup && editor.popup.isOpen), "Popup is closed"); + } else { + ok(editor.popup.isOpen, "Popup is open"); + is(editor.popup.selectedIndex !== -1, selected, "An item is selected"); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_completion-existing-property_02.js b/devtools/client/inspector/rules/test/browser_rules_completion-existing-property_02.js new file mode 100644 index 0000000000..0d0546bc8d --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_completion-existing-property_02.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that CSS property names and values are autocompleted and cycled +// correctly when editing existing properties in the rule view. + +// format : +// [ +// what key to press, +// modifers, +// expected input box value after keypress, +// is the popup open, +// is a suggestion selected in the popup, +// expect ruleview-changed, +// ] + +const OPEN = true, + SELECTED = true, + CHANGE = true; +var testData = [ + ["b", {}, "beige", OPEN, SELECTED, CHANGE], + ["l", {}, "black", OPEN, SELECTED, CHANGE], + ["VK_DOWN", {}, "blanchedalmond", OPEN, SELECTED, CHANGE], + ["VK_DOWN", {}, "blue", OPEN, SELECTED, CHANGE], + ["VK_RIGHT", {}, "blue", !OPEN, !SELECTED, !CHANGE], + [" ", {}, "blue aliceblue", OPEN, SELECTED, CHANGE], + ["!", {}, "blue !important", !OPEN, !SELECTED, CHANGE], + ["VK_BACK_SPACE", {}, "blue !", !OPEN, !SELECTED, CHANGE], + ["VK_BACK_SPACE", {}, "blue ", !OPEN, !SELECTED, CHANGE], + ["VK_BACK_SPACE", {}, "blue", !OPEN, !SELECTED, CHANGE], + ["VK_TAB", { shiftKey: true }, "color", !OPEN, !SELECTED, CHANGE], + ["VK_BACK_SPACE", {}, "", !OPEN, !SELECTED, !CHANGE], + ["d", {}, "display", OPEN, SELECTED, !CHANGE], + ["VK_TAB", {}, "blue", !OPEN, !SELECTED, CHANGE], + ["n", {}, "none", !OPEN, !SELECTED, CHANGE], + ["VK_RETURN", {}, null, !OPEN, !SELECTED, CHANGE], +]; + +const TEST_URI = "<h1 style='color: red'>Header</h1>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { toolbox, inspector, view } = await openRuleView(); + + info("Test autocompletion after 1st page load"); + await runAutocompletionTest(toolbox, inspector, view); + + info("Test autocompletion after page navigation"); + await reloadBrowser(); + await runAutocompletionTest(toolbox, inspector, view); +}); + +async function runAutocompletionTest(toolbox, inspector, view) { + info("Selecting the test node"); + await selectNode("h1", inspector); + + const prop = getTextProperty(view, 0, { color: "red" }); + + info("Focusing the css property editable value"); + let editor = await focusEditableField(view, prop.editor.valueSpan); + + info("Starting to test for css property completion"); + for (let i = 0; i < testData.length; i++) { + // Re-define the editor at each iteration, because the focus may have moved + // from property to value and back + editor = inplaceEditor(view.styleDocument.activeElement); + await testCompletion(testData[i], editor, view); + } +} + +async function testCompletion( + [key, modifiers, completion, open, selected, change], + editor, + view +) { + info("Pressing key " + key); + info("Expecting " + completion); + info("Is popup opened: " + open); + info("Is item selected: " + selected); + + let onDone; + if (change) { + // If the key triggers a ruleview-changed, wait for that event, it will + // always be the last to be triggered and tells us when the preview has + // been done. + onDone = view.once("ruleview-changed"); + } else { + // Otherwise, expect an after-suggest event (except if the popup gets + // closed). + onDone = + key !== "VK_RIGHT" && key !== "VK_BACK_SPACE" + ? editor.once("after-suggest") + : null; + } + + info("Synthesizing key " + key + ", modifiers: " + Object.keys(modifiers)); + + // Also listening for popup opened/closed events if needed. + const popupEvent = open ? "popup-opened" : "popup-closed"; + const onPopupEvent = + editor.popup.isOpen !== open ? once(editor.popup, popupEvent) : null; + + EventUtils.synthesizeKey(key, modifiers, view.styleWindow); + + // Flush the debounce for the preview text. + view.debounce.flush(); + + await onDone; + await onPopupEvent; + + // The key might have been a TAB or shift-TAB, in which case the editor will + // be a new one + editor = inplaceEditor(view.styleDocument.activeElement); + + info("Checking the state"); + if (completion !== null) { + is(editor.input.value, completion, "Correct value is autocompleted"); + } + + if (!open) { + ok(!(editor.popup && editor.popup.isOpen), "Popup is closed"); + } else { + ok(editor.popup.isOpen, "Popup is open"); + is(editor.popup.selectedIndex !== -1, selected, "An item is selected"); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_completion-new-property_01.js b/devtools/client/inspector/rules/test/browser_rules_completion-new-property_01.js new file mode 100644 index 0000000000..139c825db0 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_completion-new-property_01.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that CSS property names are autocompleted and cycled correctly when +// creating a new property in the rule view. + +// format : +// [ +// what key to press, +// expected input box value after keypress, +// is the popup open, +// is a suggestion selected in the popup, +// ] +const OPEN = true, + SELECTED = true; +var testData = [ + ["d", "display", OPEN, SELECTED], + ["VK_DOWN", "dominant-baseline", OPEN, SELECTED], + ["VK_DOWN", "d", OPEN, SELECTED], + ["VK_DOWN", "direction", OPEN, SELECTED], + ["VK_DOWN", "display", OPEN, SELECTED], + ["VK_UP", "direction", OPEN, SELECTED], + ["VK_UP", "d", OPEN, SELECTED], + ["VK_UP", "dominant-baseline", OPEN, SELECTED], + ["VK_UP", "display", OPEN, SELECTED], + ["VK_BACK_SPACE", "d", !OPEN, !SELECTED], + ["i", "display", OPEN, SELECTED], + ["s", "display", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "dis", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "di", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "d", !OPEN, !SELECTED], + ["VK_BACK_SPACE", "", !OPEN, !SELECTED], + ["f", "font-size", OPEN, SELECTED], + ["i", "filter", OPEN, SELECTED], + ["VK_ESCAPE", null, !OPEN, !SELECTED], +]; + +const TEST_URI = "<h1 style='border: 1px solid red'>Header</h1>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { toolbox, inspector, view } = await openRuleView(); + + info("Test autocompletion after 1st page load"); + await runAutocompletionTest(toolbox, inspector, view); + + info("Test autocompletion after page navigation"); + await reloadBrowser(); + await runAutocompletionTest(toolbox, inspector, view); +}); + +async function runAutocompletionTest(toolbox, inspector, view) { + info("Selecting the test node"); + await selectNode("h1", inspector); + + info("Focusing the css property editable field"); + const ruleEditor = getRuleViewRuleEditor(view, 0); + const editor = await focusNewRuleViewProperty(ruleEditor); + + info("Starting to test for css property completion"); + for (let i = 0; i < testData.length; i++) { + if (!testData[i].length) { + continue; + } + await testCompletion(testData[i], editor, view); + } +} + +async function testCompletion( + [key, completion, open, isSelected], + editor, + view +) { + info("Pressing key " + key); + info("Expecting " + completion); + info("Is popup opened: " + open); + info("Is item selected: " + isSelected); + + let onSuggest; + + if (/(right|back_space|escape)/gi.test(key)) { + info("Adding event listener for right|back_space|escape keys"); + onSuggest = once(editor.input, "keypress"); + } else { + info("Waiting for after-suggest event on the editor"); + onSuggest = editor.once("after-suggest"); + } + + // Also listening for popup opened/closed events if needed. + const popupEvent = open ? "popup-opened" : "popup-closed"; + const onPopupEvent = + editor.popup.isOpen !== open ? once(editor.popup, popupEvent) : null; + + info("Synthesizing key " + key); + EventUtils.synthesizeKey(key, {}, view.styleWindow); + + await onSuggest; + await onPopupEvent; + + info("Checking the state"); + if (completion !== null) { + is(editor.input.value, completion, "Correct value is autocompleted"); + } + if (!open) { + ok(!(editor.popup && editor.popup.isOpen), "Popup is closed"); + } else { + ok(editor.popup.isOpen, "Popup is open"); + is(editor.popup.selectedIndex !== -1, isSelected, "An item is selected"); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_completion-new-property_02.js b/devtools/client/inspector/rules/test/browser_rules_completion-new-property_02.js new file mode 100644 index 0000000000..60df90e410 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_completion-new-property_02.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that CSS property names and values are autocompleted and cycled +// correctly when editing new properties in the rule view. + +// format : +// [ +// what key to press, +// modifers, +// expected input box value after keypress, +// is the popup open, +// is a suggestion selected in the popup, +// expect ruleview-changed, +// ] + +const OPEN = true, + SELECTED = true, + CHANGE = true; +const testData = [ + ["d", {}, "display", OPEN, SELECTED, !CHANGE], + ["VK_TAB", {}, "", OPEN, !SELECTED, CHANGE], + ["VK_DOWN", {}, "block", OPEN, SELECTED, CHANGE], + ["n", {}, "none", !OPEN, !SELECTED, CHANGE], + ["VK_TAB", { shiftKey: true }, "display", !OPEN, !SELECTED, CHANGE], + ["VK_BACK_SPACE", {}, "", !OPEN, !SELECTED, !CHANGE], + ["o", {}, "overflow", OPEN, SELECTED, !CHANGE], + ["u", {}, "outline", OPEN, SELECTED, !CHANGE], + ["VK_DOWN", {}, "outline-color", OPEN, SELECTED, !CHANGE], + ["VK_TAB", {}, "none", !OPEN, !SELECTED, CHANGE], + ["r", {}, "rebeccapurple", OPEN, SELECTED, CHANGE], + ["VK_DOWN", {}, "red", OPEN, SELECTED, CHANGE], + ["VK_DOWN", {}, "revert", OPEN, SELECTED, CHANGE], + ["VK_DOWN", {}, "revert-layer", OPEN, SELECTED, CHANGE], + ["VK_DOWN", {}, "rgb", OPEN, SELECTED, CHANGE], + ["VK_DOWN", {}, "rgba", OPEN, SELECTED, CHANGE], + ["VK_DOWN", {}, "rosybrown", OPEN, SELECTED, CHANGE], + ["VK_DOWN", {}, "royalblue", OPEN, SELECTED, CHANGE], + ["VK_RIGHT", {}, "royalblue", !OPEN, !SELECTED, !CHANGE], + [" ", {}, "royalblue aliceblue", OPEN, SELECTED, CHANGE], + ["!", {}, "royalblue !important", !OPEN, !SELECTED, CHANGE], + ["VK_ESCAPE", {}, null, !OPEN, !SELECTED, CHANGE], +]; + +const TEST_URI = ` + <style type="text/css"> + h1 { + border: 1px solid red; + } + </style> + <h1>Test element</h1> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { toolbox, inspector, view } = await openRuleView(); + + info("Test autocompletion after 1st page load"); + await runAutocompletionTest(toolbox, inspector, view); + + info("Test autocompletion after page navigation"); + await reloadBrowser(); + await runAutocompletionTest(toolbox, inspector, view); +}); + +async function runAutocompletionTest(toolbox, inspector, view) { + info("Selecting the test node"); + await selectNode("h1", inspector); + + info("Focusing a new css property editable property"); + const ruleEditor = getRuleViewRuleEditor(view, 1); + let editor = await focusNewRuleViewProperty(ruleEditor); + + info("Starting to test for css property completion"); + for (let i = 0; i < testData.length; i++) { + // Re-define the editor at each iteration, because the focus may have moved + // from property to value and back + editor = inplaceEditor(view.styleDocument.activeElement); + await testCompletion(testData[i], editor, view); + } +} + +async function testCompletion( + [key, modifiers, completion, open, selected, change], + editor, + view +) { + info("Pressing key " + key); + info("Expecting " + completion); + info("Is popup opened: " + open); + info("Is item selected: " + selected); + + let onDone; + if (change) { + // If the key triggers a ruleview-changed, wait for that event, it will + // always be the last to be triggered and tells us when the preview has + // been done. + onDone = view.once("ruleview-changed"); + } else { + // Otherwise, expect an after-suggest event (except if the popup gets + // closed). + onDone = + key !== "VK_RIGHT" && key !== "VK_BACK_SPACE" + ? editor.once("after-suggest") + : null; + } + + // Also listening for popup opened/closed events if needed. + const popupEvent = open ? "popup-opened" : "popup-closed"; + const onPopupEvent = + editor.popup.isOpen !== open ? once(editor.popup, popupEvent) : null; + + info("Synthesizing key " + key + ", modifiers: " + Object.keys(modifiers)); + EventUtils.synthesizeKey(key, modifiers, view.styleWindow); + + // Flush the debounce for the preview text. + view.debounce.flush(); + + await onDone; + await onPopupEvent; + + info("Checking the state"); + if (completion !== null) { + // The key might have been a TAB or shift-TAB, in which case the editor will + // be a new one + editor = inplaceEditor(view.styleDocument.activeElement); + is(editor.input.value, completion, "Correct value is autocompleted"); + } + if (!open) { + ok(!(editor.popup && editor.popup.isOpen), "Popup is closed"); + } else { + ok(editor.popup.isOpen, "Popup is open"); + is(editor.popup.selectedIndex !== -1, selected, "An item is selected"); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_completion-new-property_03.js b/devtools/client/inspector/rules/test/browser_rules_completion-new-property_03.js new file mode 100644 index 0000000000..4f5577a21e --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_completion-new-property_03.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Regression test for a case where completing gave the wrong answer. +// See bug 1179318. + +const TEST_URI = "<h1 style='color: red'>Header</h1>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { toolbox, inspector, view } = await openRuleView(); + + info("Test autocompletion for background-color"); + await runAutocompletionTest(toolbox, inspector, view); +}); + +async function runAutocompletionTest(toolbox, inspector, view) { + info("Selecting the test node"); + await selectNode("h1", inspector); + + info("Focusing the new property editable field"); + const ruleEditor = getRuleViewRuleEditor(view, 0); + const editor = await focusNewRuleViewProperty(ruleEditor); + + info('Sending "background" to the editable field'); + for (const key of "background") { + const onSuggest = editor.once("after-suggest"); + EventUtils.synthesizeKey(key, {}, view.styleWindow); + await onSuggest; + } + + const itemIndex = 4; + + const bgcItem = editor.popup.getItemAtIndex(itemIndex); + is( + bgcItem.label, + "background-color", + "check the expected completion element" + ); + + editor.popup.selectedIndex = itemIndex; + + const node = editor.popup._list.childNodes[itemIndex]; + EventUtils.synthesizeMouseAtCenter(node, {}, editor.popup._window); + + is(editor.input.value, "background-color", "Correct value is autocompleted"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_completion-new-property_04.js b/devtools/client/inspector/rules/test/browser_rules_completion-new-property_04.js new file mode 100644 index 0000000000..9002f89822 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_completion-new-property_04.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that a new property editor supports the following flow: +// - type first character of property name +// - select an autocomplete suggestion !!with a mouse click!! +// - press RETURN to move to the property value +// - blur the input to commit + +const TEST_URI = + "<style>.title {color: red;}</style>" + "<h1 class=title>Header</h1>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Selecting the test node"); + await selectNode("h1", inspector); + + info("Focusing the new property editable field"); + const ruleEditor = getRuleViewRuleEditor(view, 1); + let editor = await focusNewRuleViewProperty(ruleEditor); + + info('Sending "background" to the editable field.'); + for (const key of "background") { + const onSuggest = editor.once("after-suggest"); + EventUtils.synthesizeKey(key, {}, view.styleWindow); + await onSuggest; + } + + const itemIndex = 4; + const bgcItem = editor.popup.getItemAtIndex(itemIndex); + is( + bgcItem.label, + "background-color", + "Check the expected completion element is background-color." + ); + editor.popup.selectItemAtIndex(itemIndex); + + info("Select the background-color suggestion with a mouse click."); + const onSuggest = editor.once("after-suggest"); + const node = editor.popup.elements.get(bgcItem); + EventUtils.synthesizeMouseAtCenter(node, {}, editor.popup._window); + + await onSuggest; + is(editor.input.value, "background-color", "Correct value is autocompleted"); + + info("Press RETURN to move the focus to a property value editor."); + let onModifications = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + + await onModifications; + + // Getting the new value editor after focus + editor = inplaceEditor(view.styleDocument.activeElement); + const textProp = ruleEditor.rule.textProps[1]; + + is(ruleEditor.rule.textProps.length, 2, "Created a new text property."); + is(ruleEditor.propertyList.children.length, 2, "Created a property editor."); + is( + editor, + inplaceEditor(textProp.editor.valueSpan), + "Editing the value span now." + ); + + info("Entering a value and blurring the field to expect a rule change"); + onModifications = view.once("ruleview-changed"); + + EventUtils.sendString("#F00"); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + + await onModifications; + + is(textProp.value, "#F00", "Text prop should have been changed."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_completion-new-property_multiline.js b/devtools/client/inspector/rules/test/browser_rules_completion-new-property_multiline.js new file mode 100644 index 0000000000..a899b03c7a --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_completion-new-property_multiline.js @@ -0,0 +1,140 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the behaviour of the CSS autocomplete for CSS value displayed on +// multiple lines. Expected behavior is: +// - UP/DOWN should navigate in the input and not increment/decrement numbers +// - typing a new value should still trigger the autocomplete +// - UP/DOWN when the autocomplete popup is displayed should cycle through +// suggestions + +const LONG_CSS_VALUE = + "transparent linear-gradient(0deg, blue 0%, white 5%, red 10%, blue 15%, " + + "white 20%, red 25%, blue 30%, white 35%, red 40%, blue 45%, white 50%, " + + "red 55%, blue 60%, white 65%, red 70%, blue 75%, white 80%, red 85%, " + + "blue 90%, white 95% ) repeat scroll 0% 0%"; + +const EXPECTED_CSS_VALUE = LONG_CSS_VALUE.replace("95%", "95%, red"); + +const TEST_URI = `<style> + .title { + background: ${LONG_CSS_VALUE}; + } + </style> + <h1 class=title>Header</h1>`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Selecting the test node"); + await selectNode("h1", inspector); + + info("Focusing the property editable field"); + const prop = getTextProperty(view, 1, { background: LONG_CSS_VALUE }); + + // Calculate offsets to click in the middle of the first box quad. + const rect = prop.editor.valueSpan.getBoundingClientRect(); + const firstQuadBounds = prop.editor.valueSpan.getBoxQuads()[0].getBounds(); + // For a multiline value, the first quad left edge is not aligned with the + // bounding rect left edge. The offsets expected by focusEditableField are + // relative to the bouding rectangle, so we need to translate the x-offset. + const x = firstQuadBounds.left - rect.left + firstQuadBounds.width / 2; + // The first quad top edge is aligned with the bounding top edge, no + // translation needed here. + const y = firstQuadBounds.height / 2; + + info("Focusing the css property editable value"); + const editor = await focusEditableField(view, prop.editor.valueSpan, x, y); + + info("Moving the caret next to a number"); + let pos = editor.input.value.indexOf("0deg") + 1; + editor.input.setSelectionRange(pos, pos); + is( + editor.input.value[editor.input.selectionStart - 1], + "0", + "Input caret is after a 0" + ); + + info("Check that UP/DOWN navigates in the input, even when next to a number"); + EventUtils.synthesizeKey("VK_DOWN", {}, view.styleWindow); + ok(editor.input.selectionStart !== pos, "Input caret moved"); + is(editor.input.value, LONG_CSS_VALUE, "Input value was not decremented."); + + info("Move the caret to the end of the gradient definition."); + pos = editor.input.value.indexOf("95%") + 3; + editor.input.setSelectionRange(pos, pos); + + info('Sending ", re" to the editable field.'); + for (const key of ", re") { + await synthesizeKeyForAutocomplete(key, editor, view.styleWindow); + } + + info("Check the autocomplete can still be displayed."); + ok(editor.popup && editor.popup.isOpen, "Autocomplete popup is displayed."); + is( + editor.popup.selectedIndex, + 0, + "Autocomplete has an item selected by default" + ); + + let item = editor.popup.getItemAtIndex(editor.popup.selectedIndex); + is( + item.label, + "rebeccapurple", + "Check autocomplete displays expected value." + ); + + info("Check autocomplete suggestions can be cycled using UP/DOWN arrows."); + + await synthesizeKeyForAutocomplete("VK_DOWN", editor, view.styleWindow); + is(editor.popup.selectedIndex, 1, "Using DOWN cycles autocomplete values."); + await synthesizeKeyForAutocomplete("VK_DOWN", editor, view.styleWindow); + is(editor.popup.selectedIndex, 2, "Using DOWN cycles autocomplete values."); + await synthesizeKeyForAutocomplete("VK_UP", editor, view.styleWindow); + is(editor.popup.selectedIndex, 1, "Using UP cycles autocomplete values."); + item = editor.popup.getItemAtIndex(editor.popup.selectedIndex); + is(item.label, "red", "Check autocomplete displays expected value."); + + info("Select the background-color suggestion with a mouse click."); + let onRuleviewChanged = view.once("ruleview-changed"); + const onSuggest = editor.once("after-suggest"); + + const node = editor.popup._list.childNodes[editor.popup.selectedIndex]; + EventUtils.synthesizeMouseAtCenter(node, {}, editor.popup._window); + + view.debounce.flush(); + await onSuggest; + await onRuleviewChanged; + + is( + editor.input.value, + EXPECTED_CSS_VALUE, + "Input value correctly autocompleted" + ); + + info("Press ESCAPE to leave the input."); + onRuleviewChanged = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_ESCAPE", {}, view.styleWindow); + await onRuleviewChanged; +}); + +/** + * Send the provided key to the currently focused input of the provided window. + * Wait for the editor to emit "after-suggest" to make sure the autocompletion + * process is finished. + * + * @param {String} key + * The key to send to the input. + * @param {InplaceEditor} editor + * The inplace editor which owns the focused input. + * @param {Window} win + * Window in which the key event will be dispatched. + */ +async function synthesizeKeyForAutocomplete(key, editor, win) { + const onSuggest = editor.once("after-suggest"); + EventUtils.synthesizeKey(key, {}, win); + await onSuggest; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_completion-on-empty.js b/devtools/client/inspector/rules/test/browser_rules_completion-on-empty.js new file mode 100644 index 0000000000..9a3348783f --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_completion-on-empty.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the suggest completion popup behavior of CSS property field. + +const TEST_URI = "<h1 style='color: lime'>Header</h1>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Selecting the test node"); + await selectNode("h1", inspector); + + const prop = getTextProperty(view, 0, { color: "lime" }); + + info("Test with css property value field"); + await testCompletion(view, prop.editor.valueSpan, true); + + info("Test with css property name field"); + await testCompletion(view, prop.editor.nameSpan, false); +}); + +async function testCompletion(view, target, isExpectedOpenPopup) { + const editor = await focusEditableField(view, target); + + info( + "Check the suggest completion popup visibility after clearing the field" + ); + + const onChanged = view.once("ruleview-changed"); + const popupEvent = isExpectedOpenPopup ? "popup-opened" : "popup-closed"; + const onPopupEvent = + editor.popup.isOpen === isExpectedOpenPopup + ? Promise.resolve() + : once(view.popup, popupEvent); + EventUtils.synthesizeKey("VK_BACK_SPACE", {}, view.styleWindow); + + // Flush the debounce to update the preview text. + view.debounce.flush(); + + await Promise.all([onChanged, onPopupEvent]); + is( + editor.popup.isOpen, + isExpectedOpenPopup, + "The popup visibility is correct" + ); + + if (editor.popup.isOpen) { + info("Close the suggest completion popup"); + const closingEvents = [ + view.once("ruleview-changed"), + once(view.popup, "popup-closed"), + ]; + EventUtils.synthesizeKey("VK_ESCAPE", {}, view.styleWindow); + await Promise.all(closingEvents); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_completion-popup-hidden-after-navigation.js b/devtools/client/inspector/rules/test/browser_rules_completion-popup-hidden-after-navigation.js new file mode 100644 index 0000000000..6f22ab0d17 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_completion-popup-hidden-after-navigation.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the ruleview autocomplete popup is hidden after page navigation. + +const TEST_URI = "<h1 style='font: 24px serif'></h1>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Test autocompletion popup is hidden after page navigation"); + + info("Selecting the test node"); + await selectNode("h1", inspector); + + info("Focusing the css property editable field"); + const propertyName = view.styleDocument.querySelectorAll( + ".ruleview-propertyname" + )[0]; + const editor = await focusEditableField(view, propertyName); + + info("Pressing key VK_DOWN"); + const onSuggest = once(editor.input, "keypress"); + const onPopupOpened = once(editor.popup, "popup-opened"); + + EventUtils.synthesizeKey("VK_DOWN", {}, view.styleWindow); + + info("Waiting for autocomplete popup to be displayed"); + await onSuggest; + await onPopupOpened; + + ok(view.popup && view.popup.isOpen, "Popup should be opened"); + + info("Reloading the page"); + await reloadBrowser(); + + ok(!(view.popup && view.popup.isOpen), "Popup should be closed"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_completion-shortcut.js b/devtools/client/inspector/rules/test/browser_rules_completion-shortcut.js new file mode 100644 index 0000000000..2d81a770ff --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_completion-shortcut.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the shortcut key for the suggest completion popup. + +const TEST_URI = "<h1 style='colo: lim'>Header</h1>"; +const TEST_SHORTCUTS = [ + { + key: " ", + modifiers: { ctrlKey: true }, + }, + { + key: "VK_DOWN", + modifiers: {}, + }, +]; + +add_task(async function () { + for (const shortcut of TEST_SHORTCUTS) { + info( + "Start to test for the shortcut " + + `key: "${shortcut.key}" modifiers: ${Object.keys(shortcut.modifiers)}` + ); + + const tab = await addTab( + "data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI) + ); + const { inspector, view } = await openRuleView(); + + info("Selecting the test node"); + await selectNode("h1", inspector); + + const prop = getTextProperty(view, 0, { colo: "lim" }); + + info("Test with css property name field"); + const nameEditor = await focusEditableField(view, prop.editor.nameSpan); + await testCompletion(shortcut, view, nameEditor, "color"); + + info("Test with css property value field"); + const valueEditor = inplaceEditor(view.styleDocument.activeElement); + await testCompletion(shortcut, view, valueEditor, "lime"); + + await removeTab(tab); + } +}); + +async function testCompletion(shortcut, view, editor, expectedValue) { + const spanEl = editor.elt; + + info("Move cursor to the end"); + EventUtils.synthesizeKey("VK_RIGHT", {}, view.styleWindow); + await waitUntil( + () => editor.input.selectionStart === editor.input.selectionEnd + ); + + info("Check whether the popup opens after sending the shortcut key"); + const onPopupOpened = once(view.popup, "popup-opened"); + EventUtils.synthesizeKey(shortcut.key, shortcut.modifiers, view.styleWindow); + await onPopupOpened; + ok(view.popup.isOpen, "The popup opened correctly"); + + info("Commit the suggestion"); + const onChanged = view.once("ruleview-changed"); + const onPopupClosed = once(view.popup, "popup-closed"); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + await Promise.all([onChanged, onPopupClosed]); + is(spanEl.textContent, expectedValue, "The value is set correctly"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_computed-lists_01.js b/devtools/client/inspector/rules/test/browser_rules_computed-lists_01.js new file mode 100644 index 0000000000..af7c03220a --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_computed-lists_01.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view shows expanders for properties with computed lists. + +var TEST_URI = ` + <style type="text/css"> + #testid { + margin: 4px; + top: 0px; + } + </style> + <h1 id="testid">Styled Node</h1> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testExpandersShown(inspector, view); +}); + +function testExpandersShown(inspector, view) { + const rule = getRuleViewRuleEditor(view, 1).rule; + + info("Check that the correct rules are visible"); + is(rule.selectorText, "#testid", "Second rule is #testid."); + is(rule.textProps[0].name, "margin", "First property is margin."); + is(rule.textProps[1].name, "top", "Second property is top."); + + info("Check that the expanders are shown correctly"); + is( + rule.textProps[0].editor.expander.style.display, + "inline-block", + "margin expander is displayed." + ); + is( + rule.textProps[1].editor.expander.style.display, + "none", + "top expander is hidden." + ); + ok( + !rule.textProps[0].editor.expander.hasAttribute("open"), + "margin computed list is closed." + ); + ok( + !rule.textProps[1].editor.expander.hasAttribute("open"), + "top computed list is closed." + ); + ok( + !rule.textProps[0].editor.computed.hasChildNodes(), + "margin computed list is empty before opening." + ); + ok( + !rule.textProps[1].editor.computed.hasChildNodes(), + "top computed list is empty." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_computed-lists_02.js b/devtools/client/inspector/rules/test/browser_rules_computed-lists_02.js new file mode 100644 index 0000000000..3d59baea68 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_computed-lists_02.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view computed lists can be expanded/collapsed, +// and contain the right subproperties. + +var TEST_URI = ` + <style type="text/css"> + #testid { + margin: 0px 1px 2px 3px; + top: 0px; + } + </style> + <h1 id="testid">Styled Node</h1> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testComputedList(inspector, view); +}); + +function testComputedList(inspector, view) { + const prop = getTextProperty(view, 1, { margin: "0px 1px 2px 3px" }); + const propEditor = prop.editor; + const expander = propEditor.expander; + + ok(!expander.hasAttribute("open"), "margin computed list is closed"); + + info("Opening the computed list of margin property"); + expander.click(); + ok(expander.hasAttribute("open"), "margin computed list is open"); + + const computed = propEditor.prop.computed; + const computedDom = propEditor.computed; + const propNames = [ + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + ]; + + is(computed.length, propNames.length, "There should be 4 computed values"); + is( + computedDom.children.length, + propNames.length, + "There should be 4 nodes in the DOM" + ); + + propNames.forEach((propName, i) => { + const propValue = i + "px"; + is( + computed[i].name, + propName, + "Computed property #" + i + " has name " + propName + ); + is( + computed[i].value, + propValue, + "Computed property #" + i + " has value " + propValue + ); + is( + computedDom.querySelectorAll(".ruleview-propertyname")[i].textContent, + propName, + "Computed property #" + i + " in DOM has correct name" + ); + is( + computedDom.querySelectorAll(".ruleview-propertyvalue")[i].textContent, + propValue, + "Computed property #" + i + " in DOM has correct value" + ); + }); + + info("Closing the computed list of margin property"); + expander.click(); + ok(!expander.hasAttribute("open"), "margin computed list is closed"); + + info("Opening the computed list of margin property"); + expander.click(); + ok(expander.hasAttribute("open"), "margin computed list is open"); + is(computed.length, propNames.length, "Still 4 computed values"); + is(computedDom.children.length, propNames.length, "Still 4 nodes in the DOM"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_computed-lists_03.js b/devtools/client/inspector/rules/test/browser_rules_computed-lists_03.js new file mode 100644 index 0000000000..f002a77c8b --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_computed-lists_03.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view does not show expanders for property values that contain +// variables. +// This should, in theory, be able to work, but the complexity outlined in +// https://bugzilla.mozilla.org/show_bug.cgi?id=1535315#c2 made us hide the expander +// instead. Also, this is what Chrome does too. + +var TEST_URI = ` + <style type="text/css"> + #testid { + --primary-color: black; + background: var(--primary-color, red); + } + </style> + <h1 id="testid">Styled Node</h1> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + const rule = getRuleViewRuleEditor(view, 1).rule; + + is( + rule.textProps[0].name, + "--primary-color", + "The first property is the variable" + ); + is(rule.textProps[1].name, "background", "The second property is background"); + + info("Check that the expander is hidden for the background property"); + is( + rule.textProps[1].editor.expander.style.display, + "none", + "Expander is hidden" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_conditional_import.js b/devtools/client/inspector/rules/test/browser_rules_conditional_import.js new file mode 100644 index 0000000000..86494cf676 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_conditional_import.js @@ -0,0 +1,117 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the rule-view content displays @import conditions. + +const TEST_URI = ` + <style type="text/css"> + @import url(${URL_ROOT_COM_SSL}doc_conditional_import.css) screen and (width > 10px); + @import url(${URL_ROOT_COM_SSL}doc_imported_named_layer.css) layer(importedLayer) (height > 42px); + @import url(${URL_ROOT_COM_SSL}doc_conditional_import.css) supports(display: flex); + @import url(${URL_ROOT_COM_SSL}doc_conditional_import.css) supports(display: flex) screen and (width > 10px); + @import url(${URL_ROOT_COM_SSL}doc_imported_named_layer.css) layer(importedLayerTwo) supports(display: flex) screen and (width > 10px); + @import url(${URL_ROOT_COM_SSL}doc_imported_no_layer.css); + </style> + <h1>Hello @import!</h1> +`; + +add_task(async function () { + // Enable the pref for @import supports() + await pushPref("layout.css.import-supports.enabled", true); + + await addTab( + "https://example.com/document-builder.sjs?html=" + + encodeURIComponent(TEST_URI) + ); + const { inspector, view } = await openRuleView(); + await selectNode("h1", inspector); + const expectedRules = [ + { selector: "element", ancestorRulesData: null }, + { + // Checking that we don't show @import for rules from imported stylesheet with no conditions + selector: `h1, [test-hint="imported-no-layer--no-rule-layer"]`, + ancestorRulesData: null, + }, + { + selector: `h1, [test-hint="imported-conditional"]`, + ancestorRulesData: [ + "@import supports(display: flex) screen and (width > 10px)", + ], + }, + { + selector: `h1, [test-hint="imported-conditional"]`, + ancestorRulesData: ["@import supports(display: flex)"], + }, + { + selector: `h1, [test-hint="imported-conditional"]`, + ancestorRulesData: ["@import screen and (width > 10px)"], + }, + { + selector: `h1, [test-hint="imported-named-layer--no-rule-layer"]`, + ancestorRulesData: [ + "@import supports(display: flex) screen and (width > 10px)", + "@layer importedLayerTwo", + "@media screen", + ], + }, + { + selector: `h1, [test-hint="imported-named-layer--named-layer"]`, + ancestorRulesData: [ + "@import supports(display: flex) screen and (width > 10px)", + "@layer importedLayerTwo", + "@media screen", + "@layer in-imported-stylesheet", + ], + }, + { + selector: `h1, [test-hint="imported-named-layer--no-rule-layer"]`, + ancestorRulesData: [ + "@import (height > 42px)", + "@layer importedLayer", + "@media screen", + ], + }, + { + selector: `h1, [test-hint="imported-named-layer--named-layer"]`, + ancestorRulesData: [ + "@import (height > 42px)", + "@layer importedLayer", + "@media screen", + "@layer in-imported-stylesheet", + ], + }, + ]; + + const rulesInView = Array.from(view.element.children); + is( + rulesInView.length, + expectedRules.length, + "All expected rules are displayed" + ); + + for (let i = 0; i < expectedRules.length; i++) { + const expectedRule = expectedRules[i]; + info(`Checking rule #${i}: ${expectedRule.selector}`); + + const selector = rulesInView[i].querySelector( + ".ruleview-selectorcontainer" + ).innerText; + is(selector, expectedRule.selector, `Expected selector for ${selector}`); + + if (expectedRule.ancestorRulesData == null) { + is( + getRuleViewAncestorRulesDataElementByIndex(view, i), + null, + `No ancestor rules data displayed for ${selector}` + ); + } else { + is( + getRuleViewAncestorRulesDataTextByIndex(view, i), + expectedRule.ancestorRulesData.join("\n"), + `Expected ancestor rules data displayed for ${selector}` + ); + } + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_container-queries.js b/devtools/client/inspector/rules/test/browser_rules_container-queries.js new file mode 100644 index 0000000000..63a13dbe47 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_container-queries.js @@ -0,0 +1,308 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the rule-view content is correct when the page defines container queries. +const TEST_URI = ` + <!DOCTYPE html> + <style type="text/css"> + body { + container: mycontainer / size; + } + + section { + container: mycontainer / inline-size; + } + + @container (width > 0px) { + h1, [test-hint="nocontainername"]{ + outline-color: chartreuse; + } + } + + @container unknowncontainer (min-width: 2vw) { + h1, [test-hint="unknowncontainer"] { + border-color: salmon; + } + } + + @container mycontainer (1px < width < 10000px) { + h1, [test-hint="container"] { + color: tomato; + } + + section, [test-hint="container-duplicate-name--body"] { + color: gold; + } + + div, [test-hint="container-duplicate-name--section"] { + color: salmon; + } + } + </style> + <body id=myBody class="a-container test"> + <h1>Hello @container!</h1> + <section> + <div> + <h2>You rock</h2> + </div> + </section> + </body> +`; + +add_task(async function () { + await pushPref("layout.css.container-queries.enabled", true); + + await addTab( + "https://example.com/document-builder.sjs?html=" + + encodeURIComponent(TEST_URI) + ); + const { inspector, view } = await openRuleView(); + + await selectNode("h1", inspector); + assertContainerQueryData(view, [ + { selector: "element", ancestorRulesData: null }, + { + selector: `h1, [test-hint="container"]`, + ancestorRulesData: ["@container mycontainer (1px < width < 10000px)"], + }, + { + selector: `h1, [test-hint="nocontainername"]`, + ancestorRulesData: ["@container (width > 0px)"], + }, + ]); + + info("Check that the query container tooltip works as expected"); + // Retrieve query containers sizes + const { bodyInlineSize, bodyBlockSize, sectionInlineSize } = + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const body = content.document.body; + const section = content.document.querySelector("section"); + return { + bodyInlineSize: content.getComputedStyle(body).inlineSize, + bodyBlockSize: content.getComputedStyle(body).blockSize, + sectionInlineSize: content.getComputedStyle(section).inlineSize, + }; + }); + + await assertQueryContainerTooltip({ + inspector, + view, + ruleIndex: 1, + expectedHeaderText: "<body#myBody.a-container.test>", + expectedBodyText: [ + "container-type: size", + `inline-size: ${bodyInlineSize}`, + `block-size: ${bodyBlockSize}`, + ], + }); + + info("Check that the 'jump to container' button works as expected"); + await assertJumpToContainerButton(inspector, view, 1, "body"); + + info("Check that inherited rules display container query data as expected"); + await selectNode("h2", inspector); + + assertContainerQueryData(view, [ + { selector: "element", ancestorRulesData: null }, + { + selector: `div, [test-hint="container-duplicate-name--section"]`, + ancestorRulesData: ["@container mycontainer (1px < width < 10000px)"], + }, + { + selector: `section, [test-hint="container-duplicate-name--body"]`, + ancestorRulesData: ["@container mycontainer (1px < width < 10000px)"], + }, + ]); + + info( + "Check that the query container tooltip works as expected for inherited rules as well" + ); + await assertQueryContainerTooltip({ + inspector, + view, + ruleIndex: 1, + expectedHeaderText: "<section>", + expectedBodyText: [ + "container-type: inline-size", + `inline-size: ${sectionInlineSize}`, + ], + }); + await assertQueryContainerTooltip({ + inspector, + view, + ruleIndex: 2, + expectedHeaderText: "<body#myBody.a-container.test>", + expectedBodyText: [ + "container-type: size", + `inline-size: ${bodyInlineSize}`, + `block-size: ${bodyBlockSize}`, + ], + }); + + info( + "Check that the 'jump to container' button works as expected for inherited rules" + ); + await assertJumpToContainerButton(inspector, view, 1, "section"); + + await selectNode("h2", inspector); + await assertJumpToContainerButton(inspector, view, 2, "body"); +}); + +function assertContainerQueryData(view, expectedRules) { + const rulesInView = Array.from( + view.element.querySelectorAll(".ruleview-rule") + ); + + is( + rulesInView.length, + expectedRules.length, + "All expected rules are displayed" + ); + + for (let i = 0; i < expectedRules.length; i++) { + const expectedRule = expectedRules[i]; + info(`Checking rule #${i}: ${expectedRule.selector}`); + + const selector = rulesInView[i].querySelector( + ".ruleview-selectorcontainer" + ).innerText; + is(selector, expectedRule.selector, `Expected selector for ${selector}`); + + const ancestorDataEl = getRuleViewAncestorRulesDataElementByIndex(view, i); + + if (expectedRule.ancestorRulesData == null) { + is( + ancestorDataEl, + null, + `No ancestor rules data displayed for ${selector}` + ); + } else { + is( + ancestorDataEl?.innerText, + expectedRule.ancestorRulesData.join("\n"), + `Expected ancestor rules data displayed for ${selector}` + ); + ok( + ancestorDataEl.querySelector(".container-query .open-inspector") !== + null, + "An icon is displayed to select the container in the markup view" + ); + } + } +} + +async function assertJumpToContainerButton( + inspector, + view, + ruleIndex, + expectedSelectedNodeAfterClick +) { + const selectContainerButton = getRuleViewAncestorRulesDataElementByIndex( + view, + ruleIndex + ).querySelector(".open-inspector"); + + // Ensure that the button can be targetted from EventUtils. + selectContainerButton.scrollIntoView(); + + const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } = + getHighlighterTestHelpers(inspector); + + const onNodeHighlight = waitForHighlighterTypeShown( + inspector.highlighters.TYPES.BOXMODEL + ); + EventUtils.synthesizeMouseAtCenter( + selectContainerButton, + { type: "mouseover" }, + selectContainerButton.ownerDocument.defaultView + ); + const { nodeFront: highlightedNodeFront } = await onNodeHighlight; + is( + highlightedNodeFront.displayName, + expectedSelectedNodeAfterClick, + "The correct node was highlighted" + ); + + const onceNewNodeFront = inspector.selection.once("new-node-front"); + const onNodeUnhighlight = waitForHighlighterTypeHidden( + inspector.highlighters.TYPES.BOXMODEL + ); + + EventUtils.synthesizeMouseAtCenter( + selectContainerButton, + {}, + selectContainerButton.ownerDocument.defaultView + ); + + const nodeFront = await onceNewNodeFront; + is( + nodeFront.displayName, + expectedSelectedNodeAfterClick, + "The correct node has been selected" + ); + + await onNodeUnhighlight; + ok("Highlighter was hidden when clicking on icon"); +} + +async function assertQueryContainerTooltip({ + inspector, + view, + ruleIndex, + expectedHeaderText, + expectedBodyText, +}) { + const tooltipTriggerEl = getRuleViewAncestorRulesDataElementByIndex( + view, + ruleIndex + ).querySelector(".container-query-declaration"); + + // Ensure that the element can be targetted from EventUtils. + tooltipTriggerEl.scrollIntoView(); + + const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } = + getHighlighterTestHelpers(inspector); + + const onNodeHighlight = waitForHighlighterTypeShown( + inspector.highlighters.TYPES.BOXMODEL + ); + + const tooltip = view.tooltips.getTooltip("interactiveTooltip"); + const onTooltipReady = tooltip.once("shown"); + EventUtils.synthesizeMouseAtCenter( + tooltipTriggerEl, + { type: "mousemove" }, + tooltipTriggerEl.ownerDocument.defaultView + ); + await onTooltipReady; + await onNodeHighlight; + + is( + tooltip.panel.querySelector("header").textContent, + expectedHeaderText, + "Tooltip has expected header content" + ); + + const lis = Array.from(tooltip.panel.querySelectorAll("li")).map( + li => li.textContent + ); + Assert.deepEqual(lis, expectedBodyText, "Tooltip has expected body items"); + + info("Hide the tooltip"); + const onHidden = tooltip.once("hidden"); + const onNodeUnhighlight = waitForHighlighterTypeHidden( + inspector.highlighters.TYPES.BOXMODEL + ); + // Move the mouse elsewhere to hide the tooltip + EventUtils.synthesizeMouse( + tooltipTriggerEl.ownerDocument.body, + 1, + 1, + { type: "mousemove" }, + tooltipTriggerEl.ownerDocument.defaultView + ); + await onHidden; + await onNodeUnhighlight; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_content_01.js b/devtools/client/inspector/rules/test/browser_rules_content_01.js new file mode 100644 index 0000000000..ef3acbfd5c --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_content_01.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the rule-view content is correct + +const TEST_URI = ` + <style type="text/css"> + @media screen and (min-width: 10px) { + #testid { + background-color: blue; + } + } + .testclass, .unmatched { + background-color: green; + } + </style> + <div id="testid" class="testclass">Styled Node</div> + <div id="testid2">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await selectNode("#testid", inspector); + is( + view.element.querySelectorAll("#ruleview-no-results").length, + 0, + "After a highlight, no longer has a no-results element." + ); + + await clearCurrentNodeSelection(inspector); + is( + view.element.querySelectorAll("#ruleview-no-results").length, + 1, + "After highlighting null, has a no-results element again." + ); + + await selectNode("#testid", inspector); + + let linkText = getRuleViewLinkTextByIndex(view, 1); + is(linkText, "inline:3", "link text at index 1 has expected content."); + + const mediaText = getRuleViewAncestorRulesDataTextByIndex(view, 1); + is( + mediaText, + "@media screen and (min-width: 10px)", + "media text at index 1 has expected content" + ); + + linkText = getRuleViewLinkTextByIndex(view, 2); + is(linkText, "inline:7", "link text at index 2 has expected content."); + is( + getRuleViewAncestorRulesDataElementByIndex(view, 2), + null, + "There is no media text element for rule at index 2" + ); + + const selector = getRuleViewRuleEditor(view, 2).selectorText; + is( + selector.querySelector(".ruleview-selector-matched").textContent, + ".testclass", + ".textclass should be matched." + ); + is( + selector.querySelector(".ruleview-selector-unmatched").textContent, + ".unmatched", + ".unmatched should not be matched." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_content_02.js b/devtools/client/inspector/rules/test/browser_rules_content_02.js new file mode 100644 index 0000000000..5e48169625 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_content_02.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the rule-view content when the inspector gets opened via the page +// ctx-menu "inspect element" + +const CONTENT = ` + <body style="color:red;"> + <div style="color:blue;"> + <p style="color:green;"> + <span style="color:yellow;">test element</span> + </p> + </div> + </body> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + CONTENT); + + // const commands = await CommandsFactory.forTab(tab); + // // Initialize the TargetCommands which require some async stuff to be done + // // before being fully ready. This will define the `targetCommand.targetFront` attribute. + // await commands.targetCommand.startListening(); + const inspector = await clickOnInspectMenuItem("span"); + + checkRuleViewContent(inspector.getPanel("ruleview").view); +}); + +function checkRuleViewContent({ styleDocument }) { + info("Making sure the rule-view contains the expected content"); + + const headers = [...styleDocument.querySelectorAll(".ruleview-header")]; + is(headers.length, 3, "There are 3 headers for inherited rules"); + + is( + headers[0].textContent, + STYLE_INSPECTOR_L10N.getFormatStr("rule.inheritedFrom", "p"), + "The first header is correct" + ); + is( + headers[1].textContent, + STYLE_INSPECTOR_L10N.getFormatStr("rule.inheritedFrom", "div"), + "The second header is correct" + ); + is( + headers[2].textContent, + STYLE_INSPECTOR_L10N.getFormatStr("rule.inheritedFrom", "body"), + "The third header is correct" + ); + + const rules = styleDocument.querySelectorAll(".ruleview-rule"); + is(rules.length, 4, "There are 4 rules in the view"); + + for (const rule of rules) { + const selector = rule.querySelector(".ruleview-selectorcontainer"); + is( + selector.textContent, + STYLE_INSPECTOR_L10N.getStr("rule.sourceElement"), + "The rule's selector is correct" + ); + + const propertyNames = [...rule.querySelectorAll(".ruleview-propertyname")]; + is(propertyNames.length, 1, "There's only one property name, as expected"); + + const propertyValues = [ + ...rule.querySelectorAll(".ruleview-propertyvalue"), + ]; + is( + propertyValues.length, + 1, + "There's only one property value, as expected" + ); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_copy_styles.js b/devtools/client/inspector/rules/test/browser_rules_copy_styles.js new file mode 100644 index 0000000000..01fe365841 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_copy_styles.js @@ -0,0 +1,359 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the behaviour of the copy styles context menu items in the rule + * view. + */ + +const osString = Services.appinfo.OS; + +const TEST_URI = URL_ROOT_SSL + "doc_copystyles.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const ruleEditor = getRuleViewRuleEditor(view, 1); + + const data = [ + { + desc: "Test Copy Property Name", + node: ruleEditor.rule.textProps[0].editor.nameSpan, + menuItemLabel: "styleinspector.contextmenu.copyPropertyName", + expectedPattern: "color", + visible: { + copyLocation: false, + copyDeclaration: true, + copyPropertyName: true, + copyPropertyValue: false, + copySelector: false, + copyRule: true, + }, + }, + { + desc: "Test Copy Property Value", + node: ruleEditor.rule.textProps[2].editor.valueSpan, + menuItemLabel: "styleinspector.contextmenu.copyPropertyValue", + expectedPattern: "12px", + visible: { + copyLocation: false, + copyDeclaration: true, + copyPropertyName: false, + copyPropertyValue: true, + copySelector: false, + copyRule: true, + }, + }, + { + desc: "Test Copy Property Value with Priority", + node: ruleEditor.rule.textProps[3].editor.valueSpan, + menuItemLabel: "styleinspector.contextmenu.copyPropertyValue", + expectedPattern: "#00F !important", + visible: { + copyLocation: false, + copyDeclaration: true, + copyPropertyName: false, + copyPropertyValue: true, + copySelector: false, + copyRule: true, + }, + }, + { + desc: "Test Copy Property Declaration", + node: ruleEditor.rule.textProps[2].editor.nameSpan, + menuItemLabel: "styleinspector.contextmenu.copyDeclaration", + expectedPattern: "font-size: 12px;", + visible: { + copyLocation: false, + copyDeclaration: true, + copyPropertyName: true, + copyPropertyValue: false, + copySelector: false, + copyRule: true, + }, + }, + { + desc: "Test Copy Property Declaration with Priority", + node: ruleEditor.rule.textProps[3].editor.nameSpan, + menuItemLabel: "styleinspector.contextmenu.copyDeclaration", + expectedPattern: "border-color: #00F !important;", + visible: { + copyLocation: false, + copyDeclaration: true, + copyPropertyName: true, + copyPropertyValue: false, + copySelector: false, + copyRule: true, + }, + }, + { + desc: "Test Copy Rule", + node: ruleEditor.rule.textProps[2].editor.nameSpan, + menuItemLabel: "styleinspector.contextmenu.copyRule", + expectedPattern: + "#testid {[\\r\\n]+" + + "\tcolor: #F00;[\\r\\n]+" + + "\tbackground-color: #00F;[\\r\\n]+" + + "\tfont-size: 12px;[\\r\\n]+" + + "\tborder-color: #00F !important;[\\r\\n]+" + + '\t--var: "\\*/";[\\r\\n]+' + + "}", + visible: { + copyLocation: false, + copyDeclaration: true, + copyPropertyName: true, + copyPropertyValue: false, + copySelector: false, + copyRule: true, + }, + }, + { + desc: "Test Copy Selector", + node: ruleEditor.selectorText, + menuItemLabel: "styleinspector.contextmenu.copySelector", + expectedPattern: "html, body, #testid", + visible: { + copyLocation: false, + copyDeclaration: false, + copyPropertyName: false, + copyPropertyValue: false, + copySelector: true, + copyRule: true, + }, + }, + { + desc: "Test Copy Location", + node: ruleEditor.source, + menuItemLabel: "styleinspector.contextmenu.copyLocation", + expectedPattern: + "https://example.com/browser/devtools/client/" + + "inspector/rules/test/doc_copystyles.css", + visible: { + copyLocation: true, + copyDeclaration: false, + copyPropertyName: false, + copyPropertyValue: false, + copySelector: false, + copyRule: true, + }, + }, + { + async setup() { + await disableProperty(view, 0); + }, + desc: "Test Copy Rule with Disabled Property", + node: ruleEditor.rule.textProps[2].editor.nameSpan, + menuItemLabel: "styleinspector.contextmenu.copyRule", + expectedPattern: + "#testid {[\\r\\n]+" + + "\t/\\* color: #F00; \\*/[\\r\\n]+" + + "\tbackground-color: #00F;[\\r\\n]+" + + "\tfont-size: 12px;[\\r\\n]+" + + "\tborder-color: #00F !important;[\\r\\n]+" + + '\t--var: "\\*/";[\\r\\n]+' + + "}", + visible: { + copyLocation: false, + copyDeclaration: true, + copyPropertyName: true, + copyPropertyValue: false, + copySelector: false, + copyRule: true, + }, + }, + { + async setup() { + await disableProperty(view, 4); + }, + desc: "Test Copy Rule with Disabled Property with Comment", + node: ruleEditor.rule.textProps[2].editor.nameSpan, + menuItemLabel: "styleinspector.contextmenu.copyRule", + expectedPattern: + "#testid {[\\r\\n]+" + + "\t/\\* color: #F00; \\*/[\\r\\n]+" + + "\tbackground-color: #00F;[\\r\\n]+" + + "\tfont-size: 12px;[\\r\\n]+" + + "\tborder-color: #00F !important;[\\r\\n]+" + + '\t/\\* --var: "\\*\\\\/"; \\*/[\\r\\n]+' + + "}", + visible: { + copyLocation: false, + copyDeclaration: true, + copyPropertyName: true, + copyPropertyValue: false, + copySelector: false, + copyRule: true, + }, + }, + { + desc: "Test Copy Property Declaration with Disabled Property", + node: ruleEditor.rule.textProps[0].editor.nameSpan, + menuItemLabel: "styleinspector.contextmenu.copyDeclaration", + expectedPattern: "/\\* color: #F00; \\*/", + visible: { + copyLocation: false, + copyDeclaration: true, + copyPropertyName: true, + copyPropertyValue: false, + copySelector: false, + copyRule: true, + }, + }, + ]; + + for (const { + setup, + desc, + node, + menuItemLabel, + expectedPattern, + visible, + } of data) { + if (setup) { + await setup(); + } + + info(desc); + await checkCopyStyle(view, node, menuItemLabel, expectedPattern, visible); + } +}); + +async function checkCopyStyle( + view, + node, + menuItemLabel, + expectedPattern, + visible +) { + const allMenuItems = openStyleContextMenuAndGetAllItems(view, node); + const menuItem = allMenuItems.find( + item => item.label === STYLE_INSPECTOR_L10N.getStr(menuItemLabel) + ); + const menuitemCopy = allMenuItems.find( + item => + item.label === + STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copy") + ); + const menuitemCopyLocation = allMenuItems.find( + item => + item.label === + STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copyLocation") + ); + const menuitemCopyDeclaration = allMenuItems.find( + item => + item.label === + STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copyDeclaration") + ); + const menuitemCopyPropertyName = allMenuItems.find( + item => + item.label === + STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copyPropertyName") + ); + const menuitemCopyPropertyValue = allMenuItems.find( + item => + item.label === + STYLE_INSPECTOR_L10N.getStr( + "styleinspector.contextmenu.copyPropertyValue" + ) + ); + const menuitemCopySelector = allMenuItems.find( + item => + item.label === + STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copySelector") + ); + const menuitemCopyRule = allMenuItems.find( + item => + item.label === + STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copyRule") + ); + + ok(menuitemCopy.disabled, "Copy disabled is as expected: true"); + ok(menuitemCopy.visible, "Copy visible is as expected: true"); + + is( + menuitemCopyLocation.visible, + visible.copyLocation, + "Copy Location visible attribute is as expected: " + visible.copyLocation + ); + + is( + menuitemCopyDeclaration.visible, + visible.copyDeclaration, + "Copy Property Declaration visible attribute is as expected: " + + visible.copyDeclaration + ); + + is( + menuitemCopyPropertyName.visible, + visible.copyPropertyName, + "Copy Property Name visible attribute is as expected: " + + visible.copyPropertyName + ); + + is( + menuitemCopyPropertyValue.visible, + visible.copyPropertyValue, + "Copy Property Value visible attribute is as expected: " + + visible.copyPropertyValue + ); + + is( + menuitemCopySelector.visible, + visible.copySelector, + "Copy Selector visible attribute is as expected: " + visible.copySelector + ); + + is( + menuitemCopyRule.visible, + visible.copyRule, + "Copy Rule visible attribute is as expected: " + visible.copyRule + ); + + try { + await waitForClipboardPromise( + () => menuItem.click(), + () => checkClipboardData(expectedPattern) + ); + } catch (e) { + failedClipboard(expectedPattern); + } +} + +async function disableProperty(view, index) { + const ruleEditor = getRuleViewRuleEditor(view, 1); + const textProp = ruleEditor.rule.textProps[index]; + await togglePropStatus(view, textProp); +} + +function checkClipboardData(expectedPattern) { + const actual = SpecialPowers.getClipboardData("text/plain"); + const expectedRegExp = new RegExp(expectedPattern, "g"); + return expectedRegExp.test(actual); +} + +function failedClipboard(expectedPattern) { + // Format expected text for comparison + const terminator = osString == "WINNT" ? "\r\n" : "\n"; + expectedPattern = expectedPattern.replace(/\[\\r\\n\][+*]/g, terminator); + expectedPattern = expectedPattern.replace(/\\\(/g, "("); + expectedPattern = expectedPattern.replace(/\\\)/g, ")"); + + let actual = SpecialPowers.getClipboardData("text/plain"); + + // Trim the right hand side of our strings. This is because expectedPattern + // accounts for windows sometimes adding a newline to our copied data. + expectedPattern = expectedPattern.trimRight(); + actual = actual.trimRight(); + + ok( + false, + "Clipboard text does not match expected " + + "results (escaped for accurate comparison):\n" + ); + info("Actual: " + escape(actual)); + info("Expected: " + escape(expectedPattern)); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_css-compatibility-add-rename-rule.js b/devtools/client/inspector/rules/test/browser_rules_css-compatibility-add-rename-rule.js new file mode 100644 index 0000000000..510a558e7b --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_css-compatibility-add-rename-rule.js @@ -0,0 +1,125 @@ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test - Add and rename rules +// Test the correctness of the compatibility +// status when the incompatible rules are added +// or renamed to another universally compatible +// rule + +const TEST_URI = ` +<style> + body { + user-select: none; + text-decoration-skip: none; + clip: auto; + } +</style> +<body> +</body>`; + +const TEST_DATA_INITIAL = [ + { + selector: "body", + rules: [ + {}, + { + "user-select": { + value: "none", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.default, + }, + "text-decoration-skip": { + value: "none", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.experimental, + }, + clip: { + value: "auto", + expected: COMPATIBILITY_TOOLTIP_MESSAGE["deprecated-supported"], + }, + }, + ], + }, +]; + +const TEST_DATA_ADD_RULE = [ + { + selector: "body", + rules: [ + { + "-moz-float-edge": { + value: "content-box", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.deprecated, + }, + }, + { + "user-select": { + value: "none", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.default, + }, + "text-decoration-skip": { + value: "none", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.experimental, + }, + clip: { + value: "auto", + expected: COMPATIBILITY_TOOLTIP_MESSAGE["deprecated-supported"], + }, + }, + ], + }, +]; + +const TEST_DATA_RENAME_RULE = [ + { + selector: "body", + rules: [ + { + "-moz-float-edge": { + value: "content-box", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.deprecated, + }, + }, + { + "background-color": { + value: "green", + }, + "text-decoration-skip": { + value: "none", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.experimental, + }, + clip: { + value: "auto", + expected: COMPATIBILITY_TOOLTIP_MESSAGE["deprecated-supported"], + }, + }, + ], + }, +]; + +add_task(async function () { + await pushPref( + "devtools.inspector.ruleview.inline-compatibility-warning.enabled", + true + ); + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + const userSelect = { "user-select": "none" }; + const backgroundColor = { "background-color": "green" }; + + info("Check initial compatibility issues"); + await runCSSCompatibilityTests(view, inspector, TEST_DATA_INITIAL); + + info( + "Add an inheritable incompatible rule and check the compatibility status" + ); + await addProperty(view, 0, "-moz-float-edge", "content-box"); + await runCSSCompatibilityTests(view, inspector, TEST_DATA_ADD_RULE); + + info("Rename user-select to color and check the compatibility status"); + await updateDeclaration(view, 1, userSelect, backgroundColor); + await runCSSCompatibilityTests(view, inspector, TEST_DATA_RENAME_RULE); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_css-compatibility-check-add-fix.js b/devtools/client/inspector/rules/test/browser_rules_css-compatibility-check-add-fix.js new file mode 100644 index 0000000000..17069756e2 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_css-compatibility-check-add-fix.js @@ -0,0 +1,134 @@ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test - Add fix for incompatible property +// For properties like "user-select", there exists an alias +// "-webkit-user-select", that is supported on all platform +// as a result of its popularity. If such a universally +// compatible alias exists, we shouldn't show compatibility +// warning for the base declaration. +// In this case "user-select" is marked compatible because the +// universally compatible alias "-webkit-user-select" exists +// alongside. + +const TARGET_BROWSERS = [ + { + // Chrome doesn't need any prefix for both user-select and text-size-adjust. + id: "chrome", + status: "current", + }, + { + // The safari_ios needs -webkit prefix for both properties. + id: "safari_ios", + status: "current", + }, +]; + +const TEST_URI = ` +<style> + div { + color: green; + background-color: black; + user-select: none; + text-size-adjust: none; + } +</style> +<div>`; + +const TEST_DATA_INITIAL = [ + { + rules: [ + {}, + { + color: { value: "green" }, + "background-color": { value: "black" }, + "user-select": { + value: "none", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.default, + }, + "text-size-adjust": { + value: "none", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.experimental, + }, + }, + ], + }, +]; + +const TEST_DATA_FIX_USER_SELECT = [ + { + rules: [ + {}, + { + color: { value: "green" }, + "background-color": { value: "black" }, + "user-select": { value: "none" }, + "-webkit-user-select": { value: "none" }, + "text-size-adjust": { + value: "none", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.experimental, + }, + }, + ], + }, +]; + +// text-size-adjust is an experimental property with aliases. +// Adding -webkit makes it compatible on all platforms but will +// still show an inline warning for its experimental status. +const TEST_DATA_FIX_EXPERIMENTAL_SUPPORTED = [ + { + rules: [ + {}, + { + color: { value: "green" }, + "background-color": { value: "black" }, + "user-select": { value: "none" }, + "-webkit-user-select": { value: "none" }, + "text-size-adjust": { + value: "none", + expected: COMPATIBILITY_TOOLTIP_MESSAGE["experimental-supported"], + }, + }, + ], + }, +]; + +add_task(async function () { + await pushPref( + "devtools.inspector.compatibility.target-browsers", + JSON.stringify(TARGET_BROWSERS) + ); + await pushPref( + "devtools.inspector.ruleview.inline-compatibility-warning.enabled", + true + ); + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + // We're only looking for properties on this single node so select it here instead of + // passing `selector` to `runCSSCompatibilityTests` (otherwise addition requests are sent + // to the server and we may end up with pending promises when the toolbox closes). + await selectNode("div", inspector); + + await runCSSCompatibilityTests(view, inspector, TEST_DATA_INITIAL); + + info( + 'Add -webkit-user-select: "none" which solves the compatibility issue from user-select' + ); + await addProperty(view, 1, "-webkit-user-select", "none"); + await runCSSCompatibilityTests(view, inspector, TEST_DATA_FIX_USER_SELECT); + + info( + 'Add -webkit-text-size-adjust: "none" fixing issue but leaving an inline warning of an experimental property' + ); + await addProperty(view, 1, "-webkit-text-size-adjust", "none"); + await runCSSCompatibilityTests( + view, + inspector, + TEST_DATA_FIX_EXPERIMENTAL_SUPPORTED + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_css-compatibility-learn-more-link.js b/devtools/client/inspector/rules/test/browser_rules_css-compatibility-learn-more-link.js new file mode 100644 index 0000000000..08e86e6202 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_css-compatibility-learn-more-link.js @@ -0,0 +1,66 @@ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the Learn More link is displayed when possible, +// and that it links to MDN or the spec if no MDN url is provided. + +const TEST_URI = ` +<style> + body { + user-select: none; + hyphenate-limit-chars: auto; + overflow-clip-box: padding-box; + } +</style> +<body> +</body>`; + +const TEST_DATA_INITIAL = [ + { + selector: "body", + rules: [ + {}, + { + "user-select": { + value: "none", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.default, + // MDN url + expectedLearnMoreUrl: + "https://developer.mozilla.org/docs/Web/CSS/user-select?utm_source=devtools&utm_medium=inspector-css-compatibility&utm_campaign=default", + }, + "hyphenate-limit-chars": { + value: "auto", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.default, + // No MDN url, but a spec one + expectedLearnMoreUrl: + "https://drafts.csswg.org/css-text-4/#propdef-hyphenate-limit-chars", + }, + "overflow-clip-box": { + expected: COMPATIBILITY_TOOLTIP_MESSAGE.default, + value: "padding-box", + // No MDN nor spec url + expectedLearnMoreUrl: null, + }, + }, + ], + }, +]; + +add_task(async function () { + await pushPref( + "devtools.inspector.ruleview.inline-compatibility-warning.enabled", + true + ); + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + // If the test fail because the properties used are no longer in the dataset, or they + // now have mdn/spec url although we expected them not to, uncomment the next line + // to get all the properties in the dataset that don't have a MDN url. + // logCssCompatDataPropertiesWithoutMDNUrl() + + await runCSSCompatibilityTests(view, inspector, TEST_DATA_INITIAL); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_css-compatibility-toggle-rules.js b/devtools/client/inspector/rules/test/browser_rules_css-compatibility-toggle-rules.js new file mode 100644 index 0000000000..7209adbc2e --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_css-compatibility-toggle-rules.js @@ -0,0 +1,150 @@ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test - Toggling rules linked to the element and the class +// Checking whether the compatibility warning icon is displayed +// correctly. +// If a rule is disabled, it is marked compatible to keep +// consistency with compatibility panel. +// We test both the compatible and incompatible rules here + +const TEST_URI = ` +<style> + div { + color: green; + background-color: black; + -moz-float-edge: content-box; + } +</style> +<div class="test-inline" style="color:pink; user-select:none;"></div> +<div class="test-class-linked"></div>`; + +const TEST_DATA_INITIAL = [ + { + selector: ".test-class-linked", + rules: [ + {}, + { + color: { value: "green" }, + "background-color": { value: "black" }, + "-moz-float-edge": { + value: "content-box", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.deprecated, + }, + }, + ], + }, + { + selector: ".test-inline", + rules: [ + { + color: { value: "pink" }, + "user-select": { + value: "none", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.default, + }, + }, + { + color: { value: "green" }, + "background-color": { value: "black" }, + "-moz-float-edge": { + value: "content-box", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.deprecated, + }, + }, + ], + }, +]; + +const TEST_DATA_TOGGLE_CLASS_DECLARATION = [ + { + selector: ".test-class-linked", + rules: [ + {}, + { + color: { value: "green" }, + "background-color": { value: "black" }, + "-moz-float-edge": { value: "content-box" }, + }, + ], + }, + { + selector: ".test-inline", + rules: [ + { + color: { value: "pink" }, + "user-select": { + value: "none", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.default, + }, + }, + { + color: { value: "green" }, + "background-color": { value: "black" }, + "-moz-float-edge": { value: "content-box" }, + }, + ], + }, +]; + +const TEST_DATA_TOGGLE_INLINE = [ + { + selector: ".test-class-linked", + rules: [ + {}, + { + color: { value: "green" }, + "background-color": { value: "black" }, + "-moz-float-edge": { value: "content-box" }, + }, + ], + }, + { + selector: ".test-inline", + rules: [ + { + color: { value: "pink" }, + "user-select": { value: "none" }, + }, + { + color: { value: "green" }, + "background-color": { value: "black" }, + "-moz-float-edge": { value: "content-box" }, + }, + ], + }, +]; + +add_task(async function () { + await pushPref( + "devtools.inspector.ruleview.inline-compatibility-warning.enabled", + true + ); + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + const mozFloatEdge = { "-moz-float-edge": "content-box" }; + const userSelect = { "user-select": "none" }; + + await runCSSCompatibilityTests(view, inspector, TEST_DATA_INITIAL); + + info( + 'Disable -moz-float-edge: "content-box" which is not cross browser compatible declaration' + ); + await toggleDeclaration(view, 1, mozFloatEdge); + await runCSSCompatibilityTests( + view, + inspector, + TEST_DATA_TOGGLE_CLASS_DECLARATION + ); + + info( + 'Toggle inline declaration "user-select": "none" and check the compatibility status' + ); + await selectNode(".test-inline", inspector); + await toggleDeclaration(view, 0, userSelect); + await runCSSCompatibilityTests(view, inspector, TEST_DATA_TOGGLE_INLINE); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_css-compatibility-tooltip-telemetry.js b/devtools/client/inspector/rules/test/browser_rules_css-compatibility-tooltip-telemetry.js new file mode 100644 index 0000000000..4580f819aa --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_css-compatibility-tooltip-telemetry.js @@ -0,0 +1,58 @@ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test - Toggling rules linked to the element and the class +// Checking whether the compatibility warning icon is displayed +// correctly. +// If a rule is disabled, it is marked compatible to keep +// consistency with compatibility panel. +// We test both the compatible and incompatible rules here + +const TEST_URI = ` +<style> + div { + -moz-float-edge: content-box; + } +</style> +<div></div>`; + +const TEST_DATA = [ + { + selector: "div", + rules: [ + {}, + { + "-moz-float-edge": { + value: "content-box", + expected: COMPATIBILITY_TOOLTIP_MESSAGE.deprecated, + }, + }, + ], + }, +]; + +add_task(async function () { + startTelemetry(); + + await pushPref( + "devtools.inspector.ruleview.inline-compatibility-warning.enabled", + true + ); + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Check correctness of data by toggling tooltip open"); + await runCSSCompatibilityTests(view, inspector, TEST_DATA); + + checkResults(); +}); + +function checkResults() { + info( + 'Check the telemetry against "devtools.tooltip.shown" for label "css-compatibility" and ensure it is set' + ); + checkTelemetry("devtools.tooltip.shown", "", 1, "css-compatibility"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_cssom.js b/devtools/client/inspector/rules/test/browser_rules_cssom.js new file mode 100644 index 0000000000..37a1214a52 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_cssom.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test to ensure that CSSOM doesn't make the rule view blow up. +// https://bugzilla.mozilla.org/show_bug.cgi?id=1224121 + +const TEST_URI = URL_ROOT + "doc_cssom.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + await selectNode("#target", inspector); + + const elementStyle = view._elementStyle; + let rule; + + rule = elementStyle.rules[1]; + is(rule.textProps.length, 1, "rule 1 should have one property"); + is(rule.textProps[0].name, "color", "the property should be 'color'"); + is(rule.ruleLine, -1, "the property has no source line"); + + rule = elementStyle.rules[2]; + is(rule.textProps.length, 1, "rule 2 should have one property"); + is( + rule.textProps[0].name, + "font-weight", + "the property should be 'font-weight'" + ); + is(rule.ruleLine, -1, "the property has no source line"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_cubicbezier-appears-on-swatch-click.js b/devtools/client/inspector/rules/test/browser_rules_cubicbezier-appears-on-swatch-click.js new file mode 100644 index 0000000000..8a967e1a8a --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_cubicbezier-appears-on-swatch-click.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that cubic-bezier pickers appear when clicking on cubic-bezier +// swatches. + +const TEST_URI = ` + <style type="text/css"> + div { + animation: move 3s linear; + transition: top 4s cubic-bezier(.1, 1.45, 1, -1.2); + } + .test { + animation-timing-function: ease-in-out; + transition-timing-function: ease-out; + } + </style> + <div class="test">Testing the cubic-bezier tooltip!</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + const swatches = []; + swatches.push( + getRuleViewProperty(view, "div", "animation").valueSpan.querySelector( + ".ruleview-bezierswatch" + ) + ); + swatches.push( + getRuleViewProperty(view, "div", "transition").valueSpan.querySelector( + ".ruleview-bezierswatch" + ) + ); + swatches.push( + getRuleViewProperty( + view, + ".test", + "animation-timing-function" + ).valueSpan.querySelector(".ruleview-bezierswatch") + ); + swatches.push( + getRuleViewProperty( + view, + ".test", + "transition-timing-function" + ).valueSpan.querySelector(".ruleview-bezierswatch") + ); + + for (const swatch of swatches) { + info("Testing that the cubic-bezier appears on cubicswatch click"); + await testAppears(view, swatch); + } +}); + +async function testAppears(view, swatch) { + ok(swatch, "The cubic-swatch exists"); + + const bezier = view.tooltips.getTooltip("cubicBezier"); + ok(bezier, "The rule-view has the expected cubicBezier property"); + + const bezierPanel = bezier.tooltip.panel; + ok(bezierPanel, "The XUL panel for the cubic-bezier tooltip exists"); + + const onBezierWidgetReady = bezier.once("ready"); + swatch.click(); + await onBezierWidgetReady; + + ok(true, "The cubic-bezier tooltip was shown on click of the cibuc swatch"); + ok( + !inplaceEditor(swatch.parentNode), + "The inplace editor wasn't shown as a result of the cibuc swatch click" + ); + await hideTooltipAndWaitForRuleViewChanged(bezier, view); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_cubicbezier-commit-on-ENTER.js b/devtools/client/inspector/rules/test/browser_rules_cubicbezier-commit-on-ENTER.js new file mode 100644 index 0000000000..5fbd1da25b --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_cubicbezier-commit-on-ENTER.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that a curve change in the cubic-bezier tooltip is committed when ENTER +// is pressed. + +const TEST_URI = ` + <style type="text/css"> + body { + transition: top 2s linear; + } + </style> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { view } = await openRuleView(); + + info("Getting the bezier swatch element"); + const swatch = getRuleViewProperty( + view, + "body", + "transition" + ).valueSpan.querySelector(".ruleview-bezierswatch"); + + await testPressingEnterCommitsChanges(swatch, view); +}); + +async function testPressingEnterCommitsChanges(swatch, ruleView) { + const bezierTooltip = ruleView.tooltips.getTooltip("cubicBezier"); + + info("Showing the tooltip"); + const onBezierWidgetReady = bezierTooltip.once("ready"); + swatch.click(); + await onBezierWidgetReady; + + const widget = await bezierTooltip.widget; + info("Simulating a change of curve in the widget"); + widget.coordinates = [0.1, 2, 0.9, -1]; + const expected = "cubic-bezier(0.1, 2, 0.9, -1)"; + + await waitForSuccess(async function () { + const func = await getComputedStyleProperty( + "body", + null, + "transition-timing-function" + ); + return func === expected; + }, "Waiting for the change to be previewed on the element"); + + ok( + getRuleViewProperty( + ruleView, + "body", + "transition" + ).valueSpan.textContent.includes("cubic-bezier("), + "The text of the timing-function was updated" + ); + + info("Sending RETURN key within the tooltip document"); + // Pressing RETURN ends up doing 2 rule-view updates, one for the preview and + // one for the commit when the tooltip closes. + const onRuleViewChanged = waitForNEvents(ruleView, "ruleview-changed", 2); + focusAndSendKey(widget.parent.ownerDocument.defaultView, "RETURN"); + await onRuleViewChanged; + + const style = await getComputedStyleProperty( + "body", + null, + "transition-timing-function" + ); + is(style, expected, "The element's timing-function was kept after RETURN"); + + const ruleViewStyle = getRuleViewProperty( + ruleView, + "body", + "transition" + ).valueSpan.textContent.includes("cubic-bezier("); + ok(ruleViewStyle, "The text of the timing-function was kept after RETURN"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_cubicbezier-revert-on-ESC.js b/devtools/client/inspector/rules/test/browser_rules_cubicbezier-revert-on-ESC.js new file mode 100644 index 0000000000..91371fa548 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_cubicbezier-revert-on-ESC.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that changes made to the cubic-bezier timing-function in the +// cubic-bezier tooltip are reverted when ESC is pressed. + +const TEST_URI = ` + <style type='text/css'> + body { + animation-timing-function: linear; + } + </style> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { view } = await openRuleView(); + await testPressingEscapeRevertsChanges(view); +}); + +async function testPressingEscapeRevertsChanges(view) { + const { propEditor } = await openCubicBezierAndChangeCoords( + view, + 1, + 0, + [0.1, 2, 0.9, -1], + { + selector: "body", + name: "animation-timing-function", + value: "cubic-bezier(0.1, 2, 0.9, -1)", + } + ); + + is( + propEditor.valueSpan.textContent, + "cubic-bezier(.1,2,.9,-1)", + "Got expected property value." + ); + + await escapeTooltip(view); + + await waitForComputedStyleProperty( + "body", + null, + "animation-timing-function", + "linear" + ); + is( + propEditor.valueSpan.textContent, + "linear", + "Got expected property value." + ); +} + +async function escapeTooltip(view) { + info("Pressing ESCAPE to close the tooltip"); + + const bezierTooltip = view.tooltips.getTooltip("cubicBezier"); + const widget = await bezierTooltip.widget; + const onHidden = bezierTooltip.tooltip.once("hidden"); + const onModifications = view.once("ruleview-changed"); + focusAndSendKey(widget.parent.ownerDocument.defaultView, "ESCAPE"); + await onHidden; + await onModifications; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_custom.js b/devtools/client/inspector/rules/test/browser_rules_custom.js new file mode 100644 index 0000000000..084a870eb8 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_custom.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = URL_ROOT + "doc_custom.html"; + +// Tests the display of custom declarations in the rule-view. + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + + await simpleCustomOverride(inspector, view); + await importantCustomOverride(inspector, view); + await disableCustomOverride(inspector, view); +}); + +async function simpleCustomOverride(inspector, view) { + await selectNode("#testidSimple", inspector); + + const idRule = getRuleViewRuleEditor(view, 1).rule; + const idRuleProp = idRule.textProps[0]; + + is( + idRuleProp.name, + "--background-color", + "First ID prop should be --background-color" + ); + ok(!idRuleProp.overridden, "ID prop should not be overridden."); + + const classRule = getRuleViewRuleEditor(view, 2).rule; + const classRuleProp = classRule.textProps[0]; + + is( + classRuleProp.name, + "--background-color", + "First class prop should be --background-color" + ); + ok(classRuleProp.overridden, "Class property should be overridden."); + + // Override --background-color by changing the element style. + const elementProp = await addProperty( + view, + 0, + "--background-color", + "purple" + ); + + is( + classRuleProp.name, + "--background-color", + "First element prop should now be --background-color" + ); + ok( + !elementProp.overridden, + "Element style property should not be overridden" + ); + ok(idRuleProp.overridden, "ID property should be overridden"); + ok(classRuleProp.overridden, "Class property should be overridden"); +} + +async function importantCustomOverride(inspector, view) { + await selectNode("#testidImportant", inspector); + + const idRule = getRuleViewRuleEditor(view, 1).rule; + const idRuleProp = idRule.textProps[0]; + ok(idRuleProp.overridden, "Not-important rule should be overridden."); + + const classRule = getRuleViewRuleEditor(view, 2).rule; + const classRuleProp = classRule.textProps[0]; + ok(!classRuleProp.overridden, "Important rule should not be overridden."); +} + +async function disableCustomOverride(inspector, view) { + await selectNode("#testidDisable", inspector); + + const idRule = getRuleViewRuleEditor(view, 1).rule; + const idRuleProp = idRule.textProps[0]; + + await togglePropStatus(view, idRuleProp); + + const classRule = getRuleViewRuleEditor(view, 2).rule; + const classRuleProp = classRule.textProps[0]; + ok( + !classRuleProp.overridden, + "Class prop should not be overridden after id prop was disabled." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_cycle-angle.js b/devtools/client/inspector/rules/test/browser_rules_cycle-angle.js new file mode 100644 index 0000000000..be85867c94 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_cycle-angle.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test cycling angle units in the rule view. + +const TEST_URI = ` + <style type="text/css"> + .turn { + filter: hue-rotate(1turn); + } + .deg { + filter: hue-rotate(180deg); + } + </style> + <body><div class=turn>Test turn</div><div class=deg>Test deg</div>cycling angle units in the rule view!</body> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await checkAngleCycling(inspector, view); + await checkAngleCyclingPersist(inspector, view); +}); + +async function checkAngleCycling(inspector, view) { + await selectNode(".turn", inspector); + + const container = ( + await getRuleViewProperty(view, ".turn", "filter", { wait: true }) + ).valueSpan; + const valueNode = container.querySelector(".ruleview-angle"); + const win = view.styleWindow; + + // turn + is(valueNode.textContent, "1turn", "Angle displayed as a turn value."); + + const tests = [ + { + value: "360deg", + comment: "Angle displayed as a degree value.", + }, + { + value: `${Math.round(Math.PI * 2 * 10000) / 10000}rad`, + comment: "Angle displayed as a radian value.", + }, + { + value: "400grad", + comment: "Angle displayed as a gradian value.", + }, + { + value: "1turn", + comment: "Angle displayed as a turn value again.", + }, + ]; + + for (const test of tests) { + await checkSwatchShiftClick(container, win, test.value, test.comment); + } +} + +async function checkAngleCyclingPersist(inspector, view) { + await selectNode(".deg", inspector); + let container = ( + await getRuleViewProperty(view, ".deg", "filter", { wait: true }) + ).valueSpan; + let valueNode = container.querySelector(".ruleview-angle"); + const win = view.styleWindow; + + is(valueNode.textContent, "180deg", "Angle displayed as a degree value."); + + await checkSwatchShiftClick( + container, + win, + `${Math.round(Math.PI * 10000) / 10000}rad`, + "Angle displayed as a radian value." + ); + + // Select the .turn div and reselect the .deg div to see + // if the new angle unit persisted + await selectNode(".turn", inspector); + await selectNode(".deg", inspector); + + // We have to query for the container and the swatch because + // they've been re-generated + container = ( + await getRuleViewProperty(view, ".deg", "filter", { wait: true }) + ).valueSpan; + valueNode = container.querySelector(".ruleview-angle"); + is( + valueNode.textContent, + `${Math.round(Math.PI * 10000) / 10000}rad`, + "Angle still displayed as a radian value." + ); +} + +async function checkSwatchShiftClick(container, win, expectedValue, comment) { + // Wait for 500ms before attempting a click to workaround frequent + // intermittents. + // + // See intermittent bug at https://bugzilla.mozilla.org/show_bug.cgi?id=1721938 + // See potentially related bugs: + // - browserLoaded + synthesizeMouse timeouts https://bugzilla.mozilla.org/show_bug.cgi?id=1727749 + // - mochitest general synthesize events issue https://bugzilla.mozilla.org/show_bug.cgi?id=1720248 + await wait(500); + + const swatch = container.querySelector(".ruleview-angleswatch"); + const valueNode = container.querySelector(".ruleview-angle"); + + const onUnitChange = swatch.once("unit-change"); + EventUtils.synthesizeMouseAtCenter( + swatch, + { + type: "mousedown", + shiftKey: true, + }, + win + ); + await onUnitChange; + is(valueNode.textContent, expectedValue, comment); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_cycle-color.js b/devtools/client/inspector/rules/test/browser_rules_cycle-color.js new file mode 100644 index 0000000000..3d8349ebc8 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_cycle-color.js @@ -0,0 +1,225 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test cycling color types in the rule view. + +const TEST_URI = ` + <style type="text/css"> + body { + color: #f00; + } + span { + color: blue; + border-color: #ff000080; + } + div { + color: green; + } + p { + color: blue; + } + </style> + <body> + <span>Test</span> + <div>cycling color types in the rule view!</div> + <p>cycling color and using the color picker</p> + </body> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await checkColorCycling(view); + await checkAlphaColorCycling(inspector, view); + await checkColorCyclingWithDifferentDefaultType(inspector, view); + await checkColorCyclingWithColorPicker(inspector, view); +}); + +async function checkColorCycling(view) { + const { valueSpan } = getRuleViewProperty(view, "body", "color"); + + checkColorValue( + valueSpan, + "#f00", + "Color displayed as a hex value, its authored type" + ); + + await runSwatchShiftClickTests(view, valueSpan, [ + { + value: "hsl(0, 100%, 50%)", + comment: "Color displayed as an HSL value", + }, + { + value: "rgb(255, 0, 0)", + comment: "Color displayed as an RGB value", + }, + { + value: "hwb(0 0% 0%)", + comment: "Color displayed as an HWB value.", + }, + { + value: "red", + comment: "Color displayed as a color name", + }, + { + value: "#f00", + comment: "Color displayed as an authored value", + }, + { + value: "hsl(0, 100%, 50%)", + comment: "Color displayed as an HSL value again", + }, + ]); +} + +async function checkAlphaColorCycling(inspector, view) { + await selectNode("span", inspector); + const { valueSpan } = getRuleViewProperty(view, "span", "border-color"); + + checkColorValue( + valueSpan, + "#ff000080", + "Color displayed as an alpha hex value, its authored type" + ); + + await runSwatchShiftClickTests(view, valueSpan, [ + { + value: "hsla(0, 100%, 50%, 0.5)", + comment: "Color displayed as an HSLa value", + }, + { + value: "rgba(255, 0, 0, 0.5)", + comment: "Color displayed as an RGBa value", + }, + { + value: "hwb(0 0% 0% / 0.5)", + comment: "Color displayed as an HWB value.", + }, + { + value: "#ff000080", + comment: "Color displayed as an alpha hex value again", + }, + ]); +} + +async function checkColorCyclingWithDifferentDefaultType(inspector, view) { + info("Change the default color type pref to hex"); + await pushPref("devtools.defaultColorUnit", "hex"); + + info( + "Select a new node that would normally have a color with a different type" + ); + await selectNode("div", inspector); + const { valueSpan } = getRuleViewProperty(view, "div", "color"); + + checkColorValue( + valueSpan, + "#008000", + "Color displayed as a hex value, which is the type just selected" + ); + + info("Cycle through color types again"); + await runSwatchShiftClickTests(view, valueSpan, [ + { + value: "hsl(120, 100%, 25.1%)", + comment: "Color displayed as an HSL value", + }, + { + value: "rgb(0, 128, 0)", + comment: "Color displayed as an RGB value", + }, + { + value: "hwb(120 0% 49.8%)", + comment: "Color displayed as an HWB value.", + }, + { + value: "green", + comment: "Color displayed as a color name", + }, + { + value: "#008000", + comment: "Color displayed as an authored value", + }, + { + value: "hsl(120, 100%, 25.1%)", + comment: "Color displayed as an HSL value again", + }, + ]); +} + +async function checkColorCyclingWithColorPicker(inspector, view) { + // Enforce hex format for this test + await pushPref("devtools.defaultColorUnit", "hex"); + + info("Select a new node for this test"); + await selectNode("p", inspector); + const { valueSpan } = getRuleViewProperty(view, "p", "color"); + + checkColorValue(valueSpan, "#00f", "Color has the expected initial value"); + + checkSwatchShiftClick( + view, + valueSpan, + "hsl(240, 100%, 50%)", + "Color has the expected value after a shift+click" + ); + + info("Opening the color picker"); + const swatchElement = valueSpan.querySelector(".ruleview-colorswatch"); + const picker = view.tooltips.getTooltip("colorPicker"); + const onColorPickerReady = picker.once("ready"); + swatchElement.click(); + await onColorPickerReady; + + info("Hide the color picker with escape"); + const cPicker = view.tooltips.getTooltip("colorPicker"); + const { spectrum } = cPicker; + const onHidden = cPicker.tooltip.once("hidden"); + const onModifications = view.once("ruleview-changed"); + EventUtils.sendKey("ESCAPE", spectrum.element.ownerDocument.defaultView); + await onHidden; + await onModifications; + + is( + swatchElement.parentNode.dataset.color, + "hsl(240, 100%, 50%)", + "data-color is still using the correct format" + ); +} + +async function runSwatchShiftClickTests(view, valueSpan, tests) { + for (const { value, comment } of tests) { + await checkSwatchShiftClick(view, valueSpan, value, comment); + } +} + +async function checkSwatchShiftClick(view, valueSpan, expectedValue, comment) { + const swatchNode = valueSpan.querySelector(".ruleview-colorswatch"); + const colorNode = valueSpan.querySelector(".ruleview-color"); + + info( + "Shift-click the color swatch and wait for the color type and ruleview to update" + ); + const onUnitChange = swatchNode.once("unit-change"); + + EventUtils.synthesizeMouseAtCenter( + swatchNode, + { + type: "mousedown", + shiftKey: true, + }, + view.styleWindow + ); + + await onUnitChange; + + is(colorNode.textContent, expectedValue, comment); +} + +function checkColorValue(valueSpan, expectedColorValue, comment) { + const colorNode = valueSpan.querySelector(".ruleview-color"); + is(colorNode.textContent, expectedColorValue, comment); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-display-grid-property.js b/devtools/client/inspector/rules/test/browser_rules_edit-display-grid-property.js new file mode 100644 index 0000000000..e3556ed8ff --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-display-grid-property.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the grid highlighter in the rule view and modifying the 'display: grid' +// declaration. + +const TEST_URI = ` + <style type='text/css'> + #grid { + display: grid; + } + </style> + <div id="grid"> + <div id="cell1">cell1</div> + <div id="cell2">cell2</div> + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = view.highlighters; + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.GRID; + const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } = + getHighlighterTestHelpers(inspector); + + await selectNode("#grid", inspector); + const container = ( + await getRuleViewProperty(view, "#grid", "display", { wait: true }) + ).valueSpan; + let gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + + info("Toggling ON the CSS grid highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterShown; + + info("Edit the 'grid' property value to 'block'."); + const editor = await focusEditableField(view, container); + const onHighlighterHidden = waitForHighlighterTypeHidden(HIGHLIGHTER_TYPE); + const onDone = view.once("ruleview-changed"); + editor.input.value = "block;"; + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + await onHighlighterHidden; + await onDone; + + info("Check the grid highlighter and grid toggle button are hidden."); + gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + ok(!gridToggle, "Grid highlighter toggle is not visible."); + ok(!highlighters.gridHighlighters.size, "No CSS grid highlighter is shown."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property-cancel.js b/devtools/client/inspector/rules/test/browser_rules_edit-property-cancel.js new file mode 100644 index 0000000000..443f8432f8 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property-cancel.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests editing a property name or value and escaping will revert the +// changes and restore the original value. + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: #00F; + } + </style> + <div id='testid'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const ruleEditor = getRuleViewRuleEditor(view, 1); + const prop = getTextProperty(view, 1, { "background-color": "#00F" }); + const propEditor = prop.editor; + + await focusEditableField(view, propEditor.nameSpan); + await sendKeysAndWaitForFocus(view, ruleEditor.element, ["DELETE", "ESCAPE"]); + + is( + propEditor.nameSpan.textContent, + "background-color", + "'background-color' property name is correctly set." + ); + is( + await getComputedStyleProperty("#testid", null, "background-color"), + "rgb(0, 0, 255)", + "#00F background color is set." + ); + + await focusEditableField(view, propEditor.valueSpan); + const onValueDeleted = view.once("ruleview-changed"); + await sendKeysAndWaitForFocus(view, ruleEditor.element, ["DELETE", "ESCAPE"]); + await onValueDeleted; + + is( + propEditor.valueSpan.textContent, + "#00F", + "'#00F' property value is correctly set." + ); + is( + await getComputedStyleProperty("#testid", null, "background-color"), + "rgb(0, 0, 255)", + "#00F background color is set." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property-click.js b/devtools/client/inspector/rules/test/browser_rules_edit-property-click.js new file mode 100644 index 0000000000..78fa56eb35 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property-click.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the property name and value editors can be triggered when +// clicking on the property-name, the property-value, the colon or semicolon. + +const TEST_URI = ` + <style type='text/css'> + #testid { + margin: 0; + } + </style> + <div id='testid'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testEditPropertyAndCancel(inspector, view); +}); + +async function testEditPropertyAndCancel(inspector, view) { + const ruleEditor = getRuleViewRuleEditor(view, 1); + const propEditor = getTextProperty(view, 1, { margin: "0" }).editor; + + info("Test editor is created when clicking on property name"); + await focusEditableField(view, propEditor.nameSpan); + ok(propEditor.nameSpan.inplaceEditor, "Editor created for property name"); + await sendKeysAndWaitForFocus(view, ruleEditor.element, ["ESCAPE"]); + + info("Test editor is created when clicking on ':' next to property name"); + const nameRect = propEditor.nameSpan.getBoundingClientRect(); + await focusEditableField(view, propEditor.nameSpan, nameRect.width + 1); + ok(propEditor.nameSpan.inplaceEditor, "Editor created for property name"); + await sendKeysAndWaitForFocus(view, ruleEditor.element, ["ESCAPE"]); + + info("Test editor is created when clicking on property value"); + await focusEditableField(view, propEditor.valueSpan); + ok(propEditor.valueSpan.inplaceEditor, "Editor created for property value"); + // When cancelling a value edition, the text-property-editor will trigger + // a modification to make sure the property is back to its original value + // => need to wait on "ruleview-changed" to avoid unhandled promises + let onRuleviewChanged = view.once("ruleview-changed"); + await sendKeysAndWaitForFocus(view, ruleEditor.element, ["ESCAPE"]); + await onRuleviewChanged; + + info("Test editor is created when clicking on ';' next to property value"); + const valueRect = propEditor.valueSpan.getBoundingClientRect(); + await focusEditableField(view, propEditor.valueSpan, valueRect.width + 1); + ok(propEditor.valueSpan.inplaceEditor, "Editor created for property value"); + // When cancelling a value edition, the text-property-editor will trigger + // a modification to make sure the property is back to its original value + // => need to wait on "ruleview-changed" to avoid unhandled promises + onRuleviewChanged = view.once("ruleview-changed"); + await sendKeysAndWaitForFocus(view, ruleEditor.element, ["ESCAPE"]); + await onRuleviewChanged; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property-commit.js b/devtools/client/inspector/rules/test/browser_rules_edit-property-commit.js new file mode 100644 index 0000000000..c27395a18d --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property-commit.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test original value is correctly displayed when ESCaping out of the +// inplace editor in the style inspector. + +const TEST_URI = ` + <style type='text/css'> + #testid { + color: #00F; + } + </style> + <div id='testid'>Styled Node</div> +`; + +// Test data format +// { +// value: what char sequence to type, +// commitKey: what key to type to "commit" the change, +// modifiers: commitKey modifiers, +// expected: what value is expected as a result +// } +const testData = [ + { + value: "red", + commitKey: "VK_ESCAPE", + modifiers: {}, + expected: "#00F", + }, + { + value: "red", + commitKey: "VK_RETURN", + modifiers: {}, + expected: "red", + }, + { + value: "invalid", + commitKey: "VK_RETURN", + modifiers: {}, + expected: "invalid", + }, + { + value: "blue", + commitKey: "VK_TAB", + modifiers: { shiftKey: true }, + expected: "blue", + }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + for (const data of testData) { + await runTestData(view, data); + } +}); + +async function runTestData(view, { value, commitKey, modifiers, expected }) { + const idRuleEditor = getRuleViewRuleEditor(view, 1); + const propEditor = idRuleEditor.rule.textProps[0].editor; + + info("Focusing the inplace editor field"); + + const editor = await focusEditableField(view, propEditor.valueSpan); + is( + inplaceEditor(propEditor.valueSpan), + editor, + "Focused editor should be the value span." + ); + + info("Entering test data " + value); + let onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.sendString(value, view.styleWindow); + view.debounce.flush(); + await onRuleViewChanged; + + info("Entering the commit key " + commitKey + " " + modifiers); + onRuleViewChanged = view.once("ruleview-changed"); + const onBlur = once(editor.input, "blur"); + EventUtils.synthesizeKey(commitKey, modifiers); + await onBlur; + await onRuleViewChanged; + + if (commitKey === "VK_ESCAPE") { + is( + propEditor.valueSpan.textContent, + expected, + "Value is as expected: " + expected + ); + } else { + is( + propEditor.valueSpan.textContent, + expected, + "Value is as expected: " + expected + ); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property-computed.js b/devtools/client/inspector/rules/test/browser_rules_edit-property-computed.js new file mode 100644 index 0000000000..cbcbc506fe --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property-computed.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the computed values of a style (the shorthand expansion) are +// properly updated after the style is changed. + +const TEST_URI = ` + <style type="text/css"> + #testid { + padding: 10px; + } + </style> + <div id="testid">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await editAndCheck(view); +}); + +async function editAndCheck(view) { + const prop = getTextProperty(view, 1, { padding: "10px" }); + const propEditor = prop.editor; + const newPaddingValue = "20px"; + + info("Focusing the inplace editor field"); + const editor = await focusEditableField(view, propEditor.valueSpan); + is( + inplaceEditor(propEditor.valueSpan), + editor, + "Focused editor should be the value span." + ); + + const onPropertyChange = waitForComputedStyleProperty( + "#testid", + null, + "padding-top", + newPaddingValue + ); + const onRefreshAfterPreview = once(view, "ruleview-changed"); + + info("Entering a new value"); + EventUtils.sendString(newPaddingValue, view.styleWindow); + + info( + "Waiting for the debounced previewValue to apply the " + + "changes to document" + ); + + view.debounce.flush(); + await onPropertyChange; + + info("Waiting for ruleview-refreshed after previewValue was applied."); + await onRefreshAfterPreview; + + const onBlur = once(editor.input, "blur"); + + info("Entering the commit key and finishing edit"); + EventUtils.synthesizeKey("KEY_Enter"); + + info("Waiting for blur on the field"); + await onBlur; + + info("Waiting for the style changes to be applied"); + await once(view, "ruleview-changed"); + + const computed = prop.computed; + const propNames = [ + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + ]; + + is(computed.length, propNames.length, "There should be 4 computed values"); + propNames.forEach((propName, i) => { + is( + computed[i].name, + propName, + "Computed property #" + i + " has name " + propName + ); + is( + computed[i].value, + newPaddingValue, + "Computed value of " + propName + " is as expected" + ); + }); + + propEditor.expander.click(); + const computedDom = propEditor.computed; + is( + computedDom.children.length, + propNames.length, + "There should be 4 nodes in the DOM" + ); + propNames.forEach((propName, i) => { + is( + computedDom.getElementsByClassName("ruleview-propertyvalue")[i] + .textContent, + newPaddingValue, + "Computed value of " + propName + " in DOM is as expected" + ); + }); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property-increments.js b/devtools/client/inspector/rules/test/browser_rules_edit-property-increments.js new file mode 100644 index 0000000000..2072ea5f96 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property-increments.js @@ -0,0 +1,820 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that increasing/decreasing values in rule view using +// arrow keys works correctly. + +// Bug 1275446 - This test happen to hit the default timeout on linux32 +requestLongerTimeout(2); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AppConstants: "resource://gre/modules/AppConstants.sys.mjs", +}); + +const TEST_URI = ` + <style> + #test { + margin-top: 0px; + padding-top: 0px; + color: #000000; + background-color: #000000; + background: none; + transition: initial; + z-index: 0; + opacity: 1; + line-height: 1; + --custom: 0; + } + </style> + <div id="test"></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + + const { inspector, view } = await openRuleView(); + await selectNode("#test", inspector); + + await testMarginIncrements(view); + await testVariousUnitIncrements(view); + await testHexIncrements(view); + await testAlphaHexIncrements(view); + await testRgbIncrements(view); + await testHslIncrements(view); + await testRgbCss4Increments(view); + await testHslCss4Increments(view); + await testHwbIncrements(view); + await testShorthandIncrements(view); + await testOddCases(view); + await testZeroValueIncrements(view); + await testOpacityIncrements(view); + await testLineHeightIncrements(view); + await testCssVariableIncrements(view); +}); + +async function testMarginIncrements(view) { + info("Testing keyboard increments on the margin property"); + + const marginPropEditor = getTextProperty(view, 1, { + "margin-top": "0px", + }).editor; + + await runIncrementTest(marginPropEditor, view, { + 1: { + ...getSmallIncrementKey(), + start: "0px", + end: "0.1px", + selectAll: true, + }, + 2: { start: "0px", end: "1px", selectAll: true }, + 3: { shift: true, start: "0px", end: "10px", selectAll: true }, + 4: { + down: true, + ...getSmallIncrementKey(), + start: "0.1px", + end: "0px", + selectAll: true, + }, + 5: { down: true, start: "0px", end: "-1px", selectAll: true }, + 6: { down: true, shift: true, start: "0px", end: "-10px", selectAll: true }, + 7: { + pageUp: true, + shift: true, + start: "0px", + end: "100px", + selectAll: true, + }, + 8: { + pageDown: true, + shift: true, + start: "0px", + end: "-100px", + selectAll: true, + }, + 9: { start: "0", end: "1px", selectAll: true }, + 10: { down: true, start: "0", end: "-1px", selectAll: true }, + }); +} + +async function testVariousUnitIncrements(view) { + info("Testing keyboard increments on values with various units"); + + const paddingPropEditor = getTextProperty(view, 1, { + "padding-top": "0px", + }).editor; + + await runIncrementTest(paddingPropEditor, view, { + 1: { start: "0px", end: "1px", selectAll: true }, + 2: { start: "0pt", end: "1pt", selectAll: true }, + 3: { start: "0pc", end: "1pc", selectAll: true }, + 4: { start: "0em", end: "1em", selectAll: true }, + 5: { start: "0%", end: "1%", selectAll: true }, + 6: { start: "0in", end: "1in", selectAll: true }, + 7: { start: "0cm", end: "1cm", selectAll: true }, + 8: { start: "0mm", end: "1mm", selectAll: true }, + 9: { start: "0ex", end: "1ex", selectAll: true }, + 10: { start: "0", end: "1px", selectAll: true }, + 11: { down: true, start: "0", end: "-1px", selectAll: true }, + }); +} + +async function testHexIncrements(view) { + info("Testing keyboard increments with hex colors"); + + const hexColorPropEditor = getTextProperty(view, 1, { + color: "#000000", + }).editor; + + await runIncrementTest(hexColorPropEditor, view, { + 1: { start: "#CCCCCC", end: "#CDCDCD", selectAll: true }, + 2: { shift: true, start: "#CCCCCC", end: "#DCDCDC", selectAll: true }, + 3: { start: "#CCCCCC", end: "#CDCCCC", selection: [1, 3] }, + 4: { shift: true, start: "#CCCCCC", end: "#DCCCCC", selection: [1, 3] }, + 5: { start: "#FFFFFF", end: "#FFFFFF", selectAll: true }, + 6: { + down: true, + shift: true, + start: "#000000", + end: "#000000", + selectAll: true, + }, + }); +} + +async function testAlphaHexIncrements(view) { + info("Testing keyboard increments with alpha hex colors"); + + const hexColorPropEditor = getTextProperty(view, 1, { + color: "#000000", + }).editor; + + await runIncrementTest(hexColorPropEditor, view, { + 1: { start: "#CCCCCCAA", end: "#CDCDCDAB", selectAll: true }, + 2: { shift: true, start: "#CCCCCCAA", end: "#DCDCDCBA", selectAll: true }, + 3: { start: "#CCCCCCAA", end: "#CDCCCCAA", selection: [1, 3] }, + 4: { shift: true, start: "#CCCCCCAA", end: "#DCCCCCAA", selection: [1, 3] }, + 5: { start: "#FFFFFFFF", end: "#FFFFFFFF", selectAll: true }, + 6: { + down: true, + shift: true, + start: "#00000000", + end: "#00000000", + selectAll: true, + }, + }); +} + +async function testRgbIncrements(view) { + info("Testing keyboard increments with rgb(a) colors"); + + const rgbColorPropEditor = getTextProperty(view, 1, { + "background-color": "#000000", + }).editor; + + await runIncrementTest(rgbColorPropEditor, view, { + 1: { start: "rgb(0,0,0)", end: "rgb(0,1,0)", selection: [6, 7] }, + 2: { + shift: true, + start: "rgb(0,0,0)", + end: "rgb(0,10,0)", + selection: [6, 7], + }, + 3: { start: "rgb(0,255,0)", end: "rgb(0,255,0)", selection: [6, 9] }, + 4: { + shift: true, + start: "rgb(0,250,0)", + end: "rgb(0,255,0)", + selection: [6, 9], + }, + 5: { + down: true, + start: "rgb(0,0,0)", + end: "rgb(0,0,0)", + selection: [6, 7], + }, + 6: { + down: true, + shift: true, + start: "rgb(0,5,0)", + end: "rgb(0,0,0)", + selection: [6, 7], + }, + 7: { + start: "rgba(0,0,0,1)", + end: "rgba(0,0,0,1)", + selection: [11, 12], + }, + 8: { + ...getSmallIncrementKey(), + start: "rgba(0,0,0,0.5)", + end: "rgba(0,0,0,0.6)", + selection: [12, 13], + }, + 9: { + down: true, + start: "rgba(0,0,0,0)", + end: "rgba(0,0,0,0)", + selection: [11, 12], + }, + }); +} + +async function testHslIncrements(view) { + info("Testing keyboard increments with hsl(a) colors"); + + const hslColorPropEditor = getTextProperty(view, 1, { + "background-color": "#000000", + }).editor; + + await runIncrementTest(hslColorPropEditor, view, { + 1: { start: "hsl(0,0%,0%)", end: "hsl(0,1%,0%)", selection: [6, 8] }, + 2: { + shift: true, + start: "hsl(0,0%,0%)", + end: "hsl(0,10%,0%)", + selection: [6, 8], + }, + 3: { start: "hsl(0,100%,0%)", end: "hsl(0,100%,0%)", selection: [6, 10] }, + 4: { + shift: true, + start: "hsl(0,95%,0%)", + end: "hsl(0,100%,0%)", + selection: [6, 10], + }, + 5: { + down: true, + start: "hsl(0,0%,0%)", + end: "hsl(0,0%,0%)", + selection: [6, 8], + }, + 6: { + down: true, + shift: true, + start: "hsl(0,5%,0%)", + end: "hsl(0,0%,0%)", + selection: [6, 8], + }, + 7: { + start: "hsla(0,0%,0%,1)", + end: "hsla(0,0%,0%,1)", + selection: [13, 14], + }, + 8: { + ...getSmallIncrementKey(), + start: "hsla(0,0%,0%,0.5)", + end: "hsla(0,0%,0%,0.6)", + selection: [14, 15], + }, + 9: { + down: true, + start: "hsla(0,0%,0%,0)", + end: "hsla(0,0%,0%,0)", + selection: [13, 14], + }, + }); +} + +async function testRgbCss4Increments(view) { + info("Testing keyboard increments with rgb colors using CSS 4 Color syntax"); + + const rgbColorPropEditor = getTextProperty(view, 1, { + "background-color": "#000000", + }).editor; + + await runIncrementTest(rgbColorPropEditor, view, { + 1: { start: "rgb(0 0 0)", end: "rgb(0 1 0)", selection: [6, 7] }, + 2: { + shift: true, + start: "rgb(0 0 0)", + end: "rgb(0 10 0)", + selection: [6, 7], + }, + 3: { start: "rgb(0 255 0)", end: "rgb(0 255 0)", selection: [6, 9] }, + 4: { + shift: true, + start: "rgb(0 250 0)", + end: "rgb(0 255 0)", + selection: [6, 9], + }, + 5: { + down: true, + start: "rgb(0 0 0)", + end: "rgb(0 0 0)", + selection: [6, 7], + }, + 6: { + down: true, + shift: true, + start: "rgb(0 5 0)", + end: "rgb(0 0 0)", + selection: [6, 7], + }, + 7: { + start: "rgb(0 0 0/1)", + end: "rgb(0 0 0/1)", + selection: [10, 11], + }, + 8: { + ...getSmallIncrementKey(), + start: "rgb(0 0 0/0.5)", + end: "rgb(0 0 0/0.6)", + selection: [11, 12], + }, + 9: { + down: true, + start: "rgb(0 0 0/0)", + end: "rgb(0 0 0/0)", + selection: [10, 11], + }, + }); +} + +async function testHslCss4Increments(view) { + info("Testing keyboard increments with hsl colors using CSS 4 Color syntax"); + + const hslColorPropEditor = getTextProperty(view, 1, { + "background-color": "#000000", + }).editor; + + await runIncrementTest(hslColorPropEditor, view, { + 1: { start: "hsl(0 0% 0%)", end: "hsl(0 1% 0%)", selection: [6, 8] }, + 2: { + shift: true, + start: "hsl(0 0% 0%)", + end: "hsl(0 10% 0%)", + selection: [6, 8], + }, + 3: { start: "hsl(0 100% 0%)", end: "hsl(0 100% 0%)", selection: [6, 10] }, + 4: { + shift: true, + start: "hsl(0 95% 0%)", + end: "hsl(0 100% 0%)", + selection: [6, 10], + }, + 5: { + down: true, + start: "hsl(0 0% 0%)", + end: "hsl(0 0% 0%)", + selection: [6, 8], + }, + 6: { + down: true, + shift: true, + start: "hsl(0 5% 0%)", + end: "hsl(0 0% 0%)", + selection: [6, 8], + }, + 7: { + start: "hsl(0 0% 0%/1)", + end: "hsl(0 0% 0%/1)", + selection: [12, 13], + }, + 8: { + ...getSmallIncrementKey(), + start: "hsl(0 0% 0%/0.5)", + end: "hsl(0 0% 0%/0.6)", + selection: [13, 14], + }, + 9: { + down: true, + start: "hsl(0 0% 0%/0)", + end: "hsl(0 0% 0%/0)", + selection: [12, 13], + }, + }); +} + +async function testHwbIncrements(view) { + info("Testing keyboard increments with hwb colors"); + + const hwbColorPropEditor = getTextProperty(view, 1, { + "background-color": "#000000", + }).editor; + + await runIncrementTest(hwbColorPropEditor, view, { + 1: { start: "hwb(0 0% 0%)", end: "hwb(0 1% 0%)", selection: [6, 8] }, + 2: { + shift: true, + start: "hwb(0 0% 0%)", + end: "hwb(0 10% 0%)", + selection: [6, 8], + }, + 3: { start: "hwb(0 100% 0%)", end: "hwb(0 100% 0%)", selection: [6, 10] }, + 4: { + shift: true, + start: "hwb(0 95% 0%)", + end: "hwb(0 100% 0%)", + selection: [6, 10], + }, + 5: { + down: true, + start: "hwb(0 0% 0%)", + end: "hwb(0 0% 0%)", + selection: [6, 8], + }, + 6: { + down: true, + shift: true, + start: "hwb(0 5% 0%)", + end: "hwb(0 0% 0%)", + selection: [6, 8], + }, + 7: { + start: "hwb(0 0% 0%/1)", + end: "hwb(0 0% 0%/1)", + selection: [12, 13], + }, + 8: { + ...getSmallIncrementKey(), + start: "hwb(0 0% 0%/0.5)", + end: "hwb(0 0% 0%/0.6)", + selection: [13, 14], + }, + 9: { + down: true, + start: "hwb(0 0% 0%/0)", + end: "hwb(0 0% 0%/0)", + selection: [12, 13], + }, + }); +} + +async function testShorthandIncrements(view) { + info("Testing keyboard increments within shorthand values"); + + const paddingPropEditor = getTextProperty(view, 1, { + "padding-top": "0px", + }).editor; + + await runIncrementTest(paddingPropEditor, view, { + 1: { start: "0px 0px 0px 0px", end: "0px 1px 0px 0px", selection: [4, 7] }, + 2: { + shift: true, + start: "0px 0px 0px 0px", + end: "0px 10px 0px 0px", + selection: [4, 7], + }, + 3: { start: "0px 0px 0px 0px", end: "1px 0px 0px 0px", selectAll: true }, + 4: { + shift: true, + start: "0px 0px 0px 0px", + end: "10px 0px 0px 0px", + selectAll: true, + }, + 5: { + down: true, + start: "0px 0px 0px 0px", + end: "0px 0px -1px 0px", + selection: [8, 11], + }, + 6: { + down: true, + shift: true, + start: "0px 0px 0px 0px", + end: "-10px 0px 0px 0px", + selectAll: true, + }, + 7: { + up: true, + start: "0.1em .1em 0em 0em", + end: "0.1em 1.1em 0em 0em", + selection: [6, 9], + }, + 8: { + up: true, + ...getSmallIncrementKey(), + start: "0.1em .9em 0em 0em", + end: "0.1em 1em 0em 0em", + selection: [6, 9], + }, + 9: { + up: true, + shift: true, + start: "0.2em .2em 0em 0em", + end: "0.2em 10.2em 0em 0em", + selection: [6, 9], + }, + }); +} + +async function testOddCases(view) { + info("Testing some more odd cases"); + + const marginPropEditor = getTextProperty(view, 1, { + "margin-top": "0px", + }).editor; + + await runIncrementTest(marginPropEditor, view, { + 1: { start: "98.7%", end: "99.7%", selection: [3, 3] }, + 2: { + ...getSmallIncrementKey(), + start: "98.7%", + end: "98.8%", + selection: [3, 3], + }, + 3: { start: "0", end: "1px" }, + 4: { down: true, start: "0", end: "-1px" }, + 5: { start: "'a=-1'", end: "'a=0'", selection: [4, 4] }, + 6: { start: "0 -1px", end: "0 0px", selection: [2, 2] }, + 7: { start: "url(-1)", end: "url(-1)", selection: [4, 4] }, + 8: { + start: "url('test1.1.png')", + end: "url('test1.2.png')", + selection: [11, 11], + }, + 9: { + start: "url('test1.png')", + end: "url('test2.png')", + selection: [9, 9], + }, + 10: { + shift: true, + start: "url('test1.1.png')", + end: "url('test11.1.png')", + selection: [9, 9], + }, + 11: { + down: true, + start: "url('test-1.png')", + end: "url('test-2.png')", + selection: [9, 11], + }, + 12: { + start: "url('test1.1.png')", + end: "url('test1.2.png')", + selection: [11, 12], + }, + 13: { + down: true, + ...getSmallIncrementKey(), + start: "url('test-0.png')", + end: "url('test--0.1.png')", + selection: [10, 11], + }, + 14: { + ...getSmallIncrementKey(), + start: "url('test--0.1.png')", + end: "url('test-0.png')", + selection: [10, 14], + }, + }); +} + +async function testZeroValueIncrements(view) { + info("Testing a valid unit is added when incrementing from 0"); + + const backgroundPropEditor = getTextProperty(view, 1, { + background: "none", + }).editor; + await runIncrementTest(backgroundPropEditor, view, { + 1: { + start: "url(test-0.png) no-repeat 0 0", + end: "url(test-0.png) no-repeat 1px 0", + selection: [26, 26], + }, + 2: { + start: "url(test-0.png) no-repeat 0 0", + end: "url(test-0.png) no-repeat 0 1px", + selection: [28, 28], + }, + 3: { + start: "url(test-0.png) no-repeat center/0", + end: "url(test-0.png) no-repeat center/1px", + selection: [34, 34], + }, + 4: { + start: "url(test-0.png) no-repeat 0 0", + end: "url(test-1.png) no-repeat 0 0", + selection: [10, 10], + }, + 5: { + start: "linear-gradient(0, red 0, blue 0)", + end: "linear-gradient(1deg, red 0, blue 0)", + selection: [17, 17], + }, + 6: { + start: "linear-gradient(1deg, red 0, blue 0)", + end: "linear-gradient(1deg, red 1px, blue 0)", + selection: [27, 27], + }, + 7: { + start: "linear-gradient(1deg, red 0, blue 0)", + end: "linear-gradient(1deg, red 0, blue 1px)", + selection: [35, 35], + }, + }); + + const transitionPropEditor = getTextProperty(view, 1, { + transition: "initial", + }).editor; + await runIncrementTest(transitionPropEditor, view, { + 1: { start: "all 0 ease-out", end: "all 1s ease-out", selection: [5, 5] }, + 2: { + start: "margin 4s, color 0", + end: "margin 4s, color 1s", + selection: [18, 18], + }, + }); + + const zIndexPropEditor = getTextProperty(view, 1, { "z-index": "0" }).editor; + await runIncrementTest(zIndexPropEditor, view, { + 1: { start: "0", end: "1", selection: [1, 1] }, + }); +} + +async function testOpacityIncrements(view) { + info("Testing keyboard increments on the opacity property"); + + const opacityPropEditor = getTextProperty(view, 1, { opacity: "1" }).editor; + + await runIncrementTest(opacityPropEditor, view, { + 1: { + ...getSmallIncrementKey(), + start: "0.5", + end: "0.51", + selectAll: true, + }, + 2: { start: "0", end: "0.1", selectAll: true }, + 3: { shift: true, start: "0", end: "1", selectAll: true }, + 4: { + down: true, + ...getSmallIncrementKey(), + start: "0.1", + end: "0.09", + selectAll: true, + }, + 5: { down: true, start: "0", end: "-0.1", selectAll: true }, + 6: { down: true, shift: true, start: "0", end: "-1", selectAll: true }, + 7: { pageUp: true, shift: true, start: "0", end: "10", selectAll: true }, + 8: { pageDown: true, shift: true, start: "0", end: "-10", selectAll: true }, + 9: { start: "0.7", end: "0.8", selectAll: true }, + 10: { down: true, start: "0", end: "-0.1", selectAll: true }, + }); +} + +async function testLineHeightIncrements(view) { + info("Testing keyboard increments on the line height property"); + + const opacityPropEditor = getTextProperty(view, 1, { + "line-height": "1", + }).editor; + + // line-height accepts both values with or without units, check that we don't + // force using a unit if none was specified. + await runIncrementTest(opacityPropEditor, view, { + 1: { + ...getSmallIncrementKey(), + start: "0", + end: "0.1", + selectAll: true, + }, + 2: { + ...getSmallIncrementKey(), + start: "0px", + end: "0.1px", + selectAll: true, + }, + 3: { + start: "0", + end: "1", + selectAll: true, + }, + 4: { + start: "0px", + end: "1px", + selectAll: true, + }, + 5: { + down: true, + ...getSmallIncrementKey(), + start: "0", + end: "-0.1", + selectAll: true, + }, + 6: { + down: true, + ...getSmallIncrementKey(), + start: "0px", + end: "-0.1px", + selectAll: true, + }, + 7: { + down: true, + start: "0", + end: "-1", + selectAll: true, + }, + 8: { + down: true, + start: "0px", + end: "-1px", + selectAll: true, + }, + }); +} + +async function testCssVariableIncrements(view) { + info("Testing keyboard increments on the css variable property"); + + const opacityPropEditor = getTextProperty(view, 1, { + "--custom": "0", + }).editor; + + await runIncrementTest(opacityPropEditor, view, { + 1: { + ...getSmallIncrementKey(), + start: "0", + end: "0.1", + selectAll: true, + }, + 2: { + start: "0", + end: "1", + selectAll: true, + }, + 3: { + down: true, + ...getSmallIncrementKey(), + start: "0", + end: "-0.1", + selectAll: true, + }, + 4: { + down: true, + start: "0", + end: "-1", + selectAll: true, + }, + }); +} + +async function runIncrementTest(propertyEditor, view, tests) { + propertyEditor.valueSpan.scrollIntoView(); + const editor = await focusEditableField(view, propertyEditor.valueSpan); + + for (const test in tests) { + await testIncrement(editor, tests[test], view, propertyEditor); + } + + // Blur the field to put back the UI in its initial state (and avoid pending + // requests when the test ends). + const onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_ESCAPE", {}, view.styleWindow); + view.debounce.flush(); + await onRuleViewChanged; +} + +async function testIncrement(editor, options, view) { + editor.input.value = options.start; + const input = editor.input; + + if (options.selectAll) { + input.select(); + } else if (options.selection) { + input.setSelectionRange(options.selection[0], options.selection[1]); + } + + is(input.value, options.start, "Value initialized at " + options.start); + + const onRuleViewChanged = view.once("ruleview-changed"); + const onKeyUp = once(input, "keyup"); + + let key; + key = options.down ? "VK_DOWN" : "VK_UP"; + if (options.pageDown) { + key = "VK_PAGE_DOWN"; + } else if (options.pageUp) { + key = "VK_PAGE_UP"; + } + + let smallIncrementKey = { ctrlKey: options.ctrl }; + if (lazy.AppConstants.platform === "macosx") { + smallIncrementKey = { altKey: options.alt }; + } + + EventUtils.synthesizeKey( + key, + { ...smallIncrementKey, shiftKey: options.shift }, + view.styleWindow + ); + + await onKeyUp; + + // Only expect a change if the value actually changed! + if (options.start !== options.end) { + view.debounce.flush(); + await onRuleViewChanged; + } + + is(input.value, options.end, "Value changed to " + options.end); +} + +function getSmallIncrementKey() { + if (lazy.AppConstants.platform === "macosx") { + return { alt: true }; + } + return { ctrl: true }; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property-order.js b/devtools/client/inspector/rules/test/browser_rules_edit-property-order.js new file mode 100644 index 0000000000..172c01d511 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property-order.js @@ -0,0 +1,130 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Checking properties orders and overrides in the rule-view. + +const TEST_URI = "<style>#testid {}</style><div id='testid'>Styled Node</div>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const elementStyle = view._elementStyle; + const elementRule = elementStyle.rules[1]; + + info("Checking rules insertion order and checking the applied style"); + const firstProp = await addProperty(view, 1, "background-color", "green"); + let secondProp = await addProperty(view, 1, "background-color", "blue"); + + is(elementRule.textProps[0], firstProp, "Rules should be in addition order."); + is( + elementRule.textProps[1], + secondProp, + "Rules should be in addition order." + ); + + // rgb(0, 0, 255) = blue + is( + await getValue("#testid", "background-color"), + "rgb(0, 0, 255)", + "Second property should have been used." + ); + + info("Removing the second property and checking the applied style again"); + await removeProperty(view, secondProp); + // rgb(0, 128, 0) = green + is( + await getValue("#testid", "background-color"), + "rgb(0, 128, 0)", + "After deleting second property, first should be used." + ); + + info( + "Creating a new second property and checking that the insertion order " + + "is still the same" + ); + + secondProp = await addProperty(view, 1, "background-color", "blue"); + + is( + await getValue("#testid", "background-color"), + "rgb(0, 0, 255)", + "New property should be used." + ); + is( + elementRule.textProps[0], + firstProp, + "Rules shouldn't have switched places." + ); + is( + elementRule.textProps[1], + secondProp, + "Rules shouldn't have switched places." + ); + + info("Disabling the second property and checking the applied style"); + await togglePropStatus(view, secondProp); + + is( + await getValue("#testid", "background-color"), + "rgb(0, 128, 0)", + "After disabling second property, first value should be used" + ); + + info("Disabling the first property too and checking the applied style"); + await togglePropStatus(view, firstProp); + + is( + await getValue("#testid", "background-color"), + "rgba(0, 0, 0, 0)", + "After disabling both properties, value should be empty." + ); + + info("Re-enabling the second propertyt and checking the applied style"); + await togglePropStatus(view, secondProp); + + is( + await getValue("#testid", "background-color"), + "rgb(0, 0, 255)", + "Value should be set correctly after re-enabling" + ); + + info( + "Re-enabling the first property and checking the insertion order " + + "is still respected" + ); + await togglePropStatus(view, firstProp); + + is( + await getValue("#testid", "background-color"), + "rgb(0, 0, 255)", + "Re-enabling an earlier property shouldn't make it override " + + "a later property." + ); + is( + elementRule.textProps[0], + firstProp, + "Rules shouldn't have switched places." + ); + is( + elementRule.textProps[1], + secondProp, + "Rules shouldn't have switched places." + ); + info("Modifying the first property and checking the applied style"); + await setProperty(view, firstProp, "purple"); + + is( + await getValue("#testid", "background-color"), + "rgb(0, 0, 255)", + "Modifying an earlier property shouldn't override a later property." + ); +}); + +async function getValue(selector, propName) { + const value = await getComputedStyleProperty(selector, null, propName); + return value; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property-remove_01.js b/devtools/client/inspector/rules/test/browser_rules_edit-property-remove_01.js new file mode 100644 index 0000000000..c929d252da --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property-remove_01.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests removing a property by clearing the property name and pressing the +// return key, and checks if the focus is moved to the appropriate editable +// field. + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: #00F; + color: #00F; + } + </style> + <div id='testid'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + info("Getting the first property in the #testid rule"); + const rule = getRuleViewRuleEditor(view, 1).rule; + let prop = rule.textProps[0]; + + info("Deleting the name of that property to remove the property"); + await removeProperty(view, prop, false); + + let newValue = await getRulePropertyValue(0, 0, "background-color"); + is(newValue, "", "background-color should have been unset."); + + info("Getting the new first property in the rule"); + prop = rule.textProps[0]; + + let editor = inplaceEditor(view.styleDocument.activeElement); + is( + inplaceEditor(prop.editor.nameSpan), + editor, + "Focus should have moved to the next property name" + ); + + info("Deleting the name of that property to remove the property"); + view.styleDocument.activeElement.blur(); + await removeProperty(view, prop, false); + + newValue = await getRulePropertyValue(0, 0, "color"); + is(newValue, "", "color should have been unset."); + + editor = inplaceEditor(view.styleDocument.activeElement); + is( + inplaceEditor(rule.editor.newPropSpan), + editor, + "Focus should have moved to the new property span" + ); + is(rule.textProps.length, 0, "All properties should have been removed."); + is( + rule.editor.propertyList.children.length, + 1, + "Should have the new property span." + ); + + view.styleDocument.activeElement.blur(); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property-remove_02.js b/devtools/client/inspector/rules/test/browser_rules_edit-property-remove_02.js new file mode 100644 index 0000000000..b5f0673b5d --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property-remove_02.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests removing a property by clearing the property value and pressing the +// return key, and checks if the focus is moved to the appropriate editable +// field. + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: #00F; + color: #00F; + } + </style> + <div id='testid'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + info("Getting the first property in the rule"); + const rule = getRuleViewRuleEditor(view, 1).rule; + let prop = rule.textProps[0]; + + info("Clearing the property value"); + await setProperty(view, prop, null, { blurNewProperty: false }); + + let newValue = await getRulePropertyValue(0, 0, "background-color"); + is(newValue, "", "background-color should have been unset."); + + info("Getting the new first property in the rule"); + prop = rule.textProps[0]; + + let editor = inplaceEditor(view.styleDocument.activeElement); + is( + inplaceEditor(prop.editor.nameSpan), + editor, + "Focus should have moved to the next property name" + ); + view.styleDocument.activeElement.blur(); + + info("Clearing the property value"); + await setProperty(view, prop, null, { blurNewProperty: false }); + + newValue = await getRulePropertyValue(0, 0, "background-color"); + is(newValue, "", "color should have been unset."); + + editor = inplaceEditor(view.styleDocument.activeElement); + is( + inplaceEditor(rule.editor.newPropSpan), + editor, + "Focus should have moved to the new property span" + ); + is(rule.textProps.length, 0, "All properties should have been removed."); + is( + rule.editor.propertyList.children.length, + 1, + "Should have the new property span." + ); + + view.styleDocument.activeElement.blur(); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property-remove_03.js b/devtools/client/inspector/rules/test/browser_rules_edit-property-remove_03.js new file mode 100644 index 0000000000..d5bc376dd4 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property-remove_03.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests removing a property by clearing the property name and pressing shift +// and tab keys, and checks if the focus is moved to the appropriate editable +// field. + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: #00F; + color: #00F; + } + </style> + <div id='testid'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + info("Getting the second property in the rule"); + const rule = getRuleViewRuleEditor(view, 1).rule; + let prop = rule.textProps[1]; + + info("Clearing the property value and pressing shift-tab"); + let editor = await focusEditableField(view, prop.editor.valueSpan); + const onValueDone = view.once("ruleview-changed"); + editor.input.value = ""; + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, view.styleWindow); + await onValueDone; + + let newValue = await getRulePropertyValue(0, 0, "color"); + is(newValue, "", "color should have been unset."); + is( + prop.editor.valueSpan.textContent, + "", + "'' property value is correctly set." + ); + + info("Pressing shift-tab again to focus the previous property value"); + const onValueFocused = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, view.styleWindow); + await onValueFocused; + + info("Getting the first property in the rule"); + prop = rule.textProps[0]; + + editor = inplaceEditor(view.styleDocument.activeElement); + is( + inplaceEditor(prop.editor.valueSpan), + editor, + "Focus should have moved to the previous property value" + ); + + info("Pressing shift-tab again to focus the property name"); + const onNameFocused = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, view.styleWindow); + await onNameFocused; + + info("Removing the name and pressing shift-tab to focus the selector"); + const onNameDeleted = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_DELETE", {}, view.styleWindow); + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, view.styleWindow); + await onNameDeleted; + + newValue = await getRulePropertyValue(0, 0, "background-color"); + is(newValue, "", "background-color should have been unset."); + + editor = inplaceEditor(view.styleDocument.activeElement); + is( + inplaceEditor(rule.editor.selectorText), + editor, + "Focus should have moved to the selector text." + ); + is(rule.textProps.length, 0, "All properties should have been removed."); + ok( + !rule.editor.propertyList.hasChildNodes(), + "Should not have any properties." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property-remove_04.js b/devtools/client/inspector/rules/test/browser_rules_edit-property-remove_04.js new file mode 100644 index 0000000000..0d9915285c --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property-remove_04.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that removing the only declaration from a rule and unselecting then re-selecting +// the element will not restore the removed declaration. Bug 1512956 + +const TEST_URI = ` + <style type='text/css'> + #testid { + color: #00F; + } + </style> + <div id='testid'>Styled Node</div> + <div id='empty'></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Select original node"); + await selectNode("#testid", inspector); + + info("Get the first property in the #testid rule"); + const rule = getRuleViewRuleEditor(view, 1).rule; + const prop = rule.textProps[0]; + + info("Delete the property name to remove the declaration"); + const onRuleViewChanged = view.once("ruleview-changed"); + await removeProperty(view, prop, false); + info("Wait for Rule view to update"); + await onRuleViewChanged; + + is(rule.textProps.length, 0, "No CSS properties left on the rule"); + + info("Select another node"); + await selectNode("#empty", inspector); + + info("Select original node again"); + await selectNode("#testid", inspector); + + is(rule.textProps.length, 0, "Still no CSS properties on the rule"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property_01.js b/devtools/client/inspector/rules/test/browser_rules_edit-property_01.js new file mode 100644 index 0000000000..258c0a0f88 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property_01.js @@ -0,0 +1,160 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing adding new properties via the inplace-editors in the rule +// view. +// FIXME: some of the inplace-editor focus/blur/commit/revert stuff +// should be factored out in head.js + +const ALL_CHANNELS = Ci.nsITelemetry.DATASET_ALL_CHANNELS; + +const TEST_URI = ` + <style type="text/css"> + #testid { + color: red; + background-color: blue; + } + .testclass, .unmatched { + background-color: green; + } + </style> + <div id="testid" class="testclass">Styled Node</div> + <div id="testid2">Styled Node</div> +`; + +var BACKGROUND_IMAGE_URL = 'url("' + URL_ROOT + 'doc_test_image.png")'; + +var TEST_DATA = [ + { name: "border-color", value: "red", isValid: true }, + { name: "background-image", value: BACKGROUND_IMAGE_URL, isValid: true }, + { name: "border", value: "solid 1px foo", isValid: false }, +]; + +const DATA = [ + { + timestamp: null, + category: "devtools.main", + method: "edit_rule", + object: "ruleview", + }, + { + timestamp: null, + category: "devtools.main", + method: "edit_rule", + object: "ruleview", + }, + { + timestamp: null, + category: "devtools.main", + method: "edit_rule", + object: "ruleview", + }, + { + timestamp: null, + category: "devtools.main", + method: "edit_rule", + object: "ruleview", + }, + { + timestamp: null, + category: "devtools.main", + method: "edit_rule", + object: "ruleview", + }, +]; + +add_task(async function () { + // Let's reset the counts. + Services.telemetry.clearEvents(); + + // Ensure no events have been logged + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + ok(!snapshot.parent, "No events have been logged for the main process"); + + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const rule = getRuleViewRuleEditor(view, 1).rule; + for (const { name, value, isValid } of TEST_DATA) { + await testEditProperty(view, rule, name, value, isValid); + } + + checkResults(); +}); + +async function testEditProperty(view, rule, name, value, isValid) { + info("Test editing existing property name/value fields"); + + const doc = rule.editor.doc; + const prop = rule.textProps[0]; + + info("Focusing an existing property name in the rule-view"); + let editor = await focusEditableField(view, prop.editor.nameSpan, 32, 1); + + is( + inplaceEditor(prop.editor.nameSpan), + editor, + "The property name editor got focused" + ); + let input = editor.input; + + info( + "Entering a new property name, including : to commit and " + + "focus the value" + ); + const onValueFocus = once(rule.editor.element, "focus", true); + const onNameDone = view.once("ruleview-changed"); + EventUtils.sendString(name + ":", doc.defaultView); + await onValueFocus; + await onNameDone; + + // Getting the value editor after focus + editor = inplaceEditor(doc.activeElement); + input = editor.input; + is(inplaceEditor(prop.editor.valueSpan), editor, "Focus moved to the value."); + + info("Entering a new value, including ; to commit and blur the value"); + const onValueDone = view.once("ruleview-changed"); + const onBlur = once(input, "blur"); + EventUtils.sendString(value + ";", doc.defaultView); + await onBlur; + await onValueDone; + + is( + prop.editor.isValid(), + isValid, + value + " is " + isValid ? "valid" : "invalid" + ); + + info("Checking that the style property was changed on the content page"); + const propValue = await getRulePropertyValue(0, 0, name); + if (isValid) { + is(propValue, value, name + " should have been set."); + } else { + isnot(propValue, value, name + " shouldn't have been set."); + } +} + +function checkResults() { + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + const events = snapshot.parent.filter( + event => + event[1] === "devtools.main" && + event[2] === "edit_rule" && + event[3] === "ruleview" + ); + + for (const i in DATA) { + const [timestamp, category, method, object] = events[i]; + const expected = DATA[i]; + + // ignore timestamp + ok(timestamp > 0, "timestamp is greater than 0"); + is(category, expected.category, "category is correct"); + is(method, expected.method, "method is correct"); + is(object, expected.object, "object is correct"); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property_02.js b/devtools/client/inspector/rules/test/browser_rules_edit-property_02.js new file mode 100644 index 0000000000..373f78eab6 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property_02.js @@ -0,0 +1,143 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test several types of rule-view property edition + +const TEST_URI = ` + <style type="text/css"> + #testid { + background-color: blue; + } + .testclass, .unmatched { + background-color: green; + } + </style> + <div id="testid" class="testclass">Styled Node</div> + <div id="testid2">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + await testEditProperty(inspector, view); + await testDisableProperty(inspector, view); + await testPropertyStillMarkedDirty(inspector, view); +}); + +async function testEditProperty(inspector, ruleView) { + const idRule = getRuleViewRuleEditor(ruleView, 1).rule; + const prop = getTextProperty(ruleView, 1, { "background-color": "blue" }); + + let editor = await focusEditableField(ruleView, prop.editor.nameSpan); + let input = editor.input; + is( + inplaceEditor(prop.editor.nameSpan), + editor, + "Next focused editor should be the name editor." + ); + + ok( + input.selectionStart === 0 && input.selectionEnd === input.value.length, + "Editor contents are selected." + ); + + // Try clicking on the editor's input again, shouldn't cause trouble + // (see bug 761665). + EventUtils.synthesizeMouse(input, 1, 1, {}, ruleView.styleWindow); + input.select(); + + info( + 'Entering property name "border-color" followed by a colon to ' + + "focus the value" + ); + const onNameDone = ruleView.once("ruleview-changed"); + const onFocus = once(idRule.editor.element, "focus", true); + EventUtils.sendString("border-color:", ruleView.styleWindow); + await onFocus; + await onNameDone; + + info("Verifying that the focused field is the valueSpan"); + editor = inplaceEditor(ruleView.styleDocument.activeElement); + input = editor.input; + is( + inplaceEditor(prop.editor.valueSpan), + editor, + "Focus should have moved to the value." + ); + ok( + input.selectionStart === 0 && input.selectionEnd === input.value.length, + "Editor contents are selected." + ); + + info("Entering a value following by a semi-colon to commit it"); + const onBlur = once(editor.input, "blur"); + // Use sendChar() to pass each character as a string so that we can test + // prop.editor.warning.hidden after each character. + for (const ch of "red;") { + const onPreviewDone = ruleView.once("ruleview-changed"); + EventUtils.sendChar(ch, ruleView.styleWindow); + ruleView.debounce.flush(); + await onPreviewDone; + is( + prop.editor.warning.hidden, + true, + "warning triangle is hidden or shown as appropriate" + ); + } + await onBlur; + + const newValue = await getRulePropertyValue(0, 0, "border-color"); + is(newValue, "red", "border-color should have been set."); + + ruleView.styleDocument.activeElement.blur(); + await addProperty(ruleView, 1, "color", "red", { commitValueWith: ";" }); + + const props = ruleView.element.querySelectorAll(".ruleview-property"); + for (let i = 0; i < props.length; i++) { + is( + props[i].hasAttribute("dirty"), + i <= 1, + "props[" + i + "] marked dirty as appropriate" + ); + } +} + +async function testDisableProperty(inspector, ruleView) { + const prop = getTextProperty(ruleView, 1, { + "border-color": "red", + color: "red", + }); + + info("Disabling a property"); + await togglePropStatus(ruleView, prop); + + let newValue = await getRulePropertyValue(0, 0, "border-color"); + is(newValue, "", "Border-color should have been unset."); + + info("Enabling the property again"); + await togglePropStatus(ruleView, prop); + + newValue = await getRulePropertyValue(0, 0, "border-color"); + is(newValue, "red", "Border-color should have been reset."); +} + +async function testPropertyStillMarkedDirty(inspector, ruleView) { + // Select an unstyled node. + await selectNode("#testid2", inspector); + + // Select the original node again. + await selectNode("#testid", inspector); + + const props = ruleView.element.querySelectorAll(".ruleview-property"); + for (let i = 0; i < props.length; i++) { + is( + props[i].hasAttribute("dirty"), + i <= 1, + "props[" + i + "] marked dirty as appropriate" + ); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property_03.js b/devtools/client/inspector/rules/test/browser_rules_edit-property_03.js new file mode 100644 index 0000000000..e6ec0aef33 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property_03.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that emptying out an existing value removes the property and +// doesn't cause any other issues. See also Bug 1150780. + +const TEST_URI = ` + <style type="text/css"> + #testid { + color: red; + background-color: blue; + font-size: 12px; + } + .testclass, .unmatched { + background-color: green; + } + </style> + <div id="testid" class="testclass">Styled Node</div> + <div id="testid2">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const ruleEditor = getRuleViewRuleEditor(view, 1); + const propEditor = getTextProperty(view, 1, { + "background-color": "blue", + }).editor; + + await focusEditableField(view, propEditor.valueSpan); + + info("Deleting all the text out of a value field"); + let onRuleViewChanged = view.once("ruleview-changed"); + await sendKeysAndWaitForFocus(view, ruleEditor.element, ["DELETE", "RETURN"]); + await onRuleViewChanged; + + info("Pressing enter a couple times to cycle through editors"); + await sendKeysAndWaitForFocus(view, ruleEditor.element, ["RETURN"]); + onRuleViewChanged = view.once("ruleview-changed"); + await sendKeysAndWaitForFocus(view, ruleEditor.element, ["RETURN"]); + await onRuleViewChanged; + + isnot(propEditor.nameSpan.style.display, "none", "The name span is visible"); + is(ruleEditor.rule.textProps.length, 2, "Correct number of props"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property_04.js b/devtools/client/inspector/rules/test/browser_rules_edit-property_04.js new file mode 100644 index 0000000000..070832710b --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property_04.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that a disabled property remains disabled when the escaping out of +// the property editor. + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: blue; + } + </style> + <div id='testid'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const prop = getTextProperty(view, 1, { "background-color": "blue" }); + + info("Disabling a property"); + await togglePropStatus(view, prop); + + const newValue = await getRulePropertyValue(0, 0, "background-color"); + is(newValue, "", "background-color should have been unset."); + + await testEditDisableProperty(view, prop, "name", "VK_ESCAPE"); + await testEditDisableProperty(view, prop, "value", "VK_ESCAPE"); + await testEditDisableProperty(view, prop, "value", "VK_TAB"); + await testEditDisableProperty(view, prop, "value", "VK_RETURN"); +}); + +async function testEditDisableProperty(view, prop, fieldType, commitKey) { + const field = + fieldType === "name" ? prop.editor.nameSpan : prop.editor.valueSpan; + + const editor = await focusEditableField(view, field); + + ok( + !prop.editor.element.classList.contains("ruleview-overridden"), + "property is not overridden." + ); + is( + prop.editor.enable.style.visibility, + "hidden", + "property enable checkbox is hidden." + ); + + let newValue = await getRulePropertyValue(0, 0, "background-color"); + is(newValue, "", "background-color should remain unset."); + + let onChangeDone; + if (fieldType === "value") { + onChangeDone = view.once("ruleview-changed"); + } + + const onBlur = once(editor.input, "blur"); + EventUtils.synthesizeKey(commitKey, {}, view.styleWindow); + await onBlur; + await onChangeDone; + + ok(!prop.enabled, "property is disabled."); + ok( + prop.editor.element.classList.contains("ruleview-overridden"), + "property is overridden." + ); + is( + prop.editor.enable.style.visibility, + "visible", + "property enable checkbox is visible." + ); + ok( + !prop.editor.enable.getAttribute("checked"), + "property enable checkbox is not checked." + ); + + newValue = await getRulePropertyValue(0, 0, "background-color"); + is(newValue, "", "background-color should remain unset."); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property_05.js b/devtools/client/inspector/rules/test/browser_rules_edit-property_05.js new file mode 100644 index 0000000000..9bfc002a4a --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property_05.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that a disabled property is re-enabled if the property name or value is +// modified + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: blue; + } + </style> + <div id='testid'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const prop = getTextProperty(view, 1, { "background-color": "blue" }); + + info("Disabling background-color property"); + await togglePropStatus(view, prop); + + let newValue = await getRulePropertyValue(0, 0, "background-color"); + is(newValue, "", "background-color should have been unset."); + + info( + "Entering a new property name, including : to commit and " + + "focus the value" + ); + + await focusEditableField(view, prop.editor.nameSpan); + const onNameDone = view.once("ruleview-changed"); + EventUtils.sendString("border-color:", view.styleWindow); + await onNameDone; + + info("Escape editing the property value"); + const onValueDone = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_ESCAPE", {}, view.styleWindow); + await onValueDone; + + newValue = await getRulePropertyValue(0, 0, "border-color"); + is(newValue, "blue", "border-color should have been set."); + + ok(prop.enabled, "border-color property is enabled."); + ok( + !prop.editor.element.classList.contains("ruleview-overridden"), + "border-color is not overridden" + ); + + info("Disabling border-color property"); + await togglePropStatus(view, prop); + + newValue = await getRulePropertyValue(0, 0, "border-color"); + is(newValue, "", "border-color should have been unset."); + + info("Enter a new property value for the border-color property"); + await setProperty(view, prop, "red"); + + newValue = await getRulePropertyValue(0, 0, "border-color"); + is(newValue, "red", "new border-color should have been set."); + + ok(prop.enabled, "border-color property is enabled."); + ok( + !prop.editor.element.classList.contains("ruleview-overridden"), + "border-color is not overridden" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property_06.js b/devtools/client/inspector/rules/test/browser_rules_edit-property_06.js new file mode 100644 index 0000000000..1b9a04ba5d --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property_06.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that editing a property's priority is behaving correctly, and disabling +// and editing the property will re-enable the property. + +const TEST_URI = ` + <style type='text/css'> + body { + background-color: green !important; + } + body { + background-color: red; + } + </style> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("body", inspector); + + const prop = getTextProperty(view, 1, { "background-color": "red" }); + + is( + await getComputedStyleProperty("body", null, "background-color"), + "rgb(0, 128, 0)", + "green background color is set." + ); + + await setProperty(view, prop, "red !important"); + + is( + prop.editor.valueSpan.textContent, + "red !important", + "'red !important' property value is correctly set." + ); + is( + await getComputedStyleProperty("body", null, "background-color"), + "rgb(255, 0, 0)", + "red background color is set." + ); + + info("Disabling red background color property"); + await togglePropStatus(view, prop); + + is( + await getComputedStyleProperty("body", null, "background-color"), + "rgb(0, 128, 0)", + "green background color is set." + ); + + await setProperty(view, prop, "red"); + + is( + prop.editor.valueSpan.textContent, + "red", + "'red' property value is correctly set." + ); + ok(prop.enabled, "red background-color property is enabled."); + is( + await getComputedStyleProperty("body", null, "background-color"), + "rgb(0, 128, 0)", + "green background color is set." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property_07.js b/devtools/client/inspector/rules/test/browser_rules_edit-property_07.js new file mode 100644 index 0000000000..9edd910fa6 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property_07.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that adding multiple values will enable the property even if the +// property does not change, and that the extra values are added correctly. + +const STYLE = "#testid { background-color: #f00 }"; + +const TEST_URI_INLINE_SHEET = ` + <style>${STYLE}</style> + <div id='testid'>Styled Node</div> +`; + +const TEST_URI_CONSTRUCTED_SHEET = ` + <div id='testid'>Styled Node</div> + <script> + let sheet = new CSSStyleSheet(); + sheet.replaceSync("${STYLE}"); + document.adoptedStyleSheets.push(sheet); + </script> +`; + +async function runTest(testUri) { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(testUri)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const rule = getRuleViewRuleEditor(view, 1).rule; + const prop = rule.textProps[0]; + + info("Disabling red background color property"); + await togglePropStatus(view, prop); + ok(!prop.enabled, "red background-color property is disabled."); + + const editor = await focusEditableField(view, prop.editor.valueSpan); + const onDone = view.once("ruleview-changed"); + editor.input.value = "red; color: red;"; + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + await onDone; + + is( + prop.editor.valueSpan.textContent, + "red", + "'red' property value is correctly set." + ); + ok(prop.enabled, "red background-color property is enabled."); + is( + await getComputedStyleProperty("#testid", null, "background-color"), + "rgb(255, 0, 0)", + "red background color is set." + ); + + const propEditor = rule.textProps[1].editor; + is( + propEditor.nameSpan.textContent, + "color", + "new 'color' property name is correctly set." + ); + is( + propEditor.valueSpan.textContent, + "red", + "new 'red' property value is correctly set." + ); + is( + await getComputedStyleProperty("#testid", null, "color"), + "rgb(255, 0, 0)", + "red color is set." + ); +} + +add_task(async function test_inline_sheet() { + await runTest(TEST_URI_INLINE_SHEET); +}); + +add_task(async function test_constructed_sheet() { + await runTest(TEST_URI_CONSTRUCTED_SHEET); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property_08.js b/devtools/client/inspector/rules/test/browser_rules_edit-property_08.js new file mode 100644 index 0000000000..d00a7c2c24 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property_08.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that renaming a property works. + +const TEST_URI = ` + <style type="text/css"> + #testid { + color: #FFF; + } + </style> + <div style='color: red' id='testid'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + info("Get the color property editor"); + const ruleEditor = getRuleViewRuleEditor(view, 0); + const propEditor = ruleEditor.rule.textProps[0].editor; + is(ruleEditor.rule.textProps[0].name, "color"); + + info("Focus the property name field"); + await focusEditableField(ruleEditor.ruleView, propEditor.nameSpan, 32, 1); + + info("Rename the property to background-color"); + // Expect 3 events: the value editor being focused, the ruleview-changed event + // which signals that the new value has been previewed (fires once when the + // value gets focused), and the markupmutation event since we're modifying an + // inline style. + const onValueFocus = once(ruleEditor.element, "focus", true); + let onRuleViewChanged = ruleEditor.ruleView.once("ruleview-changed"); + const onMutation = inspector.once("markupmutation"); + EventUtils.sendString("background-color:", ruleEditor.doc.defaultView); + await onValueFocus; + await onRuleViewChanged; + await onMutation; + + is(ruleEditor.rule.textProps[0].name, "background-color"); + await waitForComputedStyleProperty( + "#testid", + null, + "background-color", + "rgb(255, 0, 0)" + ); + + is( + await getComputedStyleProperty("#testid", null, "color"), + "rgb(255, 255, 255)", + "color is white" + ); + + // The value field is still focused. Blur it now and wait for the + // ruleview-changed event to avoid pending requests. + onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.synthesizeKey("KEY_Escape"); + await onRuleViewChanged; +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property_09.js b/devtools/client/inspector/rules/test/browser_rules_edit-property_09.js new file mode 100644 index 0000000000..ab54c98729 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property_09.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that a newProperty editor is only created if no other editor was +// previously displayed. + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: blue; + } + </style> + <div id='testid'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testClickOnEmptyAreaToCloseEditor(inspector, view); +}); + +function synthesizeMouseOnEmptyArea(view) { + // any text property editor will do + const prop = getTextProperty(view, 1, { "background-color": "blue" }); + const propEditor = prop.editor; + const valueContainer = propEditor.valueContainer; + const valueRect = valueContainer.getBoundingClientRect(); + // click right next to the ";" at the end of valueContainer + EventUtils.synthesizeMouse( + valueContainer, + valueRect.width + 1, + 1, + {}, + view.styleWindow + ); +} + +async function testClickOnEmptyAreaToCloseEditor(inspector, view) { + // Start at the beginning: start to add a rule to the element's style + // declaration, add some text, then press escape. + const ruleEditor = getRuleViewRuleEditor(view, 1); + const prop = getTextProperty(view, 1, { "background-color": "blue" }); + const propEditor = prop.editor; + + info("Create a property value editor"); + let editor = await focusEditableField(view, propEditor.valueSpan); + ok(editor.input, "The inplace-editor field is ready"); + + info( + "Close the property value editor by clicking on an empty area " + + "in the rule editor" + ); + const onRuleViewChanged = view.once("ruleview-changed"); + let onBlur = once(editor.input, "blur"); + synthesizeMouseOnEmptyArea(view); + await onBlur; + await onRuleViewChanged; + ok(!view.isEditing, "No inplace editor should be displayed in the ruleview"); + + info("Create new newProperty editor by clicking again on the empty area"); + const onFocus = once(ruleEditor.element, "focus", true); + synthesizeMouseOnEmptyArea(view); + await onFocus; + editor = inplaceEditor(ruleEditor.element.ownerDocument.activeElement); + is( + inplaceEditor(ruleEditor.newPropSpan), + editor, + "New property editor was created" + ); + + info("Close the newProperty editor by clicking again on the empty area"); + onBlur = once(editor.input, "blur"); + synthesizeMouseOnEmptyArea(view); + await onBlur; + + ok(!view.isEditing, "No inplace editor should be displayed in the ruleview"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-property_10.js b/devtools/client/inspector/rules/test/browser_rules_edit-property_10.js new file mode 100644 index 0000000000..3db2164b0c --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-property_10.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = ` + <style> + div { + color: red; + width: 10; /* This document is in quirks mode so this value should be valid */ + } + </style> + <div></div> +`; + +// Test that CSS property names are case insensitive when validating, and that +// quirks mode is accounted for when validating. +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView } = await openRuleView(); + + await selectNode("div", inspector); + let prop = getTextProperty(ruleView, 1, { color: "red" }); + + let onRuleViewChanged; + + info(`Rename the CSS property name to "Color"`); + onRuleViewChanged = ruleView.once("ruleview-changed"); + await renameProperty(ruleView, prop, "Color"); + info("Wait for Rule view to update"); + await onRuleViewChanged; + + is(prop.overridden, false, "Titlecase property is not overriden"); + is(prop.enabled, true, "Titlecase property is enabled"); + is(prop.isNameValid(), true, "Titlecase property is valid"); + + info(`Rename the CSS property name to "COLOR"`); + onRuleViewChanged = ruleView.once("ruleview-changed"); + await renameProperty(ruleView, prop, "COLOR"); + info("Wait for Rule view to update"); + await onRuleViewChanged; + + is(prop.overridden, false, "Uppercase property is not overriden"); + is(prop.enabled, true, "Uppercase property is enabled"); + is(prop.isNameValid(), true, "Uppercase property is valid"); + + info(`Checking width validity`); + prop = getTextProperty(ruleView, 1, { width: "10" }); + is(prop.isNameValid(), true, "width is a valid property"); + is(prop.isValid(), true, "10 is a valid property value in quirks mode"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector-click-on-scrollbar.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector-click-on-scrollbar.js new file mode 100644 index 0000000000..52e11097cc --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector-click-on-scrollbar.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing ruleview inplace-editor is not blurred when clicking on the ruleview +// container scrollbar. + +const TEST_URI = ` + <style type="text/css"> + div.testclass { + color: black; + } + .a { + color: #aaa; + } + .b { + color: #bbb; + } + .c { + color: #ccc; + } + .d { + color: #ddd; + } + .e { + color: #eee; + } + .f { + color: #fff; + } + </style> + <div class="testclass a b c d e f">Styled Node</div> +`; + +add_task(async function () { + info("Toolbox height should be small enough to force scrollbars to appear"); + await new Promise(done => { + const options = { set: [["devtools.toolbox.footer.height", 200]] }; + SpecialPowers.pushPrefEnv(options, done); + }); + + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode(".testclass", inspector); + + info("Check we have an overflow on the ruleview container."); + const container = view.element; + const hasScrollbar = container.offsetHeight < container.scrollHeight; + ok(hasScrollbar, "The rule view container should have a vertical scrollbar."); + + info("Focusing an existing selector name in the rule-view."); + const ruleEditor = getRuleViewRuleEditor(view, 1); + const editor = await focusEditableField(view, ruleEditor.selectorText); + is( + inplaceEditor(ruleEditor.selectorText), + editor, + "The selector editor is focused." + ); + + info("Click on the scrollbar element."); + await clickOnRuleviewScrollbar(view); + + is( + editor.input, + view.styleDocument.activeElement, + "The editor input should still be focused." + ); + + info("Check a new value can still be committed in the editable field"); + const newValue = ".testclass.a.b.c.d.e.f"; + const onRuleViewChanged = once(view, "ruleview-changed"); + + info("Enter new value and commit."); + editor.input.value = newValue; + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + ok(getRuleViewRule(view, newValue), "Rule with '" + newValue + " 'exists."); +}); + +async function clickOnRuleviewScrollbar(view) { + const container = view.element.parentNode; + const onScroll = once(container, "scroll"); + const rect = container.getBoundingClientRect(); + // click 5 pixels before the bottom-right corner should hit the scrollbar + EventUtils.synthesizeMouse( + container, + rect.width - 5, + rect.height - 5, + {}, + view.styleWindow + ); + await onScroll; + + ok(true, "The rule view container scrolled after clicking on the scrollbar."); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector-click.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector-click.js new file mode 100644 index 0000000000..e3c1822201 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector-click.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing selector inplace-editor remains available and focused after clicking +// in its input. + +const TEST_URI = ` + <style type="text/css"> + .testclass { + text-align: center; + } + </style> + <div class="testclass">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode(".testclass", inspector); + await testClickOnSelectorEditorInput(view); +}); + +async function testClickOnSelectorEditorInput(view) { + info("Test clicking inside the selector editor input"); + + const ruleEditor = getRuleViewRuleEditor(view, 1); + + info("Focusing an existing selector name in the rule-view"); + const editor = await focusEditableField(view, ruleEditor.selectorText); + const editorInput = editor.input; + is( + inplaceEditor(ruleEditor.selectorText), + editor, + "The selector editor got focused" + ); + + info("Click inside the editor input"); + const onClick = once(editorInput, "click"); + EventUtils.synthesizeMouse(editor.input, 2, 1, {}, view.styleWindow); + await onClick; + is( + editor.input, + view.styleDocument.activeElement, + "The editor input should still be focused" + ); + ok(!ruleEditor.newPropSpan, "No newProperty editor was created"); + + info("Doubleclick inside the editor input"); + const onDoubleClick = once(editorInput, "dblclick"); + EventUtils.synthesizeMouse( + editor.input, + 2, + 1, + { clickCount: 2 }, + view.styleWindow + ); + await onDoubleClick; + is( + editor.input, + view.styleDocument.activeElement, + "The editor input should still be focused" + ); + ok(!ruleEditor.newPropSpan, "No newProperty editor was created"); + + info("Click outside the editor input"); + const onBlur = once(editorInput, "blur"); + const rect = editorInput.getBoundingClientRect(); + EventUtils.synthesizeMouse( + editorInput, + rect.width + 5, + rect.height / 2, + {}, + view.styleWindow + ); + await onBlur; + + isnot( + editorInput, + view.styleDocument.activeElement, + "The editor input should no longer be focused" + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector-commit.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector-commit.js new file mode 100644 index 0000000000..ffed882701 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector-commit.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test selector value is correctly displayed when committing the inplace editor +// with ENTER, ESC, SHIFT+TAB and TAB + +const TEST_URI = ` + <style type='text/css'> + #testid1 { + text-align: center; + } + #testid2 { + text-align: center; + } + #testid3 { + } + </style> + <div id='testid1'>Styled Node</div> + <div id='testid2'>Styled Node</div> + <div id='testid3'>Styled Node</div> +`; + +const TEST_DATA = [ + { + node: "#testid1", + value: ".testclass", + commitKey: "VK_ESCAPE", + modifiers: {}, + expected: "#testid1", + }, + { + node: "#testid1", + value: ".testclass1", + commitKey: "VK_RETURN", + modifiers: {}, + expected: ".testclass1", + }, + { + node: "#testid2", + value: ".testclass2", + commitKey: "VK_TAB", + modifiers: {}, + expected: ".testclass2", + }, + { + node: "#testid3", + value: ".testclass3", + commitKey: "VK_TAB", + modifiers: { shiftKey: true }, + expected: ".testclass3", + }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + for (const data of TEST_DATA) { + await runTestData(inspector, view, data); + } +}); + +async function runTestData(inspector, view, data) { + const { node, value, commitKey, modifiers, expected } = data; + + info( + "Updating " + + node + + " to " + + value + + " and committing with " + + commitKey + + ". Expecting: " + + expected + ); + + info("Selecting the test element"); + await selectNode(node, inspector); + + let idRuleEditor = getRuleViewRuleEditor(view, 1); + + info("Focusing an existing selector name in the rule-view"); + const editor = await focusEditableField(view, idRuleEditor.selectorText); + is( + inplaceEditor(idRuleEditor.selectorText), + editor, + "The selector editor got focused" + ); + + info("Enter the new selector value: " + value); + editor.input.value = value; + + info("Entering the commit key " + commitKey + " " + modifiers); + EventUtils.synthesizeKey(commitKey, modifiers); + + const activeElement = view.styleDocument.activeElement; + + if (commitKey === "VK_ESCAPE") { + is( + idRuleEditor.rule.selectorText, + expected, + "Value is as expected: " + expected + ); + is(idRuleEditor.isEditing, false, "Selector is not being edited."); + is(idRuleEditor.selectorText, activeElement, "Focus is on selector span."); + return; + } + + await once(view, "ruleview-changed"); + + ok( + getRuleViewRule(view, expected), + "Rule with " + expected + " selector exists." + ); + + if (modifiers.shiftKey) { + idRuleEditor = getRuleViewRuleEditor(view, 0); + } + + const rule = idRuleEditor.rule; + if (rule.textProps.length) { + is( + inplaceEditor(rule.textProps[0].editor.nameSpan).input, + activeElement, + "Focus is on the first property name span." + ); + } else { + is( + inplaceEditor(idRuleEditor.newPropSpan).input, + activeElement, + "Focus is on the new property span." + ); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector_01.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector_01.js new file mode 100644 index 0000000000..be18919f68 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector_01.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing selector inplace-editor behaviors in the rule-view + +const TEST_URI = ` + <style type="text/css"> + .testclass { + text-align: center; + } + </style> + <div id="testid" class="testclass">Styled Node</div> + <span>This is a span</span> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Selecting the test element"); + await selectNode("#testid", inspector); + await testEditSelector(view, "span"); + + info("Selecting the modified element with the new rule"); + await selectNode("span", inspector); + await checkModifiedElement(view, "span"); +}); + +async function testEditSelector(view, name) { + info("Test editing existing selector fields"); + + const idRuleEditor = getRuleViewRuleEditor(view, 1); + + info("Focusing an existing selector name in the rule-view"); + const editor = await focusEditableField(view, idRuleEditor.selectorText); + + is( + inplaceEditor(idRuleEditor.selectorText), + editor, + "The selector editor got focused" + ); + + info("Entering a new selector name and committing"); + editor.input.value = name; + + info("Waiting for rule view to update"); + const onRuleViewChanged = once(view, "ruleview-changed"); + + info("Entering the commit key"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + is(view._elementStyle.rules.length, 2, "Should have 2 rules."); + ok(getRuleViewRule(view, name), "Rule with " + name + " selector exists."); + ok( + getRuleViewRuleEditor(view, 1).element.getAttribute("unmatched"), + "Rule with " + name + " does not match the current element." + ); +} + +function checkModifiedElement(view, name) { + is(view._elementStyle.rules.length, 2, "Should have 2 rules."); + ok(getRuleViewRule(view, name), "Rule with " + name + " selector exists."); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector_02.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector_02.js new file mode 100644 index 0000000000..70b58d4e42 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector_02.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing selector inplace-editor behaviors in the rule-view with pseudo +// classes. + +const TEST_URI = ` + <style type="text/css"> + .testclass { + text-align: center; + } + #testid3::first-letter { + text-decoration: "italic" + } + </style> + <div id="testid">Styled Node</div> + <span class="testclass">This is a span</span> + <div class="testclass2">A</div> + <div id="testid3">B</div> +`; + +const PSEUDO_PREF = "devtools.inspector.show_pseudo_elements"; + +add_task(async function () { + // Expand the pseudo-elements section by default. + Services.prefs.setBoolPref(PSEUDO_PREF, true); + + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Selecting the test element"); + await selectNode(".testclass", inspector); + await testEditSelector(view, "div:nth-child(1)"); + + info("Selecting the modified element"); + await selectNode("#testid", inspector); + await checkModifiedElement(view, "div:nth-child(1)"); + + info("Selecting the test element"); + await selectNode("#testid3", inspector); + await testEditSelector(view, ".testclass2::first-letter"); + + info("Selecting the modified element"); + await selectNode(".testclass2", inspector); + await checkModifiedElement(view, ".testclass2::first-letter"); + + // Reset the pseudo-elements section pref to its default value. + Services.prefs.clearUserPref(PSEUDO_PREF); +}); + +async function testEditSelector(view, name) { + info("Test editing existing selector fields"); + + const idRuleEditor = + getRuleViewRuleEditor(view, 1) || getRuleViewRuleEditor(view, 1, 0); + + info("Focusing an existing selector name in the rule-view"); + const editor = await focusEditableField(view, idRuleEditor.selectorText); + + is( + inplaceEditor(idRuleEditor.selectorText), + editor, + "The selector editor got focused" + ); + + info("Entering a new selector name: " + name); + editor.input.value = name; + + info("Waiting for rule view to update"); + const onRuleViewChanged = once(view, "ruleview-changed"); + + info("Entering the commit key"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + is(view._elementStyle.rules.length, 2, "Should have 2 rule."); + ok(getRuleViewRule(view, name), "Rule with " + name + " selector exists."); + + const newRuleEditor = + getRuleViewRuleEditor(view, 1) || getRuleViewRuleEditor(view, 1, 0); + ok( + newRuleEditor.element.getAttribute("unmatched"), + "Rule with " + name + " does not match the current element." + ); +} + +function checkModifiedElement(view, name) { + is(view._elementStyle.rules.length, 2, "Should have 2 rules."); + ok(getRuleViewRule(view, name), "Rule with " + name + " selector exists."); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector_03.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector_03.js new file mode 100644 index 0000000000..bbc9175568 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector_03.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing selector inplace-editor behaviors in the rule-view with invalid +// selectors + +const TEST_URI = ` + <style type="text/css"> + .testclass { + text-align: center; + } + </style> + <div class="testclass">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode(".testclass", inspector); + await testEditSelector(view, "asd@:::!"); +}); + +async function testEditSelector(view, name) { + info("Test editing existing selector fields"); + + const ruleEditor = getRuleViewRuleEditor(view, 1); + + info("Focusing an existing selector name in the rule-view"); + const editor = await focusEditableField(view, ruleEditor.selectorText); + + is( + inplaceEditor(ruleEditor.selectorText), + editor, + "The selector editor got focused" + ); + + info("Entering a new selector name and committing"); + editor.input.value = name; + const onRuleViewChanged = once(view, "ruleview-invalid-selector"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + is(view._elementStyle.rules.length, 2, "Should have 2 rules."); + is( + getRuleViewRule(view, name), + undefined, + "Rule with " + name + " selector should not exist." + ); + ok( + getRuleViewRule(view, ".testclass"), + "Rule with .testclass selector exists." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector_04.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector_04.js new file mode 100644 index 0000000000..02a1feaac3 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector_04.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the selector highlighter is removed when modifying a selector and +// the selector highlighter works for the newly added unmatched rule. + +const TEST_URI = ` + <style type="text/css"> + p { + background: red; + } + </style> + <p>Test the selector highlighter</p> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("p", inspector); + + await testSelectorHighlight(view, "p"); + await testEditSelector(inspector, view, "body"); + await testSelectorHighlight(view, "body"); +}); + +async function testSelectorHighlight(view, selector) { + info("Test creating selector highlighter"); + + info("Clicking on a selector icon"); + const { highlighter, isShown } = await clickSelectorIcon(view, selector); + + ok(highlighter, "The selector highlighter instance was created"); + ok(isShown, "The selector highlighter was shown"); +} + +async function testEditSelector(inspector, view, name) { + info("Test editing existing selector fields"); + + const ruleEditor = getRuleViewRuleEditor(view, 1); + + info("Focusing an existing selector name in the rule-view"); + const editor = await focusEditableField(view, ruleEditor.selectorText); + + is( + inplaceEditor(ruleEditor.selectorText), + editor, + "The selector editor got focused" + ); + + const onRuleViewChanged = view.once("ruleview-changed"); + const { waitForHighlighterTypeHidden } = getHighlighterTestHelpers(inspector); + const onSelectorHighlighterHidden = waitForHighlighterTypeHidden( + inspector.highlighters.TYPES.SELECTOR + ); + + info("Entering a new selector name and committing"); + editor.input.value = name; + EventUtils.synthesizeKey("KEY_Enter"); + + info("Waiting for Rules view to update"); + await onRuleViewChanged; + await onSelectorHighlighterHidden; + const highlighter = inspector.highlighters.getActiveHighlighter( + inspector.highlighters.TYPES.SELECTOR + ); + + ok(!highlighter, "The highlighter instance was removed"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector_05.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector_05.js new file mode 100644 index 0000000000..30d945608d --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector_05.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that adding a new property of an unmatched rule works properly. + +const TEST_URI = ` + <style type="text/css"> + #testid { + } + .testclass { + background-color: white; + } + </style> + <div id="testid">Styled Node</div> + <span class="testclass">This is a span</span> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Selecting the test element"); + await selectNode("#testid", inspector); + await testEditSelector(view, "span"); + await testAddProperty(view); + + info("Selecting the modified element with the new rule"); + await selectNode("span", inspector); + await checkModifiedElement(view, "span"); +}); + +async function testEditSelector(view, name) { + info("Test editing existing selector fields"); + + const ruleEditor = getRuleViewRuleEditor(view, 1); + + info("Focusing an existing selector name in the rule-view"); + const editor = await focusEditableField(view, ruleEditor.selectorText); + + is( + inplaceEditor(ruleEditor.selectorText), + editor, + "The selector editor got focused" + ); + + info("Entering a new selector name and committing"); + editor.input.value = name; + + info("Waiting for rule view to update"); + const onRuleViewChanged = once(view, "ruleview-changed"); + + info("Entering the commit key"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + is(view._elementStyle.rules.length, 2, "Should have 2 rules."); + ok(getRuleViewRule(view, name), "Rule with " + name + " selector exists."); + ok( + getRuleViewRuleEditor(view, 1).element.getAttribute("unmatched"), + "Rule with " + name + " does not match the current element." + ); + + // Escape the new property editor after editing the selector + const onBlur = once(view.styleDocument.activeElement, "blur"); + EventUtils.synthesizeKey("VK_ESCAPE", {}, view.styleWindow); + await onBlur; +} + +function checkModifiedElement(view, name) { + is(view._elementStyle.rules.length, 3, "Should have 3 rules."); + ok(getRuleViewRule(view, name), "Rule with " + name + " selector exists."); +} + +async function testAddProperty(view) { + info("Test creating a new property"); + const textProp = await addProperty(view, 1, "text-align", "center"); + + is(textProp.value, "center", "Text prop should have been changed."); + ok(!textProp.overridden, "Property should not be overridden"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector_06.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector_06.js new file mode 100644 index 0000000000..8f418d2320 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector_06.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing selector inplace-editor behaviors in the rule-view with unmatched +// selectors + +const TEST_URI = ` + <style type="text/css"> + .testclass { + text-align: center; + } + div { + } + </style> + <div class="testclass">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode(".testclass", inspector); + await testEditClassSelector(view); + await testEditDivSelector(view); +}); + +async function testEditClassSelector(view) { + let ruleEditor = getRuleViewRuleEditor(view, 1); + const editor = await focusEditableField(view, ruleEditor.selectorText); + + editor.input.value = "body"; + const onRuleViewChanged = once(view, "ruleview-changed"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + // Get the new rule editor that replaced the original + ruleEditor = getRuleViewRuleEditor(view, 1); + const propEditor = ruleEditor.rule.textProps[0].editor; + + info("Check that the correct rules are visible"); + is(view._elementStyle.rules.length, 3, "Should have 3 rules."); + ok(ruleEditor.element.getAttribute("unmatched"), "Rule editor is unmatched."); + is( + getRuleViewRule(view, ".testclass"), + undefined, + "Rule with .testclass selector should not exist." + ); + ok(getRuleViewRule(view, "body"), "Rule with body selector exists."); + is( + inplaceEditor(propEditor.nameSpan), + inplaceEditor(view.styleDocument.activeElement), + "Focus should have moved to the property name." + ); +} + +async function testEditDivSelector(view) { + let ruleEditor = getRuleViewRuleEditor(view, 2); + const editor = await focusEditableField(view, ruleEditor.selectorText); + + editor.input.value = "asdf"; + const onRuleViewChanged = once(view, "ruleview-changed"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + // Get the new rule editor that replaced the original + ruleEditor = getRuleViewRuleEditor(view, 2); + + info("Check that the correct rules are visible"); + is(view._elementStyle.rules.length, 3, "Should have 3 rules."); + ok(ruleEditor.element.getAttribute("unmatched"), "Rule editor is unmatched."); + is( + getRuleViewRule(view, "div"), + undefined, + "Rule with div selector should not exist." + ); + ok(getRuleViewRule(view, "asdf"), "Rule with asdf selector exists."); + is( + inplaceEditor(ruleEditor.newPropSpan), + inplaceEditor(view.styleDocument.activeElement), + "Focus should have moved to the property name." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector_07.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector_07.js new file mode 100644 index 0000000000..0949a966e8 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector_07.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view overridden search filter does not appear for an +// unmatched rule. + +const TEST_URI = ` + <style type="text/css"> + div { + height: 0px; + } + #testid { + height: 1px; + } + .testclass { + height: 10px; + } + </style> + <div id="testid">Styled Node</div> + <span class="testclass">This is a span</span> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await selectNode("#testid", inspector); + await testEditSelector(view, "span"); +}); + +async function testEditSelector(view, name) { + info("Test editing existing selector fields"); + + let ruleEditor = getRuleViewRuleEditor(view, 1); + + info("Focusing an existing selector name in the rule-view"); + const editor = await focusEditableField(view, ruleEditor.selectorText); + + is( + inplaceEditor(ruleEditor.selectorText), + editor, + "The selector editor got focused" + ); + + info("Entering a new selector name and committing"); + editor.input.value = name; + + info("Entering the commit key"); + const onRuleViewChanged = once(view, "ruleview-changed"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + // Get the new rule editor that replaced the original + ruleEditor = getRuleViewRuleEditor(view, 1); + const rule = ruleEditor.rule; + const textPropEditor = rule.textProps[0].editor; + + is(view._elementStyle.rules.length, 3, "Should have 3 rules."); + ok(getRuleViewRule(view, name), "Rule with " + name + " selector exists."); + ok( + ruleEditor.element.getAttribute("unmatched"), + "Rule with " + name + " does not match the current element." + ); + ok(textPropEditor.filterProperty.hidden, "Overridden search is hidden."); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector_08.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector_08.js new file mode 100644 index 0000000000..112e2c83c7 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector_08.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that reverting a selector edit does the right thing. +// Bug 1241046. + +const TEST_URI = ` + <style type="text/css"> + span { + color: chartreuse; + } + </style> + <span> + <div id="testid" class="testclass">Styled Node</div> + </span> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Selecting the test element"); + await selectNode("#testid", inspector); + + let idRuleEditor = getRuleViewRuleEditor(view, 2); + + info("Focusing an existing selector name in the rule-view"); + let editor = await focusEditableField(view, idRuleEditor.selectorText); + + is( + inplaceEditor(idRuleEditor.selectorText), + editor, + "The selector editor got focused" + ); + + info("Entering a new selector name and committing"); + editor.input.value = "pre"; + + info("Waiting for rule view to update"); + let onRuleViewChanged = once(view, "ruleview-changed"); + + info("Entering the commit key"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + info("Re-focusing the selector name in the rule-view"); + idRuleEditor = getRuleViewRuleEditor(view, 2); + editor = await focusEditableField(view, idRuleEditor.selectorText); + + is(view._elementStyle.rules.length, 2, "Should have 2 rules."); + ok(getRuleViewRule(view, "pre"), "Rule with pre selector exists."); + is( + getRuleViewRuleEditor(view, 2).element.getAttribute("unmatched"), + "true", + "Rule with pre does not match the current element." + ); + + // Now change it back. + info("Re-entering original selector name and committing"); + editor.input.value = "span"; + + info("Waiting for rule view to update"); + onRuleViewChanged = once(view, "ruleview-changed"); + + info("Entering the commit key"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + is(view._elementStyle.rules.length, 2, "Should have 2 rules."); + ok(getRuleViewRule(view, "span"), "Rule with span selector exists."); + is( + getRuleViewRuleEditor(view, 2).element.getAttribute("unmatched"), + "false", + "Rule with span matches the current element." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector_09.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector_09.js new file mode 100644 index 0000000000..f9ee73098c --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector_09.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that editing a selector to an unmatched rule does set up the correct +// property on the rule, and that settings property in said rule does not +// lead to overriding properties from matched rules. +// Test that having a rule with both matched and unmatched selectors does work +// correctly. + +const TEST_URI = ` + <style type="text/css"> + #testid { + color: black; + } + .testclass { + background-color: white; + } + </style> + <div id="testid">Styled Node</div> + <span class="testclass">This is a span</span> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await selectNode("#testid", inspector); + await testEditSelector(view, "span"); + await testAddImportantProperty(view); + await testAddMatchedRule(view, "span, div"); +}); + +async function testEditSelector(view, name) { + info("Test editing existing selector fields"); + + const ruleEditor = getRuleViewRuleEditor(view, 1); + + info("Focusing an existing selector name in the rule-view"); + const editor = await focusEditableField(view, ruleEditor.selectorText); + + is( + inplaceEditor(ruleEditor.selectorText), + editor, + "The selector editor got focused" + ); + + info("Entering a new selector name and committing"); + editor.input.value = name; + + info("Waiting for rule view to update"); + const onRuleViewChanged = once(view, "ruleview-changed"); + + info("Entering the commit key"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + ok(getRuleViewRule(view, name), "Rule with " + name + " selector exists."); + ok( + getRuleViewRuleEditor(view, 1).element.getAttribute("unmatched"), + "Rule with " + name + " does not match the current element." + ); + + // Escape the new property editor after editing the selector + const onBlur = once(view.styleDocument.activeElement, "blur"); + EventUtils.synthesizeKey("KEY_Escape", {}, view.styleWindow); + await onBlur; +} + +async function testAddImportantProperty(view) { + info("Test creating a new property with !important"); + const textProp = await addProperty(view, 1, "color", "red !important"); + + is(textProp.value, "red", "Text prop should have been changed."); + is(textProp.priority, "important", 'Text prop has an "important" priority.'); + ok(!textProp.overridden, "Property should not be overridden"); + + const prop = getTextProperty(view, 1, { color: "black" }); + ok( + !prop.overridden, + "Existing property on matched rule should not be overridden" + ); +} + +async function testAddMatchedRule(view, name) { + info("Test adding a matching selector"); + + const ruleEditor = getRuleViewRuleEditor(view, 1); + + info("Focusing an existing selector name in the rule-view"); + const editor = await focusEditableField(view, ruleEditor.selectorText); + + is( + inplaceEditor(ruleEditor.selectorText), + editor, + "The selector editor got focused" + ); + + info("Entering a new selector name and committing"); + editor.input.value = name; + + info("Waiting for rule view to update"); + const onRuleViewChanged = once(view, "ruleview-changed"); + + info("Entering the commit key"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + is( + getRuleViewRuleEditor(view, 1).element.getAttribute("unmatched"), + "false", + "Rule with " + name + " does match the current element." + ); + + // Escape the new property editor after editing the selector + const onBlur = once(view.styleDocument.activeElement, "blur"); + EventUtils.synthesizeKey("KEY_Escape", {}, view.styleWindow); + await onBlur; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector_10.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector_10.js new file mode 100644 index 0000000000..26ee52d64e --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector_10.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Regression test for bug 1293616: make sure that editing a selector +// keeps the rule in the proper position. + +const TEST_URI = ` + <style type="text/css"> + #testid span, #testid p { + background: aqua; + } + span { + background: fuchsia; + } + </style> + <div id="testid"> + <span class="pickme"> + Styled Node + </span> + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode(".pickme", inspector); + await testEditSelector(view); +}); + +async function testEditSelector(view) { + let ruleEditor = getRuleViewRuleEditor(view, 1); + const editor = await focusEditableField(view, ruleEditor.selectorText); + + editor.input.value = "#testid span"; + const onRuleViewChanged = once(view, "ruleview-changed"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + // Escape the new property editor after editing the selector + const onBlur = once(view.styleDocument.activeElement, "blur"); + EventUtils.synthesizeKey("KEY_Escape", {}, view.styleWindow); + await onBlur; + + // Get the new rule editor that replaced the original + ruleEditor = getRuleViewRuleEditor(view, 1); + + info("Check that the correct rules are visible"); + is(view._elementStyle.rules.length, 3, "Should have 3 rules."); + is( + ruleEditor.element.getAttribute("unmatched"), + "false", + "Rule editor is matched." + ); + + let props = ruleEditor.rule.textProps; + is(props.length, 1, "Rule has correct number of properties"); + is(props[0].name, "background", "Found background property"); + ok(!props[0].overridden, "Background property is not overridden"); + + ruleEditor = getRuleViewRuleEditor(view, 2); + props = ruleEditor.rule.textProps; + is(props.length, 1, "Rule has correct number of properties"); + is(props[0].name, "background", "Found background property"); + ok(props[0].overridden, "Background property is overridden"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector_11.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector_11.js new file mode 100644 index 0000000000..6882686eff --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector_11.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Regression test for bug 1293616, where editing a selector should +// change the relative priority of the rule. + +const TEST_URI = ` + <style type="text/css"> + #testid { + background: aqua; + } + .pickme { + background: seagreen; + } + span { + background: fuchsia; + } + </style> + <div> + <span id="testid" class="pickme"> + Styled Node + </span> + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode(".pickme", inspector); + await testEditSelector(view); +}); + +async function testEditSelector(view) { + let ruleEditor = getRuleViewRuleEditor(view, 1); + const editor = await focusEditableField(view, ruleEditor.selectorText); + + editor.input.value = ".pickme"; + const onRuleViewChanged = once(view, "ruleview-changed"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + // Escape the new property editor after editing the selector + const onBlur = once(view.styleDocument.activeElement, "blur"); + EventUtils.synthesizeKey("KEY_Escape", {}, view.styleWindow); + await onBlur; + + // Get the new rule editor that replaced the original + ruleEditor = getRuleViewRuleEditor(view, 1); + + info("Check that the correct rules are visible"); + is(view._elementStyle.rules.length, 4, "Should have 4 rules."); + is( + ruleEditor.element.getAttribute("unmatched"), + "false", + "Rule editor is matched." + ); + + let props = ruleEditor.rule.textProps; + is(props.length, 1, "Rule has correct number of properties"); + is(props[0].name, "background", "Found background property"); + is(props[0].value, "aqua", "Background property is aqua"); + ok(props[0].overridden, "Background property is overridden"); + + ruleEditor = getRuleViewRuleEditor(view, 2); + props = ruleEditor.rule.textProps; + is(props.length, 1, "Rule has correct number of properties"); + is(props[0].name, "background", "Found background property"); + is(props[0].value, "seagreen", "Background property is seagreen"); + ok(!props[0].overridden, "Background property is not overridden"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-selector_12.js b/devtools/client/inspector/rules/test/browser_rules_edit-selector_12.js new file mode 100644 index 0000000000..187836b860 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-selector_12.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test editing selectors for rules inside @import'd stylesheets. +// This is a regression test for bug 1355819. + +add_task(async function () { + await addTab(URL_ROOT + "doc_edit_imported_selector.html"); + const { inspector, view } = await openRuleView(); + + info("Select the node styled by an @import'd rule"); + await selectNode("#target", inspector); + + info("Focus the selector in the rule-view"); + let ruleEditor = getRuleViewRuleEditor(view, 1); + const editor = await focusEditableField(view, ruleEditor.selectorText); + + info("Change the selector to something else"); + editor.input.value = "div"; + const onRuleViewChanged = once(view, "ruleview-changed"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + info("Escape the new property editor after editing the selector"); + const onBlur = once(view.styleDocument.activeElement, "blur"); + EventUtils.synthesizeKey("KEY_Escape", {}, view.styleWindow); + await onBlur; + + info("Check the rules are still displayed correctly"); + is(view._elementStyle.rules.length, 3, "The element still has 3 rules."); + + ruleEditor = getRuleViewRuleEditor(view, 1); + is( + ruleEditor.element.getAttribute("unmatched"), + "false", + "Rule editor is matched." + ); + is(ruleEditor.selectorText.textContent, "div", "The new selector is correct"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-size-property-dragging.js b/devtools/client/inspector/rules/test/browser_rules_edit-size-property-dragging.js new file mode 100644 index 0000000000..c95506de50 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-size-property-dragging.js @@ -0,0 +1,481 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that increasing / decreasing values in rule view by dragging with +// the mouse works correctly. + +const TEST_URI = ` + <style> + #test { + padding-top: 10px; + margin-top: unset; + margin-bottom: 0px; + width: 0px; + border: 1px solid red; + line-height: 2; + border-width: var(--12px); + max-height: +10.2e3vmin; + min-height: 1% !important; + font-size: 10Q; + transform: rotate(45deg); + margin-left: 28.3em; + animation-delay: +15s; + margin-right: -2px; + padding-bottom: .9px; + rotate: 90deg; + } + </style> + <div id="test"></div> +`; + +const DRAGGABLE_VALUE_CLASSNAME = "ruleview-propertyvalue-draggable"; + +add_task(async function () { + await pushPref("devtools.inspector.draggable_properties", true); + + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + + const { inspector, view } = await openRuleView(); + await selectNode("#test", inspector); + + testDraggingClassIsAddedWhenNeeded(view); + + // Check that toggling the feature updates the UI immediately. + await pushPref("devtools.inspector.draggable_properties", false); + testDraggingClassIsRemovedAfterPrefChange(view); + + await pushPref("devtools.inspector.draggable_properties", true); + testDraggingClassIsAddedWhenNeeded(view); + + await testIncrementAngleValue(view); + await testPressingEscapeWhileDragging(view); + await testUpdateDisabledValue(view); + await testWidthIncrements(view); + await testDraggingClassIsAddedOnValueUpdate(view); +}); + +const PROPERTIES = [ + { + name: "border", + value: "1px solid red", + shouldBeDraggable: false, + }, + { + name: "line-height", + value: "2", + shouldBeDraggable: false, + }, + { + name: "border-width", + value: "var(--12px)", + shouldBeDraggable: false, + }, + { + name: "transform", + value: "rotate(45deg)", + shouldBeDraggable: false, + }, + { + name: "max-height", + value: "+10.2e3vmin", + shouldBeDraggable: true, + }, + { + name: "min-height", + value: "1%", + shouldBeDraggable: true, + }, + { + name: "font-size", + value: "10Q", + shouldBeDraggable: true, + }, + { + name: "margin-left", + value: "28.3em", + shouldBeDraggable: true, + }, + { + name: "animation-delay", + value: "+15s", + shouldBeDraggable: true, + }, + { + name: "margin-right", + value: "-2px", + shouldBeDraggable: true, + }, + { + name: "padding-bottom", + value: ".9px", + shouldBeDraggable: true, + }, + { + name: "rotate", + value: "90deg", + shouldBeDraggable: true, + }, +]; + +function testDraggingClassIsAddedWhenNeeded(view) { + info("Testing class is added or not on different property values"); + runIsDraggableTest(view, PROPERTIES); +} + +function testDraggingClassIsRemovedAfterPrefChange(view) { + info("Testing class is removed if the feature is disabled"); + runIsDraggableTest( + view, + // Create a temporary copy of the test PROPERTIES, where shouldBeDraggable is + // always false. + PROPERTIES.map(prop => + Object.assign({}, prop, { shouldBeDraggable: false }) + ) + ); +} + +async function testIncrementAngleValue(view) { + info("Testing updating an angle value with the angle swatch span"); + const rotatePropEditor = getTextProperty(view, 1, { + rotate: "90deg", + }).editor; + await runIncrementTest(rotatePropEditor, view, [ + { + startValue: "90deg", + expectedEndValue: "100deg", + distance: 10, + description: "updating angle value", + }, + ]); +} + +async function testPressingEscapeWhileDragging(view) { + info("Testing pressing escape while dragging with mouse"); + const marginPropEditor = getTextProperty(view, 1, { + "margin-bottom": "0px", + }).editor; + await runIncrementTest(marginPropEditor, view, [ + { + startValue: "0px", + expectedEndValue: "0px", + expectedEndValueBeforeEscape: "100px", + escape: true, + distance: 100, + description: "Pressing escape to check if value has been reset", + }, + ]); +} + +async function testUpdateDisabledValue(view) { + info("Testing updating a disabled value by dragging mouse"); + + const textProperty = getTextProperty(view, 1, { "padding-top": "10px" }); + const editor = textProperty.editor; + + await togglePropStatus(view, textProperty); + ok(!editor.enable.checked, "Should be disabled"); + await runIncrementTest(editor, view, [ + { + startValue: "10px", + expectedEndValue: "110px", + distance: 100, + description: "Updating disabled value", + }, + ]); + ok(editor.enable.checked, "Should be enabled"); +} + +async function testWidthIncrements(view) { + info("Testing dragging the mouse on the width property"); + + const marginPropEditor = getTextProperty(view, 1, { width: "0px" }).editor; + await runIncrementTest(marginPropEditor, view, [ + { + startValue: "0px", + expectedEndValue: "20px", + distance: 20, + description: "Increasing value while dragging", + }, + { + startValue: "20px", + expectedEndValue: "0px", + distance: -20, + description: "Decreasing value while dragging", + }, + { + startValue: "0px", + expectedEndValue: "2px", + ...getSmallIncrementKey(), + distance: 20, + description: + "Increasing value with small increments by pressing ctrl or alt", + }, + { + startValue: "2px", + expectedEndValue: "202px", + shift: true, + distance: 20, + description: "Increasing value with large increments by pressing shift", + }, + { + startValue: "202px", + expectedEndValue: "402px", + distance: 200, + description: "Increasing value with long distance", + }, + { + startValue: "402px", + expectedEndValue: "402px", + distance: marginPropEditor._DRAGGING_DEADZONE_DISTANCE - 1, + description: "No change in the deadzone (positive value)", + deadzoneIncluded: true, + }, + { + startValue: "402px", + expectedEndValue: "402px", + distance: -1 * (marginPropEditor._DRAGGING_DEADZONE_DISTANCE - 1), + description: "No change in the deadzone (negative value)", + deadzoneIncluded: true, + }, + { + startValue: "402px", + expectedEndValue: "403px", + distance: marginPropEditor._DRAGGING_DEADZONE_DISTANCE + 1, + description: "Changed by 1 when leaving the deadzone (positive value)", + deadzoneIncluded: true, + }, + { + startValue: "403px", + expectedEndValue: "402px", + distance: -1 * (marginPropEditor._DRAGGING_DEADZONE_DISTANCE + 1), + description: "Changed by 1 when leaving the deadzone (negative value)", + deadzoneIncluded: true, + }, + ]); +} + +async function testDraggingClassIsAddedOnValueUpdate(view) { + info("Testing dragging class is added when a supported unit is detected"); + + const editor = getTextProperty(view, 1, { "margin-top": "unset" }).editor; + const valueSpan = editor.valueSpan; + ok( + !valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME), + "Should not be draggable" + ); + valueSpan.scrollIntoView(); + await setProperty(view, editor.prop, "23em"); + ok( + valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME), + "Should be draggable" + ); +} + +/** + * Runs each test and check whether or not the property is draggable + * + * @param {CSSRuleView} view + * @param {Array.<{ + * name: String, + * value: String, + * shouldBeDraggable: Boolean, + * }>} tests + */ +function runIsDraggableTest(view, tests) { + for (const test of tests) { + const property = test; + info(`Testing ${property.name} with value ${property.value}`); + const editor = getTextProperty(view, 1, { + [property.name]: property.value, + }).editor; + const valueSpan = editor.valueSpan; + if (property.shouldBeDraggable) { + ok( + valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME), + "Should be draggable" + ); + } else { + ok( + !valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME), + "Should not be draggable" + ); + } + } +} + +/** + * Runs each test in the tests array by synthesizing a mouse dragging + * + * @param {TextPropertyEditor} editor + * @param {CSSRuleView} view + * @param {Array} tests + */ +async function runIncrementTest(editor, view, tests) { + for (const test of tests) { + await testIncrement(editor, test, view); + } + view.debounce.flush(); +} + +/** + * Runs an increment test + * + * 1. We initialize the TextProperty value with "startValue" + * 2. We synthesize a mouse dragging of "distance" length + * 3. We check the value of TextProperty is equal to "expectedEndValue" + * + * @param {TextPropertyEditor} editor + * @param {Array} options + * @param {String} options.startValue + * @param {String} options.expectedEndValue + * @param {Boolean} options.shift Whether or not we press the shift key + * @param {Number} options.distance Distance of the dragging + * @param {String} options.description + * @param {Boolean} options.ctrl Small increment key + * @param {Boolean} options.alt Small increment key for macosx + * @param {Boolean} option.deadzoneIncluded True if the provided distance + * accounts for the deadzone. When false, the deadzone will automatically + * be added to the distance. + * @param {CSSRuleView} view + */ +async function testIncrement(editor, options, view) { + info("Running subtest: " + options.description); + + editor.valueSpan.scrollIntoView(); + await setProperty(editor.ruleView, editor.prop, options.startValue); + + is( + editor.prop.value, + options.startValue, + "Value initialized at " + options.startValue + ); + + const onMouseUp = once(editor.valueSpan, "mouseup"); + + await synthesizeMouseDragging(editor, options.distance, options); + + // mouseup event not triggered when escape is pressed + if (!options.escape) { + info("Waiting mouseup"); + await onMouseUp; + info("Received mouseup"); + } + + is( + editor.prop.value, + options.expectedEndValue, + "Value changed to " + editor.prop.value + ); +} + +/** + * Synthesizes mouse dragging (mousedown + mousemove + mouseup) + * + * @param {TextPropertyEditor} editor + * @param {Number} distance length of the horizontal dragging (negative if dragging left) + * @param {Object} option + * @param {Boolean} option.escape + * @param {Boolean} option.alt + * @param {Boolean} option.shift + * @param {Boolean} option.ctrl + * @param {Boolean} option.deadzoneIncluded + */ +async function synthesizeMouseDragging(editor, distance, options = {}) { + info(`Start to synthesize mouse dragging (from ${1} to ${1 + distance})`); + + const styleWindow = editor.ruleView.styleWindow; + const elm = editor.valueSpan; + const startPosition = [1, 1]; + + // Handle the pixel based deadzone. + const deadzone = editor._DRAGGING_DEADZONE_DISTANCE; + if (!options.deadzoneIncluded) { + // Most tests do not care about the deadzone and the provided distance does + // not account for the deadzone. Add it automatically. + distance = distance + Math.sign(distance) * deadzone; + } + const updateExpected = Math.abs(options.distance) > deadzone; + + const endPosition = [startPosition[0] + distance, startPosition[1]]; + + EventUtils.synthesizeMouse( + elm, + startPosition[0], + startPosition[1], + { type: "mousedown" }, + styleWindow + ); + + // If the drag will not trigger any update, simply wait for 100ms. + // Otherwise, wait for the next property-updated-by-dragging event. + const updated = updateExpected + ? editor.ruleView.once("property-updated-by-dragging") + : wait(100); + + EventUtils.synthesizeMouse( + elm, + endPosition[0], + endPosition[1], + { + type: "mousemove", + shiftKey: !!options.shift, + ctrlKey: !!options.ctrl, + altKey: !!options.alt, + }, + styleWindow + ); + + // We wait because the mousemove event listener is throttled to 30ms + // in the TextPropertyEditor class + info("waiting for event property-updated-by-dragging"); + await updated; + ok(true, "received event property-updated-by-dragging"); + + if (options.escape) { + is( + editor.prop.value, + options.expectedEndValueBeforeEscape, + "testing value before pressing escape" + ); + EventUtils.synthesizeKey("VK_ESCAPE", {}, styleWindow); + } + + // If the drag will not trigger any update, simply wait for 100ms. + // Otherwise, wait for the next ruleview-changed event. + const done = updateExpected + ? editor.ruleView.once("ruleview-changed") + : wait(100); + + EventUtils.synthesizeMouse( + elm, + endPosition[0], + endPosition[1], + { + type: "mouseup", + }, + styleWindow + ); + await done; + + // If the drag did not trigger any update, mouseup might open an inline editor. + // Leave the editor. + const inplaceEditor = styleWindow.document.querySelector( + ".styleinspector-propertyeditor" + ); + if (inplaceEditor) { + const onBlur = once(inplaceEditor, "blur"); + EventUtils.synthesizeKey("VK_ESCAPE", {}, styleWindow); + await onBlur; + } + + info("Finish to synthesize mouse dragging"); +} + +function getSmallIncrementKey() { + if (AppConstants.platform === "macosx") { + return { alt: true }; + } + return { ctrl: true }; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-value-after-name_01.js b/devtools/client/inspector/rules/test/browser_rules_edit-value-after-name_01.js new file mode 100644 index 0000000000..a3813f1f7b --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-value-after-name_01.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that clicking on swatch-preceeded value while editing the property name +// will result in editing the property value. Also tests that the value span is updated +// only if the property name has changed. See also Bug 1248274. + +const TEST_URI = ` + <style type="text/css"> + #testid { + color: red; + } + </style> + <div id="testid">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await selectNode("#testid", inspector); + const prop = getTextProperty(view, 1, { color: "red" }); + const propEditor = prop.editor; + + await testColorValueSpanClickWithoutNameChange(propEditor, view); + await testColorValueSpanClickAfterNameChange(propEditor, view); +}); + +async function testColorValueSpanClickWithoutNameChange(propEditor, view) { + info("Test click on color span while focusing property name editor"); + const colorSpan = propEditor.valueSpan.querySelector(".ruleview-color"); + + info("Focus the color name span"); + await focusEditableField(view, propEditor.nameSpan); + let editor = inplaceEditor(propEditor.doc.activeElement); + + // We add a click event to make sure the color span won't be cleared + // on nameSpan blur (which would lead to the click event not being triggered) + const onColorSpanClick = once(colorSpan, "click"); + + // The property-value-updated is emitted when the valueSpan markup is being + // re-populated, which should not be the case when not modifying the property name + const onPropertyValueUpdated = function () { + ok(false, 'The "property-value-updated" should not be emitted'); + }; + view.on("property-value-updated", onPropertyValueUpdated); + + info("blur propEditor.nameSpan by clicking on the color span"); + EventUtils.synthesizeMouse(colorSpan, 1, 1, {}, propEditor.doc.defaultView); + + info("wait for the click event on the color span"); + await onColorSpanClick; + ok(true, "Expected click event was emitted"); + + editor = inplaceEditor(propEditor.doc.activeElement); + is( + inplaceEditor(propEditor.valueSpan), + editor, + "The property value editor got focused" + ); + + // We remove this listener in order to not cause unwanted conflict in the next test + view.off("property-value-updated", onPropertyValueUpdated); + + info( + "blur valueSpan editor to trigger ruleview-changed event and prevent " + + "having pending request" + ); + const onRuleViewChanged = view.once("ruleview-changed"); + editor.input.blur(); + await onRuleViewChanged; +} + +async function testColorValueSpanClickAfterNameChange(propEditor, view) { + info("Test click on color span after property name change"); + const colorSpan = propEditor.valueSpan.querySelector(".ruleview-color"); + + info("Focus the color name span"); + await focusEditableField(view, propEditor.nameSpan); + let editor = inplaceEditor(propEditor.doc.activeElement); + + info( + "Modify the property to border-color to trigger the " + + "property-value-updated event" + ); + editor.input.value = "border-color"; + + let onRuleViewChanged = view.once("ruleview-changed"); + const onPropertyValueUpdate = view.once("property-value-updated"); + + info("blur propEditor.nameSpan by clicking on the color span"); + EventUtils.synthesizeMouse(colorSpan, 1, 1, {}, propEditor.doc.defaultView); + + info( + "wait for ruleview-changed event to be triggered to prevent pending requests" + ); + await onRuleViewChanged; + + info("wait for the property value to be updated"); + await onPropertyValueUpdate; + ok(true, 'Expected "property-value-updated" event was emitted'); + + editor = inplaceEditor(propEditor.doc.activeElement); + is( + inplaceEditor(propEditor.valueSpan), + editor, + "The property value editor got focused" + ); + + info( + "blur valueSpan editor to trigger ruleview-changed event and prevent " + + "having pending request" + ); + onRuleViewChanged = view.once("ruleview-changed"); + editor.input.blur(); + await onRuleViewChanged; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-value-after-name_02.js b/devtools/client/inspector/rules/test/browser_rules_edit-value-after-name_02.js new file mode 100644 index 0000000000..95b6dccea9 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-value-after-name_02.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that hitting shift + click on color swatch while editing the property +// name will only change the color unit and not lead to edit the property value. +// See also Bug 1248274. + +const TEST_URI = ` + <style type="text/css"> + #testid { + color: red; + background: linear-gradient( + 90deg, + rgb(183,222,237), + rgb(33,180,226), + rgb(31,170,217), + rgba(200,170,140,0.5)); + } + </style> + <div id="testid">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Test shift + click on color swatch while editing property name"); + + await selectNode("#testid", inspector); + const prop = getTextProperty(view, 1, { + background: + "linear-gradient( 90deg, rgb(183,222,237), rgb(33,180,226), rgb(31,170,217), rgba(200,170,140,0.5))", + }); + const propEditor = prop.editor; + + const swatchSpan = propEditor.valueSpan.querySelectorAll( + ".ruleview-colorswatch" + )[2]; + + info("Focus the background name span"); + await focusEditableField(view, propEditor.nameSpan); + const editor = inplaceEditor(propEditor.doc.activeElement); + + info( + "Modify the property to background-image to trigger the " + + "property-value-updated event" + ); + editor.input.value = "background-image"; + + const onPropertyValueUpdate = view.once("property-value-updated"); + const onSwatchUnitChange = swatchSpan.once("unit-change"); + const onRuleViewChanged = view.once("ruleview-changed"); + + info("blur propEditor.nameSpan by clicking on the color swatch"); + EventUtils.synthesizeMouseAtCenter( + swatchSpan, + { shiftKey: true }, + propEditor.doc.defaultView + ); + + info( + "wait for ruleview-changed event to be triggered to prevent pending requests" + ); + await onRuleViewChanged; + + info("wait for the color unit to change"); + await onSwatchUnitChange; + ok(true, "the color unit was changed"); + + info("wait for the property value to be updated"); + await onPropertyValueUpdate; + + ok( + !inplaceEditor(propEditor.valueSpan), + "The inplace editor wasn't shown " + + "as a result of the color swatch shift + click" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-value-after-name_03.js b/devtools/client/inspector/rules/test/browser_rules_edit-value-after-name_03.js new file mode 100644 index 0000000000..c43751da1f --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-value-after-name_03.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that clicking on color swatch while editing the property name +// will show the color tooltip with the correct value. See also Bug 1248274. + +const TEST_URI = ` + <style type="text/css"> + #testid { + color: red; + background: linear-gradient( + 90deg, + rgb(183,222,237), + rgb(33,180,226), + rgb(31,170,217), + rgba(200,170,140,0.5)); + } + </style> + <div id="testid">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Test click on color swatch while editing property name"); + + await selectNode("#testid", inspector); + const prop = getTextProperty(view, 1, { + background: + "linear-gradient( 90deg, rgb(183,222,237), rgb(33,180,226), rgb(31,170,217), rgba(200,170,140,0.5))", + }); + const propEditor = prop.editor; + + const swatchSpan = propEditor.valueSpan.querySelectorAll( + ".ruleview-colorswatch" + )[3]; + const colorPicker = view.tooltips.getTooltip("colorPicker"); + + info("Focus the background name span"); + await focusEditableField(view, propEditor.nameSpan); + const editor = inplaceEditor(propEditor.doc.activeElement); + + info( + "Modify the background property to background-image to trigger the " + + "property-value-updated event" + ); + editor.input.value = "background-image"; + + const onRuleViewChanged = view.once("ruleview-changed"); + const onPropertyValueUpdate = view.once("property-value-updated"); + const onReady = colorPicker.once("ready"); + + info("blur propEditor.nameSpan by clicking on the color swatch"); + EventUtils.synthesizeMouseAtCenter( + swatchSpan, + {}, + propEditor.doc.defaultView + ); + + info( + "wait for ruleview-changed event to be triggered to prevent pending requests" + ); + await onRuleViewChanged; + + info("wait for the property value to be updated"); + await onPropertyValueUpdate; + + info("wait for the color picker to be shown"); + await onReady; + + ok(true, "The color picker was shown on click of the color swatch"); + ok( + !inplaceEditor(propEditor.valueSpan), + "The inplace editor wasn't shown as a result of the color swatch click" + ); + + const spectrum = colorPicker.spectrum; + is( + `"${spectrum.rgb}"`, + '"200,170,140,0.5"', + "The correct color picker was shown" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-value-after-name_04.js b/devtools/client/inspector/rules/test/browser_rules_edit-value-after-name_04.js new file mode 100644 index 0000000000..a315f5bec5 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-value-after-name_04.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that clicking on a property's value URL while editing the property name +// will open the link in a new tab. See also Bug 1248274. + +const TEST_URI = ` + <style type="text/css"> + #testid { + background: url("https://example.com/browser/devtools/client/inspector/rules/test/doc_test_image.png"), linear-gradient(white, #F06 400px); + } + </style> + <div id="testid">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Test click on background-image url while editing property name"); + + await selectNode("#testid", inspector); + const ruleEditor = getRuleViewRuleEditor(view, 1); + const propEditor = ruleEditor.rule.textProps[0].editor; + const anchor = propEditor.valueSpan.querySelector( + ".ruleview-propertyvalue .theme-link" + ); + + info("Focus the background name span"); + await focusEditableField(view, propEditor.nameSpan); + const editor = inplaceEditor(propEditor.doc.activeElement); + + info( + "Modify the property to background to trigger the " + + "property-value-updated event" + ); + editor.input.value = "background-image"; + + const onRuleViewChanged = view.once("ruleview-changed"); + const onPropertyValueUpdate = view.once("property-value-updated"); + const onTabOpened = waitForTab(); + + info("blur propEditor.nameSpan by clicking on the link"); + // The url can be wrapped across multiple lines, and so we click the lower left corner + // of the anchor to make sure to target the link. + const rect = anchor.getBoundingClientRect(); + EventUtils.synthesizeMouse( + anchor, + 2, + rect.height - 2, + {}, + propEditor.doc.defaultView + ); + + info( + "wait for ruleview-changed event to be triggered to prevent pending requests" + ); + await onRuleViewChanged; + + info("wait for the property value to be updated"); + await onPropertyValueUpdate; + + info("wait for the image to be open in a new tab"); + const tab = await onTabOpened; + ok(true, "A new tab opened"); + + is( + tab.linkedBrowser.currentURI.spec, + anchor.href, + "The URL for the new tab is correct" + ); + + gBrowser.removeTab(tab); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-variable-add.js b/devtools/client/inspector/rules/test/browser_rules_edit-variable-add.js new file mode 100644 index 0000000000..3c4c9a25a4 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-variable-add.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test adding a CSS variable. + +const TEST_URI = ` + <style type='text/css'> + div { + color: var(--color); + } + </style> + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + info("Check the initial state of --color which refers to an unset variable"); + checkCSSVariableOutput( + view, + "div", + "color", + "ruleview-unmatched-variable", + "--color is not set" + ); + + info("Add the --color CSS variable"); + await addProperty(view, 0, "--color", "lime"); + checkCSSVariableOutput( + view, + "div", + "color", + "ruleview-variable", + "--color = lime" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-variable-remove.js b/devtools/client/inspector/rules/test/browser_rules_edit-variable-remove.js new file mode 100644 index 0000000000..322cc8e166 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-variable-remove.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test removing a CSS variable. + +const TEST_URI = ` + <style type='text/css'> + div { + color: var(--color); + --color: lime; + } + </style> + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + info("Check the initial state of the --color variable"); + checkCSSVariableOutput( + view, + "div", + "color", + "ruleview-variable", + "--color = lime" + ); + + info("Remove the --color variable declaration"); + const prop = getTextProperty(view, 1, { "--color": "lime" }); + await removeProperty(view, prop); + checkCSSVariableOutput( + view, + "div", + "color", + "ruleview-unmatched-variable", + "--color is not set" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_edit-variable.js b/devtools/client/inspector/rules/test/browser_rules_edit-variable.js new file mode 100644 index 0000000000..0df57d9bf1 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_edit-variable.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test editing a CSS variable. + +const TEST_URI = ` + <style type='text/css'> + div { + color: var(--color); + --color: lime; + } + </style> + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + info("Check the initial state of the --color variable"); + checkCSSVariableOutput( + view, + "div", + "color", + "ruleview-variable", + "--color = lime" + ); + + info("Edit the CSS variable"); + const prop = getTextProperty(view, 1, { "--color": "lime" }); + const propEditor = prop.editor; + const editor = await focusEditableField(view, propEditor.valueSpan); + editor.input.value = "blue"; + const onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + await onRuleViewChanged; + checkCSSVariableOutput( + view, + "div", + "color", + "ruleview-variable", + "--color = blue" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_editable-field-focus_01.js b/devtools/client/inspector/rules/test/browser_rules_editable-field-focus_01.js new file mode 100644 index 0000000000..5665d517db --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_editable-field-focus_01.js @@ -0,0 +1,111 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the correct editable fields are focused when tabbing and entering +// through the rule view. + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: blue; + color: red; + margin: 0; + padding: 0; + } + div { + border-color: red + } + </style> + <div id='testid'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testEditableFieldFocus(inspector, view, "KEY_Enter"); + await testEditableFieldFocus(inspector, view, "KEY_Tab"); +}); + +async function testEditableFieldFocus(inspector, view, commitKey) { + info("Click on the selector of the inline style ('element')"); + let ruleEditor = getRuleViewRuleEditor(view, 0); + const onFocus = once(ruleEditor.element, "focus", true); + ruleEditor.selectorText.click(); + await onFocus; + assertEditor( + view, + ruleEditor.newPropSpan, + "Focus should be in the element property span" + ); + + info("Focus the next field with " + commitKey); + ruleEditor = getRuleViewRuleEditor(view, 1); + await focusNextEditableField(view, ruleEditor, commitKey); + assertEditor( + view, + ruleEditor.selectorText, + "Focus should have moved to the next rule selector" + ); + + for (let i = 0; i < ruleEditor.rule.textProps.length; i++) { + const textProp = ruleEditor.rule.textProps[i]; + const propEditor = textProp.editor; + + info("Focus the next field with " + commitKey); + // Expect a ruleview-changed event if we are moving from a property value + // to the next property name (which occurs after the first iteration, as for + // i=0, the previous field is the selector). + const onRuleViewChanged = i > 0 ? view.once("ruleview-changed") : null; + await focusNextEditableField(view, ruleEditor, commitKey); + await onRuleViewChanged; + assertEditor( + view, + propEditor.nameSpan, + "Focus should have moved to the property name" + ); + + info("Focus the next field with " + commitKey); + await focusNextEditableField(view, ruleEditor, commitKey); + assertEditor( + view, + propEditor.valueSpan, + "Focus should have moved to the property value" + ); + } + + // Expect a ruleview-changed event again as we're bluring a property value. + const onRuleViewChanged = view.once("ruleview-changed"); + await focusNextEditableField(view, ruleEditor, commitKey); + await onRuleViewChanged; + assertEditor( + view, + ruleEditor.newPropSpan, + "Focus should have moved to the new property span" + ); + + ruleEditor = getRuleViewRuleEditor(view, 2); + + await focusNextEditableField(view, ruleEditor, commitKey); + assertEditor( + view, + ruleEditor.selectorText, + "Focus should have moved to the next rule selector" + ); + + info("Blur the selector field"); + EventUtils.synthesizeKey("KEY_Escape"); +} + +async function focusNextEditableField(view, ruleEditor, commitKey) { + const onFocus = once(ruleEditor.element, "focus", true); + EventUtils.synthesizeKey(commitKey, {}, view.styleWindow); + await onFocus; +} + +function assertEditor(view, element, message) { + const editor = inplaceEditor(view.styleDocument.activeElement); + is(inplaceEditor(element), editor, message); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_editable-field-focus_02.js b/devtools/client/inspector/rules/test/browser_rules_editable-field-focus_02.js new file mode 100644 index 0000000000..23c7397b46 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_editable-field-focus_02.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the correct editable fields are focused when shift tabbing +// through the rule view. + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: blue; + color: red; + margin: 0; + padding: 0; + } + div { + border-color: red + } + </style> + <div id='testid'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testEditableFieldFocus(inspector, view, "VK_TAB", { shiftKey: true }); +}); + +async function testEditableFieldFocus( + inspector, + view, + commitKey, + options = {} +) { + let ruleEditor = getRuleViewRuleEditor(view, 2); + const editor = await focusEditableField(view, ruleEditor.selectorText); + is( + inplaceEditor(ruleEditor.selectorText), + editor, + "Focus should be in the 'div' rule selector" + ); + + ruleEditor = getRuleViewRuleEditor(view, 1); + + await focusNextField(view, ruleEditor, commitKey, options); + assertEditor( + view, + ruleEditor.newPropSpan, + "Focus should have moved to the new property span" + ); + + for (const textProp of ruleEditor.rule.textProps.slice(0).reverse()) { + const propEditor = textProp.editor; + + await focusNextField(view, ruleEditor, commitKey, options); + if ( + ["background-color", "color"].includes(propEditor.nameSpan.textContent) + ) { + // background-color and color property value spans have inner focusable elements + // and so, focus needs to move to the inplace editor field where enter needs to be + // pressed to trigger click event on it + await focusNextField(view, ruleEditor, commitKey, options); + EventUtils.sendKey("Return"); + } + await assertEditor( + view, + propEditor.valueSpan, + "Focus should have moved to the property value" + ); + + await focusNextFieldAndExpectChange(view, ruleEditor, commitKey, options); + await assertEditor( + view, + propEditor.nameSpan, + "Focus should have moved to the property name" + ); + } + + ruleEditor = getRuleViewRuleEditor(view, 1); + + await focusNextField(view, ruleEditor, commitKey, options); + await assertEditor( + view, + ruleEditor.selectorText, + "Focus should have moved to the '#testid' rule selector" + ); + + ruleEditor = getRuleViewRuleEditor(view, 0); + + await focusNextField(view, ruleEditor, commitKey, options); + assertEditor( + view, + ruleEditor.newPropSpan, + "Focus should have moved to the new property span" + ); +} + +async function focusNextFieldAndExpectChange( + view, + ruleEditor, + commitKey, + options +) { + const onRuleViewChanged = view.once("ruleview-changed"); + await focusNextField(view, ruleEditor, commitKey, options); + await onRuleViewChanged; +} + +async function focusNextField(view, ruleEditor, commitKey, options) { + const onFocus = once(ruleEditor.element, "focus", true); + EventUtils.synthesizeKey(commitKey, options, view.styleWindow); + await onFocus; +} + +function assertEditor(view, element, message) { + const editor = inplaceEditor(view.styleDocument.activeElement); + is(inplaceEditor(element), editor, message); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_eyedropper.js b/devtools/client/inspector/rules/test/browser_rules_eyedropper.js new file mode 100644 index 0000000000..bf59ef4b91 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_eyedropper.js @@ -0,0 +1,160 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test opening the eyedropper from the color picker. Pressing escape to close it, and +// clicking the page to select a color. + +const TEST_URI = ` + <style type="text/css"> + body { + background-color: white; + padding: 0px + } + + #div1 { + background-color: #ff5; + width: 20px; + height: 20px; + } + + #div2 { + margin-left: 20px; + width: 20px; + height: 20px; + background-color: #f09; + } + </style> + <body><div id="div1"></div><div id="div2"></div></body> +`; + +// #f09 +const ORIGINAL_COLOR = "rgb(255, 0, 153)"; +// #ff5 +const EXPECTED_COLOR = "rgb(255, 255, 85)"; + +registerCleanupFunction(() => { + // Restore the default Toolbox host position after the test. + Services.prefs.clearUserPref("devtools.toolbox.host"); +}); + +add_task(async function () { + info("Add the test tab, open the rule-view and select the test node"); + + const url = "data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI); + await addTab(url); + + const { inspector, view, toolbox } = await openRuleView(); + + await runTest(inspector, view, false); + + info("Reload the page to restore the initial state"); + await navigateTo(url); + + info("Change toolbox host to WINDOW"); + await toolbox.switchHost("window"); + + // Switching hosts is not correctly waiting when DevTools run in content frame + // See Bug 1571421. + await wait(1000); + + await runTest(inspector, view, true); +}); + +async function runTest(inspector, view, isWindowHost) { + await selectNode("#div2", inspector); + + info("Get the background-color property from the rule-view"); + const property = getRuleViewProperty(view, "#div2", "background-color"); + const swatch = property.valueSpan.querySelector(".ruleview-colorswatch"); + ok(swatch, "Color swatch is displayed for the bg-color property"); + + info("Open the eyedropper from the colorpicker tooltip"); + await openEyedropper(view, swatch); + + const tooltip = view.tooltips.getTooltip("colorPicker").tooltip; + ok( + !tooltip.isVisible(), + "color picker tooltip is closed after opening eyedropper" + ); + + info("Test that pressing escape dismisses the eyedropper"); + await testESC(swatch, inspector); + + if (isWindowHost) { + // The following code is only needed on linux otherwise the test seems to + // timeout when clicking again on the swatch. Both the focus and the wait + // seem needed to make it pass. + // To be fixed in Bug 1571421. + info("Ensure the swatch window is focused"); + const onWindowFocus = BrowserTestUtils.waitForEvent( + swatch.ownerGlobal, + "focus" + ); + swatch.ownerGlobal.focus(); + await onWindowFocus; + } + + info("Open the eyedropper again"); + await openEyedropper(view, swatch); + + info("Test that a color can be selected with the eyedropper"); + await testSelect(view, swatch, inspector); + + const onHidden = tooltip.once("hidden"); + tooltip.hide(); + await onHidden; + ok(!tooltip.isVisible(), "color picker tooltip is closed"); + + await waitForTick(); +} + +async function testESC(swatch, inspector) { + info("Press escape"); + const onCanceled = new Promise(resolve => { + inspector.inspectorFront.once("color-pick-canceled", resolve); + }); + BrowserTestUtils.synthesizeKey( + "VK_ESCAPE", + {}, + gBrowser.selectedTab.linkedBrowser + ); + await onCanceled; + + const color = swatch.style.backgroundColor; + is(color, ORIGINAL_COLOR, "swatch didn't change after pressing ESC"); +} + +async function testSelect(view, swatch, inspector) { + info("Click at x:10px y:10px"); + const onPicked = new Promise(resolve => { + inspector.inspectorFront.once("color-picked", resolve); + }); + // The change to the content is done async after rule view change + const onRuleViewChanged = view.once("ruleview-changed"); + + await safeSynthesizeMouseEventAtCenterInContentPage("#div1", { + type: "mousemove", + }); + await safeSynthesizeMouseEventAtCenterInContentPage("#div1", { + type: "mousedown", + }); + await safeSynthesizeMouseEventAtCenterInContentPage("#div1", { + type: "mouseup", + }); + + await onPicked; + await onRuleViewChanged; + + const color = swatch.style.backgroundColor; + is(color, EXPECTED_COLOR, "swatch changed colors"); + + ok(!swatch.eyedropperOpen, "swatch eye dropper is closed"); + ok(!swatch.activeSwatch, "no active swatch"); + + is( + await getComputedStyleProperty("div", null, "background-color"), + EXPECTED_COLOR, + "div's color set to body color after dropper" + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_filtereditor-appears-on-swatch-click.js b/devtools/client/inspector/rules/test/browser_rules_filtereditor-appears-on-swatch-click.js new file mode 100644 index 0000000000..037c3f8a82 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_filtereditor-appears-on-swatch-click.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the that Filter Editor Tooltip opens by clicking on filter swatches + +const TEST_URL = URL_ROOT + "doc_filter.html"; + +add_task(async function () { + await addTab(TEST_URL); + + const { toolbox, view } = await openRuleView(); + + info("Getting the filter swatch element"); + const property = await getRuleViewProperty(view, "body", "filter", { + wait: true, + }); + + const swatch = property.valueSpan.querySelector(".ruleview-filterswatch"); + + const filterTooltip = view.tooltips.getTooltip("filterEditor"); + // Clicking on a cssfilter swatch sets the current filter value in the tooltip + // which, in turn, makes the FilterWidget emit an "updated" event that causes + // the rule-view to refresh. So we must wait for the ruleview-changed event. + const onRuleViewChanged = view.once("ruleview-changed"); + swatch.click(); + await onRuleViewChanged; + + ok(true, "The shown event was emitted after clicking on swatch"); + ok( + !inplaceEditor(swatch.parentNode), + "The inplace editor wasn't shown as a result of the filter swatch click" + ); + + info("Get the cssfilter widget instance"); + const widget = filterTooltip.widget; + const select = widget.el.querySelector("select"); + + // Next we will check that interacting with the select does not close the + // filter tooltip. + info("Show the filter select"); + const onSelectPopupShown = BrowserTestUtils.waitForSelectPopupShown(window); + EventUtils.synthesizeMouseAtCenter(select, {}, toolbox.win); + const selectPopup = await onSelectPopupShown; + ok( + filterTooltip.tooltip.isVisible(), + "The tooltip was not hidden when opening the select" + ); + + info("Hide the filter select"); + const onSelectPopupHidden = once(selectPopup, "popuphidden"); + const blurMenuItem = selectPopup.querySelector("menuitem[label='blur']"); + EventUtils.synthesizeMouseAtCenter(blurMenuItem, {}, window); + await onSelectPopupHidden; + await waitFor(() => select.value === "blur"); + is( + select.value, + "blur", + "The filter select was updated with the correct value" + ); + ok( + filterTooltip.tooltip.isVisible(), + "The tooltip was not hidden when using the select" + ); + + await hideTooltipAndWaitForRuleViewChanged(filterTooltip, view); + await waitForTick(); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_filtereditor-commit-on-ENTER.js b/devtools/client/inspector/rules/test/browser_rules_filtereditor-commit-on-ENTER.js new file mode 100644 index 0000000000..7237f0c997 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_filtereditor-commit-on-ENTER.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the Filter Editor Tooltip committing changes on ENTER + +const TEST_URL = URL_ROOT + "doc_filter.html"; + +add_task(async function () { + await addTab(TEST_URL); + const { view } = await openRuleView(); + + info("Get the filter swatch element"); + const swatch = getRuleViewProperty( + view, + "body", + "filter" + ).valueSpan.querySelector(".ruleview-filterswatch"); + + info("Click on the filter swatch element"); + // Clicking on a cssfilter swatch sets the current filter value in the tooltip + // which, in turn, makes the FilterWidget emit an "updated" event that causes + // the rule-view to refresh. So we must wait for the ruleview-changed event. + let onRuleViewChanged = view.once("ruleview-changed"); + swatch.click(); + await onRuleViewChanged; + + info("Get the cssfilter widget instance"); + const filterTooltip = view.tooltips.getTooltip("filterEditor"); + const widget = filterTooltip.widget; + + info("Set a new value in the cssfilter widget"); + onRuleViewChanged = view.once("ruleview-changed"); + widget.setCssValue("blur(2px)"); + await waitForComputedStyleProperty("body", null, "filter", "blur(2px)"); + await onRuleViewChanged; + ok(true, "Changes previewed on the element"); + + info("Press RETURN to commit changes"); + // Pressing return in the cssfilter tooltip triggeres 2 ruleview-changed + onRuleViewChanged = waitForNEvents(view, "ruleview-changed", 2); + EventUtils.sendKey("RETURN", widget.styleWindow); + await onRuleViewChanged; + + is( + await getComputedStyleProperty("body", null, "filter"), + "blur(2px)", + "The elemenet's filter was kept after RETURN" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_filtereditor-revert-on-ESC.js b/devtools/client/inspector/rules/test/browser_rules_filtereditor-revert-on-ESC.js new file mode 100644 index 0000000000..ea46af9997 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_filtereditor-revert-on-ESC.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that changes made to the Filter Editor Tooltip are reverted when +// ESC is pressed + +const TEST_URL = URL_ROOT + "doc_filter.html"; + +add_task(async function () { + await addTab(TEST_URL); + const { view } = await openRuleView(); + await testPressingEscapeRevertsChanges(view); +}); + +async function testPressingEscapeRevertsChanges(view) { + const prop = getTextProperty(view, 1, { filter: "blur(2px) contrast(2)" }); + const propEditor = prop.editor; + const swatch = propEditor.valueSpan.querySelector(".ruleview-filterswatch"); + + await clickOnFilterSwatch(swatch, view); + await setValueInFilterWidget("blur(2px)", view); + + await waitForComputedStyleProperty("body", null, "filter", "blur(2px)"); + is( + propEditor.valueSpan.textContent, + "blur(2px)", + "Got expected property value." + ); + + await pressEscapeToCloseTooltip(view); + + await waitForComputedStyleProperty( + "body", + null, + "filter", + "blur(2px) contrast(2)" + ); + is( + propEditor.valueSpan.textContent, + "blur(2px) contrast(2)", + "Got expected property value." + ); +} + +async function clickOnFilterSwatch(swatch, view) { + info("Clicking on a css filter swatch to open the tooltip"); + + // Clicking on a cssfilter swatch sets the current filter value in the tooltip + // which, in turn, makes the FilterWidget emit an "updated" event that causes + // the rule-view to refresh. So we must wait for the ruleview-changed event. + const onRuleViewChanged = view.once("ruleview-changed"); + swatch.click(); + await onRuleViewChanged; +} + +async function setValueInFilterWidget(value, view) { + info("Setting the CSS filter value in the tooltip"); + + const filterTooltip = view.tooltips.getTooltip("filterEditor"); + const onRuleViewChanged = view.once("ruleview-changed"); + filterTooltip.widget.setCssValue(value); + await onRuleViewChanged; +} + +async function pressEscapeToCloseTooltip(view) { + info("Pressing ESCAPE to close the tooltip"); + + const filterTooltip = view.tooltips.getTooltip("filterEditor"); + const onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.sendKey("ESCAPE", filterTooltip.widget.styleWindow); + await onRuleViewChanged; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-on-mutation.js b/devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-on-mutation.js new file mode 100644 index 0000000000..eb887f9848 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-on-mutation.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the flexbox highlighter is hidden when the highlighted flexbox container is +// removed from the page. + +const TEST_URI = ` + <style type='text/css'> + #flex { + display: flex; + } + </style> + <div id="flex"></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.FLEXBOX; + const { + getNodeForActiveHighlighter, + waitForHighlighterTypeShown, + waitForHighlighterTypeHidden, + } = getHighlighterTestHelpers(inspector); + + const onRuleViewUpdated = view.once("ruleview-refreshed"); + await selectNode("#flex", inspector); + await onRuleViewUpdated; + const container = getRuleViewProperty(view, "#flex", "display").valueSpan; + const flexboxToggle = container.querySelector( + ".js-toggle-flexbox-highlighter" + ); + + info("Toggling ON the flexbox highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterShown; + ok( + getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter is shown." + ); + + info("Remove the #flex container in the content page."); + const onHighlighterHidden = waitForHighlighterTypeHidden(HIGHLIGHTER_TYPE); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => + content.document.querySelector("#flex").remove() + ); + await onHighlighterHidden; + ok( + !getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter is hidden." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-on-navigate.js b/devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-on-navigate.js new file mode 100644 index 0000000000..85b8f5429f --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-on-navigate.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that flexbox highlighter is hidden on page navigation. + +const TEST_URI = ` + <style type='text/css'> + #flex { + display: flex; + } + </style> + <div id="flex"></div> +`; + +const TEST_URI_2 = "data:text/html,<html><body>test</body></html>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.FLEXBOX; + const { getNodeForActiveHighlighter, waitForHighlighterTypeShown } = + getHighlighterTestHelpers(inspector); + + await selectNode("#flex", inspector); + const container = getRuleViewProperty(view, "#flex", "display").valueSpan; + const flexboxToggle = container.querySelector( + ".js-toggle-flexbox-highlighter" + ); + + info("Toggling ON the flexbox highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterShown; + ok( + getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter is shown." + ); + + await navigateTo(TEST_URI_2); + ok( + !getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter is hidden." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-on-reload.js b/devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-on-reload.js new file mode 100644 index 0000000000..aa35ffb322 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-on-reload.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that a flexbox highlighter after reloading the page. + +const TEST_URI = ` + <style type='text/css'> + #flex { + display: flex; + } + </style> + <div id="flex"></div> +`; + +add_task(async function () { + const tab = await addTab( + "data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI) + ); + + info("Check that the flexbox highlighter can be displayed."); + await checkFlexboxHighlighter(); + + info("Close the toolbox before reloading the tab."); + await gDevTools.closeToolboxForTab(tab); + + await reloadBrowser(); + + info( + "Check that the flexbox highlighter can be displayed after reloading the page." + ); + await checkFlexboxHighlighter(); +}); + +async function checkFlexboxHighlighter() { + const { inspector, view } = await openRuleView(); + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.FLEXBOX; + const { getNodeForActiveHighlighter, waitForHighlighterTypeShown } = + getHighlighterTestHelpers(inspector); + + await selectNode("#flex", inspector); + const container = getRuleViewProperty(view, "#flex", "display").valueSpan; + const flexboxToggle = container.querySelector( + ".js-toggle-flexbox-highlighter" + ); + + info("Toggling ON the flexbox highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterShown; + + ok( + getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter is shown." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-restored-after-reload.js b/devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-restored-after-reload.js new file mode 100644 index 0000000000..dfa0368e1d --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_flexbox-highlighter-restored-after-reload.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the flexbox highlighter is re-displayed after reloading a page. + +const TEST_URI = ` + <style type='text/css'> + #flex { + display: flex; + } + </style> + <div id="flex"></div> +`; + +const OTHER_URI = ` + <style type='text/css'> + #grid { + display: grid; + } + </style> + <div id="grid"></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + + info("Check that the flexbox highlighter can be displayed."); + const { inspector, view } = await openRuleView(); + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.FLEXBOX; + const { + getActiveHighlighter, + waitForHighlighterTypeShown, + waitForHighlighterTypeRestored, + waitForHighlighterTypeDiscarded, + } = getHighlighterTestHelpers(inspector); + + await selectNode("#flex", inspector); + const container = getRuleViewProperty(view, "#flex", "display").valueSpan; + const flexboxToggle = container.querySelector( + ".js-toggle-flexbox-highlighter" + ); + + info("Toggling ON the flexbox highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterShown; + ok(getActiveHighlighter(HIGHLIGHTER_TYPE), "Flexbox highlighter is shown."); + + info("Reload the page, expect the highlighter to be displayed once again"); + const onRestored = waitForHighlighterTypeRestored(HIGHLIGHTER_TYPE); + const onReloaded = inspector.once("reloaded"); + await reloadBrowser(); + info("Wait for inspector to be reloaded after page reload"); + await onReloaded; + info("Wait for the highlighter to be restored"); + await onRestored; + ok(getActiveHighlighter(HIGHLIGHTER_TYPE), "Flexbox highlighter restored."); + + info("Navigate to another URL, and check that the highlighter is hidden"); + const otherUri = + "data:text/html;charset=utf-8," + encodeURIComponent(OTHER_URI); + const onDiscarded = waitForHighlighterTypeDiscarded(HIGHLIGHTER_TYPE); + await navigateTo(otherUri); + info("Expect the highlighter not to be restored"); + await onDiscarded; + ok(!getActiveHighlighter(HIGHLIGHTER_TYPE), "Flexbox highlighter not shown."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle-telemetry.js b/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle-telemetry.js new file mode 100644 index 0000000000..cc58a25c5f --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle-telemetry.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 telemetry is correct when the flexbox highlighter is activated from +// the rules view. + +const TEST_URI = ` + <style type='text/css'> + #flex { + display: flex; + } + </style> + <div id="flex"></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + startTelemetry(); + const { inspector, view } = await openRuleView(); + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.FLEXBOX; + const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } = + getHighlighterTestHelpers(inspector); + + await selectNode("#flex", inspector); + const container = getRuleViewProperty(view, "#flex", "display").valueSpan; + const flexboxToggle = container.querySelector( + ".js-toggle-flexbox-highlighter" + ); + + info("Toggling ON the flexbox highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterShown; + + info("Toggling OFF the flexbox highlighter from the rule-view."); + const onHighlighterHidden = waitForHighlighterTypeHidden(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterHidden; + + checkResults(); +}); + +function checkResults() { + checkTelemetry("devtools.rules.flexboxhighlighter.opened", "", 1, "scalar"); + checkTelemetry( + "DEVTOOLS_FLEXBOX_HIGHLIGHTER_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_01.js b/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_01.js new file mode 100644 index 0000000000..d09e28a0ec --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_01.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the flexbox highlighter in the rule view and the display of the +// flexbox highlighter. + +const TEST_URI = ` + <style type='text/css'> + #flex { + display: flex; + } + </style> + <div id="flex"></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.FLEXBOX; + const { + getActiveHighlighter, + getNodeForActiveHighlighter, + waitForHighlighterTypeShown, + waitForHighlighterTypeHidden, + } = getHighlighterTestHelpers(inspector); + + await selectNode("#flex", inspector); + const container = getRuleViewProperty(view, "#flex", "display").valueSpan; + const flexboxToggle = container.querySelector( + ".js-toggle-flexbox-highlighter" + ); + + info("Checking the initial state of the flexbox toggle in the rule-view."); + ok(flexboxToggle, "Flexbox highlighter toggle is visible."); + ok( + !flexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle button is not active." + ); + ok( + !getActiveHighlighter(HIGHLIGHTER_TYPE), + "No flexbox highlighter exists in the rule-view." + ); + ok( + !getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "No flexbox highlighter is shown." + ); + + info("Toggling ON the flexbox highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterShown; + + info( + "Checking the flexbox highlighter is created and toggle button is active in " + + "the rule-view." + ); + ok( + flexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle is active." + ); + ok( + getActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter created in the rule-view." + ); + ok( + getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter is shown." + ); + + info("Toggling OFF the flexbox highlighter from the rule-view."); + const onHighlighterHidden = waitForHighlighterTypeHidden(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterHidden; + + info( + "Checking the flexbox highlighter is not shown and toggle button is not active " + + "in the rule-view." + ); + ok( + !flexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle button is not active." + ); + ok( + !getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "No flexbox highlighter is shown." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_01b.js b/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_01b.js new file mode 100644 index 0000000000..16f55d75c2 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_01b.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the flebox highlighter in the rule view and the display of the +// flexbox highlighter. + +const TEST_URI = ` + <style type='text/css'> + #flex { + display: inline-flex; + } + </style> + <div id="flex"></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.FLEXBOX; + const { + getActiveHighlighter, + getNodeForActiveHighlighter, + waitForHighlighterTypeShown, + waitForHighlighterTypeHidden, + } = getHighlighterTestHelpers(inspector); + + await selectNode("#flex", inspector); + const container = getRuleViewProperty(view, "#flex", "display").valueSpan; + const flexboxToggle = container.querySelector( + ".js-toggle-flexbox-highlighter" + ); + + info("Checking the initial state of the flexbox toggle in the rule-view."); + ok(flexboxToggle, "Flexbox highlighter toggle is visible."); + ok( + !flexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle button is not active." + ); + ok( + !getActiveHighlighter(HIGHLIGHTER_TYPE), + "No flexbox highlighter exists in the rule-view." + ); + ok( + !getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "No flexbox highlighter is shown." + ); + + info("Toggling ON the flexbox highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterShown; + + info( + "Checking the flexbox highlighter is created and toggle button is active in " + + "the rule-view." + ); + ok( + flexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle is active." + ); + ok( + getActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter created in the rule-view." + ); + ok( + getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter is shown." + ); + + info("Toggling OFF the flexbox highlighter from the rule-view."); + const onHighlighterHidden = waitForHighlighterTypeHidden(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterHidden; + + info( + "Checking the flexbox highlighter is not shown and toggle button is not active " + + "in the rule-view." + ); + ok( + !flexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle button is not active." + ); + ok( + !getActiveHighlighter(HIGHLIGHTER_TYPE), + "No flexbox highlighter is shown." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_02.js b/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_02.js new file mode 100644 index 0000000000..858562a5ee --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_02.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the flexbox highlighter in the rule view from an overridden +// 'display: flex' declaration. + +const TEST_URI = ` + <style type='text/css'> + #flex { + display: flex; + } + div, ul { + display: flex; + } + </style> + <ul id="flex"> + <li>1</li> + <li>2</li> + </ul> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.FLEXBOX; + const { + getActiveHighlighter, + getNodeForActiveHighlighter, + waitForHighlighterTypeShown, + waitForHighlighterTypeHidden, + } = getHighlighterTestHelpers(inspector); + + await selectNode("#flex", inspector); + const container = getRuleViewProperty(view, "#flex", "display").valueSpan; + const flexboxToggle = container.querySelector( + ".js-toggle-flexbox-highlighter" + ); + const overriddenContainer = getRuleViewProperty( + view, + "div, ul", + "display" + ).valueSpan; + const overriddenFlexboxToggle = overriddenContainer.querySelector( + ".js-toggle-flexbox-highlighter" + ); + + info("Checking the initial state of the flexbox toggle in the rule-view."); + ok( + flexboxToggle && overriddenFlexboxToggle, + "Flexbox highlighter toggles are visible." + ); + ok( + !flexboxToggle.classList.contains("active") && + !overriddenFlexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle buttons are not active." + ); + ok( + !getActiveHighlighter(HIGHLIGHTER_TYPE), + "No flexbox highlighter exists in the rule-view." + ); + ok( + !getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "No flexbox highlighter is shown." + ); + + info( + "Toggling ON the flexbox highlighter from the overridden rule in the rule-view." + ); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + overriddenFlexboxToggle.click(); + await onHighlighterShown; + + info( + "Checking the flexbox highlighter is created and toggle buttons are active in " + + "the rule-view." + ); + ok( + flexboxToggle.classList.contains("active") && + overriddenFlexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle is active." + ); + ok( + getActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter created in the rule-view." + ); + ok( + getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter is shown." + ); + + info( + "Toggling off the flexbox highlighter from the normal flexbox declaration in " + + "the rule-view." + ); + const onHighlighterHidden = waitForHighlighterTypeHidden(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterHidden; + + info( + "Checking the flexbox highlighter is not shown and toggle buttons are not " + + "active in the rule-view." + ); + ok( + !flexboxToggle.classList.contains("active") && + !overriddenFlexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle buttons are not active." + ); + ok( + !getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "No flexbox highlighter is shown." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_03.js b/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_03.js new file mode 100644 index 0000000000..4f5ecbbd52 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_03.js @@ -0,0 +1,133 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the flexbox highlighter in the rule view with multiple flexboxes in the +// page. + +const TEST_URI = ` + <style type='text/css'> + .flex { + display: flex; + } + </style> + <div id="flex1" class="flex"></div> + <div id="flex2" class="flex"></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.FLEXBOX; + const { + getActiveHighlighter, + getNodeForActiveHighlighter, + waitForHighlighterTypeShown, + } = getHighlighterTestHelpers(inspector); + + info("Selecting the first flexbox container."); + await selectNode("#flex1", inspector); + let container = getRuleViewProperty(view, ".flex", "display").valueSpan; + let flexboxToggle = container.querySelector(".js-toggle-flexbox-highlighter"); + + info( + "Checking the state of the flexbox toggle for the first flexbox container in " + + "the rule-view." + ); + ok(flexboxToggle, "flexbox highlighter toggle is visible."); + ok( + !flexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle button is not active." + ); + ok( + !getActiveHighlighter(HIGHLIGHTER_TYPE), + "No flexbox highlighter exists in the rule-view." + ); + ok( + !getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "No flexbox highlighter is shown." + ); + + info( + "Toggling ON the flexbox highlighter for the first flexbox container from the " + + "rule-view." + ); + let onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterShown; + + info( + "Checking the flexbox highlighter is created and toggle button is active in " + + "the rule-view." + ); + ok( + flexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle is active." + ); + ok( + getActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter created in the rule-view." + ); + ok( + getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter is shown." + ); + + info("Selecting the second flexbox container."); + await selectNode("#flex2", inspector); + const firstFlexboxHighterShown = + getNodeForActiveHighlighter(HIGHLIGHTER_TYPE); + container = getRuleViewProperty(view, ".flex", "display").valueSpan; + flexboxToggle = container.querySelector(".js-toggle-flexbox-highlighter"); + + info( + "Checking the state of the CSS flexbox toggle for the second flexbox container " + + "in the rule-view." + ); + ok(flexboxToggle, "Flexbox highlighter toggle is visible."); + ok( + !flexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle button is not active." + ); + ok( + getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter is still shown." + ); + + info( + "Toggling ON the flexbox highlighter for the second flexbox container from the " + + "rule-view." + ); + onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterShown; + + info( + "Checking the flexbox highlighter is created for the second flexbox container " + + "and toggle button is active in the rule-view." + ); + ok( + flexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle is active." + ); + ok( + getNodeForActiveHighlighter(HIGHLIGHTER_TYPE) != firstFlexboxHighterShown, + "Flexbox highlighter for the second flexbox container is shown." + ); + + info("Selecting the first flexbox container."); + await selectNode("#flex1", inspector); + container = getRuleViewProperty(view, ".flex", "display").valueSpan; + flexboxToggle = container.querySelector(".js-toggle-flexbox-highlighter"); + + info( + "Checking the state of the flexbox toggle for the first flexbox container in " + + "the rule-view." + ); + ok(flexboxToggle, "Flexbox highlighter toggle is visible."); + ok( + !flexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle button is not active." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_04.js b/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_04.js new file mode 100644 index 0000000000..abf1e1e1d5 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_flexbox-toggle_04.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the flexbox highlighter in the rule view from a +// 'display: flex!important' declaration. + +const TEST_URI = ` + <style type='text/css'> + #flex { + display: flex !important; + } + </style> + <div id="flex"></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.FLEXBOX; + const { + getActiveHighlighter, + getNodeForActiveHighlighter, + waitForHighlighterTypeShown, + waitForHighlighterTypeHidden, + } = getHighlighterTestHelpers(inspector); + + await selectNode("#flex", inspector); + const container = getRuleViewProperty(view, "#flex", "display").valueSpan; + const flexboxToggle = container.querySelector( + ".js-toggle-flexbox-highlighter" + ); + + info("Checking the initial state of the flexbox toggle in the rule-view."); + ok(flexboxToggle, "Flexbox highlighter toggle is visible."); + ok( + !flexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle button is not active." + ); + ok( + !getActiveHighlighter(HIGHLIGHTER_TYPE), + "No flexbox highlighter exists in the rule-view." + ); + ok( + !getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "No flexbox highlighter is shown." + ); + + info("Toggling ON the flexbox highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterShown; + + info( + "Checking the flexbox highlighter is created and toggle button is active in " + + "the rule-view." + ); + ok( + flexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle is active." + ); + ok( + getActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter created in the rule-view." + ); + ok( + getNodeForActiveHighlighter(HIGHLIGHTER_TYPE), + "Flexbox highlighter is shown." + ); + + info("Toggling OFF the flexbox highlighter from the rule-view."); + const onHighlighterHidden = waitForHighlighterTypeHidden(HIGHLIGHTER_TYPE); + flexboxToggle.click(); + await onHighlighterHidden; + + info( + "Checking the flexbox highlighter is not shown and toggle button is not active " + + "in the rule-view." + ); + ok( + !flexboxToggle.classList.contains("active"), + "Flexbox highlighter toggle button is not active." + ); + ok( + !getActiveHighlighter(HIGHLIGHTER_TYPE), + "No flexbox highlighter is shown." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_font-family-parsing.js b/devtools/client/inspector/rules/test/browser_rules_font-family-parsing.js new file mode 100644 index 0000000000..5e067be731 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_font-family-parsing.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the parsed font-family property value shown in the rules +// pane is correct. + +const TEST_URI = ` + <style type="text/css"> + #id1 { + font-family: georgia, arial, sans-serif; + } + #id2 { + font-family: georgia,arial,sans-serif; + } + #id3 { + font-family: georgia ,arial ,sans-serif; + } + #id4 { + font-family: georgia , arial , sans-serif; + } + #id4 { + font-family: arial, georgia, sans-serif ; + } + #id5 { + font-family: helvetica !important; + } + </style> + <div id="id1">1</div> + <div id="id2">2</div> + <div id="id3">3</div> + <div id="id4">4</div> + <div id="id5">5</div> +`; + +const TESTS = [ + { selector: "#id1", expectedTextContent: "georgia, arial, sans-serif" }, + { selector: "#id2", expectedTextContent: "georgia,arial,sans-serif" }, + { selector: "#id3", expectedTextContent: "georgia ,arial ,sans-serif" }, + { selector: "#id4", expectedTextContent: "arial, georgia, sans-serif" }, + { selector: "#id5", expectedTextContent: "helvetica !important" }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + for (const { selector, expectedTextContent } of TESTS) { + await selectNode(selector, inspector); + info("Looking for font-family property value in selector " + selector); + + const prop = getRuleViewProperty(view, selector, "font-family").valueSpan; + is( + prop.textContent, + expectedTextContent, + "The font-family property value is correct" + ); + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_grid-highlighter-on-mutation.js b/devtools/client/inspector/rules/test/browser_rules_grid-highlighter-on-mutation.js new file mode 100644 index 0000000000..1394c0532c --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_grid-highlighter-on-mutation.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the grid highlighter is hidden when the highlighted grid container is +// removed from the page. + +const TEST_URI = ` + <style type='text/css'> + #grid { + display: grid; + } + </style> + <div id="grid"> + <div id="cell1">cell1</div> + <div id="cell2">cell2</div> + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = inspector.highlighters; + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.GRID; + const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } = + getHighlighterTestHelpers(inspector); + + await selectNode("#grid", inspector); + const container = getRuleViewProperty(view, "#grid", "display").valueSpan; + const gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + + info("Toggling ON the CSS grid highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterShown; + is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown."); + + const onHighlighterHidden = waitForHighlighterTypeHidden(HIGHLIGHTER_TYPE); + info("Remove the #grid container in the content page"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => + content.document.querySelector("#grid").remove() + ); + await onHighlighterHidden; + ok(!highlighters.gridHighlighters.size, "CSS grid highlighter is hidden."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_grid-highlighter-on-navigate.js b/devtools/client/inspector/rules/test/browser_rules_grid-highlighter-on-navigate.js new file mode 100644 index 0000000000..6c2192b800 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_grid-highlighter-on-navigate.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that grid highlighter is hidden on page navigation. + +const TEST_URI = ` + <style type='text/css'> + #grid { + display: grid; + } + </style> + <div id="grid"> + <div id="cell1">cell1</div> + <div id="cell2">cell2</div> + </div> +`; + +const TEST_URI_2 = "data:text/html,<html><body>test</body></html>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = inspector.highlighters; + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.GRID; + const { waitForHighlighterTypeShown } = getHighlighterTestHelpers(inspector); + + await selectNode("#grid", inspector); + const container = getRuleViewProperty(view, "#grid", "display").valueSpan; + const gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + + info("Toggling ON the CSS grid highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterShown; + + is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown."); + + await navigateTo(TEST_URI_2); + ok(!highlighters.gridHighlighters.size, "CSS grid highlighter is hidden."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_grid-highlighter-on-reload.js b/devtools/client/inspector/rules/test/browser_rules_grid-highlighter-on-reload.js new file mode 100644 index 0000000000..7850b069ae --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_grid-highlighter-on-reload.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that a grid highlighter showing grid gaps can be displayed after reloading the +// page (Bug 1342051). + +const TEST_URI = ` + <style type='text/css'> + #grid { + display: grid; + grid-gap: 10px; + } + </style> + <div id="grid"> + <div id="cell1">cell1</div> + <div id="cell2">cell2</div> + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + + info("Check that the grid highlighter can be displayed"); + await checkGridHighlighter(); + + info("Close the toolbox before reloading the tab"); + await gDevTools.closeToolboxForTab(gBrowser.selectedTab); + + await reloadBrowser(); + + info( + "Check that the grid highlighter can be displayed after reloading the page" + ); + await checkGridHighlighter(); +}); + +async function checkGridHighlighter() { + const { inspector, view } = await openRuleView(); + const { highlighters } = inspector; + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.GRID; + const { waitForHighlighterTypeShown } = getHighlighterTestHelpers(inspector); + + await selectNode("#grid", inspector); + const container = getRuleViewProperty(view, "#grid", "display").valueSpan; + const gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + + info("Toggling ON the CSS grid highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterShown; + + is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown."); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_grid-highlighter-restored-after-reload.js b/devtools/client/inspector/rules/test/browser_rules_grid-highlighter-restored-after-reload.js new file mode 100644 index 0000000000..6a2e5743af --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_grid-highlighter-restored-after-reload.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the grid highlighter is re-displayed after reloading a page. + +const TEST_URI = ` + <style type='text/css'> + #grid { + display: grid; + } + </style> + <div id="grid"> + <div id="cell1">cell1</div> + <div id="cell2">cell2</div> + </div> +`; + +const OTHER_URI = ` + <style type='text/css'> + #grid { + display: grid; + } + </style> + <div id="grid"> + <div id="cell1">cell1</div> + <div id="cell2">cell2</div> + <div id="cell3">cell3</div> + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + + info("Check that the grid highlighter can be displayed"); + const { inspector, view } = await openRuleView(); + const { highlighters } = inspector; + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.GRID; + const { + waitForHighlighterTypeShown, + waitForHighlighterTypeRestored, + waitForHighlighterTypeDiscarded, + } = getHighlighterTestHelpers(inspector); + + await selectNode("#grid", inspector); + const container = getRuleViewProperty(view, "#grid", "display").valueSpan; + const gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + + info("Toggling ON the CSS grid highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterShown; + + is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown."); + + info("Reload the page, expect the highlighter to be displayed once again"); + const onRestored = waitForHighlighterTypeRestored(HIGHLIGHTER_TYPE); + + const onReloaded = inspector.once("reloaded"); + await reloadBrowser(); + info("Wait for inspector to be reloaded after page reload"); + await onReloaded; + + await onRestored; + is( + highlighters.gridHighlighters.size, + 1, + "CSS grid highlighter was restored." + ); + + info("Navigate to another URL, and check that the highlighter is hidden"); + const otherUri = + "data:text/html;charset=utf-8," + encodeURIComponent(OTHER_URI); + const onDiscarded = waitForHighlighterTypeDiscarded(HIGHLIGHTER_TYPE); + await navigateTo(otherUri); + await onDiscarded; + is( + highlighters.gridHighlighters.size, + 0, + "CSS grid highlighter was not restored." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_grid-template-areas.js b/devtools/client/inspector/rules/test/browser_rules_grid-template-areas.js new file mode 100644 index 0000000000..0decb7f7db --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_grid-template-areas.js @@ -0,0 +1,178 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the text editor correctly display the grid-template-areas value. +// The CSS Grid spec allows to create grid-template-areas in an ascii-art style matrix. +// It should show each string on its own line, when displaying rules for grid-template-areas. + +const TEST_URI = ` +<style type='text/css'> + #testid { + display: grid; + grid-template-areas: "a a bb" + 'a a bb' + "ccc ccc bb"; + } + + #testid-quotes { + quotes: "«" "»" "‹" "›"; + } + + #testid-invalid-strings { + grid-template-areas: "a a b" + "a a"; + } + + #testid-valid-quotefree-value { + grid-template-areas: inherit; + } + + .a { + grid-area: a; + } + + .b { + grid-area: bb; + } + + .c { + grid-area: ccc; + } +</style> +<div id="testid"> + <div class="a">cell a</div> + <div class="b">cell b</div> + <div class="c">cell c</div> +</div> +<q id="testid-quotes"> + Show us the wonder-working <q>Brothers,</q> let them come out publicly—and we will believe in them! +</q> +<div id="testid-invalid-strings"> + <div class="a">cell a</div> + <div class="b">cell b</div> +</div> +<div id="testid-valid-quotefree-value"> + <div class="a">cell a</div> + <div class="b">cell b</div> +</div> +`; + +const multiLinesInnerText = '\n"a a bb" \n\'a a bb\' \n"ccc ccc bb"'; +const typedAndCopiedMultiLinesString = '"a a bb ccc" "a a bb ccc"'; +const typedAndCopiedMultiLinesInnerText = '\n"a a bb ccc" \n"a a bb ccc"'; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Selecting the test node"); + await selectNode("#testid", inspector); + + info( + "Tests display of grid-template-areas value in an ascii-art style," + + "displaying each string on its own line" + ); + + const gridRuleProperty = await getRuleViewProperty( + view, + "#testid", + "grid-template-areas" + ).valueSpan; + is( + gridRuleProperty.innerText, + multiLinesInnerText, + "the grid-area is displayed with each string in its own line, and sufficient spacing for areas to align vertically" + ); + + // copy/paste the current value inside, to also make sure of the value copied is useful as text + + // Calculate offsets to click in the value line which is below the property name line . + const rect = gridRuleProperty.getBoundingClientRect(); + const previousProperty = await getRuleViewProperty( + view, + "#testid", + "display" + ).nameSpan.getBoundingClientRect(); + + const x = rect.width / 2; + const y = rect.y - previousProperty.y + 1; + + info("Focusing the css property editable value"); + await focusEditableField(view, gridRuleProperty, x, y); + info("typing a new value"); + [...typedAndCopiedMultiLinesString].map(char => + EventUtils.synthesizeKey(char, {}, view.styleWindow) + ); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + is( + gridRuleProperty.innerText, + typedAndCopiedMultiLinesInnerText, + "the typed value is correct, and a single quote is displayed on its own line" + ); + info("copy-paste the 'grid-template-areas' property value to duplicate it"); + const onDone = view.once("ruleview-changed"); + await focusEditableField(view, gridRuleProperty, x, y); + EventUtils.synthesizeKey("C", { accelKey: true }, view.styleWindow); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, view.styleWindow); + EventUtils.synthesizeKey("V", { accelKey: true }, view.styleWindow); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + + await onDone; + + info("Check copy-pasting the property value is not breaking it"); + + is( + gridRuleProperty.innerText, + typedAndCopiedMultiLinesInnerText + " " + typedAndCopiedMultiLinesInnerText, + "copy-pasting the current value duplicate the correct value, with each string of the multi strings grid-template-areas value is displayed on a new line" + ); + + // test that when "non grid-template-area", like quotes for example, its multi-string value is not displayed over multiple lines + await selectNode("#testid-quotes", inspector); + + info( + "Tests display of content string value is NOT in an ascii-art style," + + "displaying each string on a single line" + ); + + const contentRuleProperty = await getRuleViewProperty( + view, + "#testid-quotes", + "quotes" + ).valueSpan; + is( + contentRuleProperty.innerText, + '"«" "»" "‹" "›"', + "the quotes strings values are all displayed on the same single line" + ); + + // test that when invalid strings values do not get formatted + info("testing it does not try to format invalid values"); + await selectNode("#testid-invalid-strings", inspector); + const invalidGridRuleProperty = await getRuleViewProperty( + view, + "#testid-invalid-strings", + "grid-template-areas" + ).valueSpan; + is( + invalidGridRuleProperty.innerText, + '"a a b" "a a"', + "the invalid strings values do not get formatted" + ); + + // test that when a valid value without quotes such as `inherit` it does not get formatted + info("testing it does not try to format valid non-quote values"); + await selectNode("#testid-valid-quotefree-value", inspector); + const validGridRuleNoQuoteProperty = await getRuleViewProperty( + view, + "#testid-valid-quotefree-value", + "grid-template-areas" + ).valueSpan; + is( + validGridRuleNoQuoteProperty.innerText, + "inherit", + "the valid quote-free values do not get formatted" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_grid-toggle-telemetry.js b/devtools/client/inspector/rules/test/browser_rules_grid-toggle-telemetry.js new file mode 100644 index 0000000000..7ea229a734 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_grid-toggle-telemetry.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the telemetry count is correct when the grid highlighter is activated from +// the rules view. + +const TEST_URI = ` + <style type='text/css'> + #grid { + display: grid; + } + </style> + <div id="grid"> + <div id="cell1">cell1</div> + <div id="cell2">cell2</div> + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + startTelemetry(); + const { inspector, view } = await openRuleView(); + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.GRID; + const { waitForHighlighterTypeShown } = getHighlighterTestHelpers(inspector); + + await selectNode("#grid", inspector); + const container = getRuleViewProperty(view, "#grid", "display").valueSpan; + const gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + + info("Toggling ON the CSS grid highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterShown; + + checkResults(); +}); + +function checkResults() { + checkTelemetry("devtools.rules.gridinspector.opened", "", 1, "scalar"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_grid-toggle_01.js b/devtools/client/inspector/rules/test/browser_rules_grid-toggle_01.js new file mode 100644 index 0000000000..21097c907a --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_grid-toggle_01.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the grid highlighter in the rule view and the display of the +// grid highlighter. + +const TEST_URI = ` + <style type='text/css'> + #grid { + display: grid; + } + </style> + <div id="grid"> + <div id="cell1">cell1</div> + <div id="cell2">cell2</div> + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = view.highlighters; + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.GRID; + const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } = + getHighlighterTestHelpers(inspector); + + await selectNode("#grid", inspector); + const container = getRuleViewProperty(view, "#grid", "display").valueSpan; + const gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + + info("Checking the initial state of the CSS grid toggle in the rule-view."); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !gridToggle.classList.contains("active"), + "Grid highlighter toggle button is not active." + ); + ok(!highlighters.gridHighlighters.size, "No CSS grid highlighter is shown."); + + info("Toggling ON the CSS grid highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterShown; + + info( + "Checking the CSS grid highlighter is created and toggle button is active in " + + "the rule-view." + ); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + gridToggle.classList.contains("active"), + "Grid highlighter toggle is active." + ); + is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown."); + + info("Toggling OFF the CSS grid highlighter from the rule-view."); + const onHighlighterHidden = waitForHighlighterTypeHidden(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterHidden; + + info( + "Checking the CSS grid highlighter is not shown and toggle button is not active " + + "in the rule-view." + ); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !gridToggle.classList.contains("active"), + "Grid highlighter toggle button is not active." + ); + ok(!highlighters.gridHighlighters.size, "No CSS grid highlighter is shown."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_grid-toggle_01b.js b/devtools/client/inspector/rules/test/browser_rules_grid-toggle_01b.js new file mode 100644 index 0000000000..0933d1b4ab --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_grid-toggle_01b.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the grid highlighter in the rule view and the display of the +// grid highlighter. + +const TEST_URI = ` + <style type='text/css'> + #grid { + display: inline-grid; + } + </style> + <div id="grid"> + <div id="cell1">cell1</div> + <div id="cell2">cell2</div> + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = view.highlighters; + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.GRID; + const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } = + getHighlighterTestHelpers(inspector); + + await selectNode("#grid", inspector); + const container = getRuleViewProperty(view, "#grid", "display").valueSpan; + const gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + + info("Checking the initial state of the CSS grid toggle in the rule-view."); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !gridToggle.classList.contains("active"), + "Grid highlighter toggle button is not active." + ); + ok(!highlighters.gridHighlighters.size, "No CSS grid highlighter is shown."); + + info("Toggling ON the CSS grid highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterShown; + + info( + "Checking the CSS grid highlighter is created and toggle button is active in " + + "the rule-view." + ); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + gridToggle.classList.contains("active"), + "Grid highlighter toggle is active." + ); + is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown."); + + info("Toggling OFF the CSS grid highlighter from the rule-view."); + const onHighlighterHidden = waitForHighlighterTypeHidden(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterHidden; + + info( + "Checking the CSS grid highlighter is not shown and toggle button is not active " + + "in the rule-view." + ); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !gridToggle.classList.contains("active"), + "Grid highlighter toggle button is not active." + ); + ok(!highlighters.gridHighlighters.size, "No CSS grid highlighter is shown."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_grid-toggle_02.js b/devtools/client/inspector/rules/test/browser_rules_grid-toggle_02.js new file mode 100644 index 0000000000..2f168126a6 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_grid-toggle_02.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the grid highlighter in the rule view from an overridden 'display: grid' +// declaration. + +const TEST_URI = ` + <style type='text/css'> + #grid { + display: grid; + } + div, ul { + display: grid; + } + </style> + <ul id="grid"> + <li id="cell1">cell1</li> + <li id="cell2">cell2</li> + </ul> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = view.highlighters; + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.GRID; + const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } = + getHighlighterTestHelpers(inspector); + + await selectNode("#grid", inspector); + const container = getRuleViewProperty(view, "#grid", "display").valueSpan; + const gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + const overriddenContainer = getRuleViewProperty( + view, + "div, ul", + "display" + ).valueSpan; + const overriddenGridToggle = + overriddenContainer.querySelector(".ruleview-grid"); + + info("Checking the initial state of the CSS grid toggle in the rule-view."); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !overriddenGridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !gridToggle.classList.contains("active") && + !overriddenGridToggle.classList.contains("active"), + "Grid highlighter toggle buttons are not active." + ); + ok(!highlighters.gridHighlighters.size, "No CSS grid highlighter is shown."); + + info( + "Toggling ON the CSS grid highlighter from the overridden rule in the rule-view." + ); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + overriddenGridToggle.click(); + await onHighlighterShown; + + info( + "Checking the CSS grid highlighter is created and toggle buttons are active in " + + "the rule-view." + ); + ok( + gridToggle.classList.contains("active") && + overriddenGridToggle.classList.contains("active"), + "Grid highlighter toggle is active." + ); + is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown."); + + info( + "Toggling off the CSS grid highlighter from the normal grid declaration in the " + + "rule-view." + ); + const onHighlighterHidden = waitForHighlighterTypeHidden(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterHidden; + + info( + "Checking the CSS grid highlighter is not shown and toggle buttons are not " + + "active in the rule-view." + ); + ok( + !gridToggle.classList.contains("active") && + !overriddenGridToggle.classList.contains("active"), + "Grid highlighter toggle buttons are not active." + ); + ok(!highlighters.gridHighlighters.size, "No CSS grid highlighter is shown."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_grid-toggle_03.js b/devtools/client/inspector/rules/test/browser_rules_grid-toggle_03.js new file mode 100644 index 0000000000..2beec28d0e --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_grid-toggle_03.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the grid highlighter in the rule view with multiple grids in the page. + +const TEST_URI = ` + <style type='text/css'> + .grid { + display: grid; + } + </style> + <div id="grid1" class="grid"> + <div class="cell1">cell1</div> + <div class="cell2">cell2</div> + </div> + <div id="grid2" class="grid"> + <div class="cell1">cell1</div> + <div class="cell2">cell2</div> + </div> +`; + +add_task(async function () { + await pushPref("devtools.gridinspector.maxHighlighters", 1); + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = view.highlighters; + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.GRID; + const { waitForHighlighterTypeShown } = getHighlighterTestHelpers(inspector); + + info("Selecting the first grid container."); + await selectNode("#grid1", inspector); + let container = getRuleViewProperty(view, ".grid", "display").valueSpan; + let gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + + info( + "Checking the state of the CSS grid toggle for the first grid container in the " + + "rule-view." + ); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !gridToggle.classList.contains("active"), + "Grid highlighter toggle button is not active." + ); + ok(!highlighters.gridHighlighters.size, "No CSS grid highlighter is shown."); + + info( + "Toggling ON the CSS grid highlighter for the first grid container from the " + + "rule-view." + ); + let onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterShown; + + info( + "Checking the CSS grid highlighter is created and toggle button is active in " + + "the rule-view." + ); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + gridToggle.classList.contains("active"), + "Grid highlighter toggle is active." + ); + is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown."); + + info("Selecting the second grid container."); + await selectNode("#grid2", inspector); + const firstGridHighterShown = highlighters.gridHighlighters + .keys() + .next().value; + container = getRuleViewProperty(view, ".grid", "display").valueSpan; + gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + + info( + "Checking the state of the CSS grid toggle for the second grid container in the " + + "rule-view." + ); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !gridToggle.classList.contains("active"), + "Grid highlighter toggle button is not active." + ); + is( + highlighters.gridHighlighters.size, + 1, + "CSS grid highlighter is still shown." + ); + + info( + "Toggling ON the CSS grid highlighter for the second grid container from the " + + "rule-view." + ); + onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterShown; + + info( + "Checking the CSS grid highlighter is created for the second grid container and " + + "toggle button is active in the rule-view." + ); + ok( + gridToggle.classList.contains("active"), + "Grid highlighter toggle is active." + ); + ok( + highlighters.gridHighlighters.keys().next().value != firstGridHighterShown, + "Grid highlighter for the second grid container is shown." + ); + + info("Selecting the first grid container."); + await selectNode("#grid1", inspector); + container = getRuleViewProperty(view, ".grid", "display").valueSpan; + gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + + info( + "Checking the state of the CSS grid toggle for the first grid container in the " + + "rule-view." + ); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !gridToggle.classList.contains("active"), + "Grid highlighter toggle button is not active." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_grid-toggle_04.js b/devtools/client/inspector/rules/test/browser_rules_grid-toggle_04.js new file mode 100644 index 0000000000..70e4b91893 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_grid-toggle_04.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the grid highlighter in the rule view from a 'display: grid !important' +// declaration. + +const TEST_URI = ` + <style type='text/css'> + #grid { + display: grid !important; + } + </style> + <div id="grid"> + <div id="cell1">cell1</div> + <div id="cell2">cell2</div> + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = view.highlighters; + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.GRID; + const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } = + getHighlighterTestHelpers(inspector); + + await selectNode("#grid", inspector); + const container = getRuleViewProperty(view, "#grid", "display").valueSpan; + const gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + + info("Checking the initial state of the CSS grid toggle in the rule-view."); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !gridToggle.classList.contains("active"), + "Grid highlighter toggle button is not active." + ); + ok(!highlighters.gridHighlighters.size, "No CSS grid highlighter is shown."); + + info("Toggling ON the CSS grid highlighter from the rule-view."); + const onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterShown; + + info( + "Checking the CSS grid highlighter is created and toggle button is active in " + + "the rule-view." + ); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + gridToggle.classList.contains("active"), + "Grid highlighter toggle is active." + ); + is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown."); + + info("Toggling OFF the CSS grid highlighter from the rule-view."); + const onHighlighterHidden = waitForHighlighterTypeHidden(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterHidden; + + info( + "Checking the CSS grid highlighter is not shown and toggle button is not active " + + "in the rule-view." + ); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !gridToggle.classList.contains("active"), + "Grid highlighter toggle button is not active." + ); + ok(!highlighters.gridHighlighters.size, "No CSS grid highlighter is shown."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_grid-toggle_05.js b/devtools/client/inspector/rules/test/browser_rules_grid-toggle_05.js new file mode 100644 index 0000000000..54d2d0979f --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_grid-toggle_05.js @@ -0,0 +1,139 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the grid toggle is hidden when the maximum number of grid highlighters +// have been reached. + +const TEST_URI = ` + <style type='text/css'> + .grid { + display: grid; + } + </style> + <div id="grid1" class="grid"> + <div class="cell1">cell1</div> + <div class="cell2">cell2</div> + </div> + <div id="grid2" class="grid"> + <div class="cell1">cell1</div> + <div class="cell2">cell2</div> + </div> + <div id="grid3" class="grid"> + <div class="cell1">cell1</div> + <div class="cell2">cell2</div> + </div> +`; + +add_task(async function () { + await pushPref("devtools.gridinspector.maxHighlighters", 2); + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, gridInspector } = await openLayoutView(); + const ruleView = selectRuleView(inspector); + const { document: doc } = gridInspector; + const { highlighters } = inspector; + const HIGHLIGHTER_TYPE = inspector.highlighters.TYPES.GRID; + const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } = + getHighlighterTestHelpers(inspector); + + await selectNode("#grid1", inspector); + const gridList = doc.getElementById("grid-list"); + const checkbox2 = gridList.children[1].querySelector("input"); + const checkbox3 = gridList.children[2].querySelector("input"); + const container = getRuleViewProperty(ruleView, ".grid", "display").valueSpan; + const gridToggle = container.querySelector(".js-toggle-grid-highlighter"); + + info("Checking the initial state of the CSS grid toggle in the rule-view."); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !gridToggle.classList.contains("active"), + "Grid highlighter toggle button is not active." + ); + ok(!highlighters.gridHighlighters.size, "No CSS grid highlighter is shown."); + + info("Toggling ON the CSS grid highlighter for #grid2."); + let onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + checkbox2.click(); + await onHighlighterShown; + + info( + "Checking the CSS grid toggle for #grid1 is not disabled and not active." + ); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !gridToggle.classList.contains("active"), + "Grid highlighter toggle button is not active." + ); + is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown."); + + info("Toggling ON the CSS grid highlighter for #grid3."); + onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + checkbox3.click(); + await onHighlighterShown; + + info("Checking the CSS grid toggle for #grid1 is disabled."); + ok( + gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is disabled." + ); + is(highlighters.gridHighlighters.size, 2, "CSS grid highlighters are shown."); + + info("Toggling OFF the CSS grid highlighter for #grid3."); + let onHighlighterHidden = waitForHighlighterTypeHidden(HIGHLIGHTER_TYPE); + checkbox3.click(); + await onHighlighterHidden; + + info( + "Checking the CSS grid toggle for #grid1 is not disabled and not active." + ); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !gridToggle.classList.contains("active"), + "Grid highlighter toggle button is not active." + ); + is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown."); + + info("Toggling ON the CSS grid highlighter for #grid1 from the rule-view."); + onHighlighterShown = waitForHighlighterTypeShown(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterShown; + + info("Checking the CSS grid toggle for #grid1 is not disabled."); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + gridToggle.classList.contains("active"), + "Grid highlighter toggle is active." + ); + is(highlighters.gridHighlighters.size, 2, "CSS grid highlighters are shown."); + + info("Toggling OFF the CSS grid highlighter for #grid1 from the rule-view."); + onHighlighterHidden = waitForHighlighterTypeHidden(HIGHLIGHTER_TYPE); + gridToggle.click(); + await onHighlighterHidden; + + info( + "Checking the CSS grid toggle for #grid1 is not disabled and not active." + ); + ok( + !gridToggle.hasAttribute("disabled"), + "Grid highlighter toggle is not disabled." + ); + ok( + !gridToggle.classList.contains("active"), + "Grid highlighter toggle button is not active." + ); + is(highlighters.gridHighlighters.size, 1, "CSS grid highlighter is shown."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_gridline-names-are-shown-correctly.js b/devtools/client/inspector/rules/test/browser_rules_gridline-names-are-shown-correctly.js new file mode 100644 index 0000000000..ec7f4a358f --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_gridline-names-are-shown-correctly.js @@ -0,0 +1,146 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the text editor correctly calculates the grid line names shown in the +// autocomplete popup. We generally want to show all the grid line names for a grid +// container, except for implicit line names created by an implicitly named area. + +const TEST_URL = URL_ROOT + "doc_grid_area_gridline_names.html"; + +add_task(async function () { + await addTab(TEST_URL); + const { inspector, view } = await openRuleView(); + + info( + "Test that implicit grid line names from explicit grid areas are shown." + ); + await testExplicitNamedAreas(inspector, view); + + info( + "Test that explicit grid line names creating implicit grid areas are shown." + ); + await testImplicitNamedAreasWithExplicitGridLineNames(inspector, view); + + info( + "Test that implicit grid line names creating implicit grid areas are not shown." + ); + await testImplicitAreasWithImplicitGridLineNames(inspector, view); + await testImplicitNamedAreasWithReversedGridLineNames(inspector, view); +}); + +async function testExplicitNamedAreas(inspector, view) { + await selectNode(".header", inspector); + + const gridColLines = ["header-start", "header-end", "main-start", "main-end"]; + + const propertyName = view.styleDocument.querySelectorAll( + ".ruleview-propertyvalue" + )[0]; + const gridLineNamesUpdated = inspector.once("grid-line-names-updated"); + const editor = await focusEditableField(view, propertyName); + const onPopupShown = once(editor.popup, "popup-opened"); + await gridLineNamesUpdated; + + EventUtils.synthesizeKey("VK_DOWN", { shiftKey: true }, view.styleWindow); + + await onPopupShown; + + info( + "Check that the expected grid line column names are shown in the editor popup." + ); + for (const lineName of gridColLines) { + ok( + editor.gridLineNames.cols.indexOf(lineName) > -1, + `${lineName} is a suggested implicit grid line` + ); + } +} + +async function testImplicitNamedAreasWithExplicitGridLineNames( + inspector, + view +) { + await selectNode(".contentArea", inspector); + + const gridRowLines = [ + "main-start", + "main-end", + "content-start", + "content-end", + ]; + + const propertyName = view.styleDocument.querySelectorAll( + ".ruleview-propertyvalue" + )[1]; + const gridLineNamesUpdated = inspector.once("grid-line-names-updated"); + const editor = await focusEditableField(view, propertyName); + const onPopupShown = once(editor.popup, "popup-opened"); + await gridLineNamesUpdated; + + EventUtils.synthesizeKey("VK_DOWN", { shiftKey: true }, view.styleWindow); + + await onPopupShown; + + info( + "Check that the expected grid line row names are shown in the editor popup." + ); + for (const lineName of gridRowLines) { + ok( + editor.gridLineNames.rows.indexOf(lineName) > -1, + `${lineName} is a suggested explicit grid line` + ); + } +} + +async function testImplicitAreasWithImplicitGridLineNames(inspector, view) { + await selectNode(".a", inspector); + + const propertyName = view.styleDocument.querySelectorAll( + ".ruleview-propertyvalue" + )[0]; + const gridLineNamesUpdated = inspector.once("grid-line-names-updated"); + const editor = await focusEditableField(view, propertyName); + const onPopupShown = once(editor.popup, "popup-opened"); + await gridLineNamesUpdated; + + EventUtils.synthesizeKey("VK_DOWN", { shiftKey: true }, view.styleWindow); + + await onPopupShown; + + info( + "Check that the implicit grid lines created by implicit areas are not shown." + ); + ok( + !(editor.gridLineNames.cols.indexOf("a-end") > -1), + "a-end is not shown because it is created by an implicit named area." + ); +} + +async function testImplicitNamedAreasWithReversedGridLineNames( + inspector, + view +) { + await selectNode(".b", inspector); + + const propertyName = view.styleDocument.querySelectorAll( + ".ruleview-propertyvalue" + )[0]; + const gridLineNamesUpdated = inspector.once("grid-line-names-updated"); + const editor = await focusEditableField(view, propertyName); + const onPopupShown = once(editor.popup, "popup-opened"); + await gridLineNamesUpdated; + + EventUtils.synthesizeKey("VK_DOWN", { shiftKey: true }, view.styleWindow); + + await onPopupShown; + + info( + "Test that reversed implicit grid line names from implicit areas are not shown" + ); + ok( + !(editor.gridLineNames.cols.indexOf("b-start") > -1), + "b-start is not shown because it is created by an implicit named area." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_gridline-names-autocomplete.js b/devtools/client/inspector/rules/test/browser_rules_gridline-names-autocomplete.js new file mode 100644 index 0000000000..e80e7f01ac --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_gridline-names-autocomplete.js @@ -0,0 +1,205 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that CSS property values are autocompleted and cycled +// correctly when editing an existing property in the rule view. + +// format : +// [ +// what key to press, +// modifers, +// expected input box value after keypress, +// is the popup open, +// is a suggestion selected in the popup, +// expect ruleview-changed, +// ] + +const OPEN = true, + SELECTED = true, + CHANGE = true; +const changeTestData = [ + ["c", {}, "col1-start", OPEN, SELECTED, CHANGE], + ["o", {}, "col1-start", OPEN, SELECTED, CHANGE], + ["l", {}, "col1-start", OPEN, SELECTED, CHANGE], + ["VK_DOWN", {}, "col2-start", OPEN, SELECTED, CHANGE], + ["VK_RIGHT", {}, "col2-start", !OPEN, !SELECTED, !CHANGE], +]; + +// Creates a new CSS property value. +// Checks that grid-area autocompletes column and row names. +const newAreaTestData = [ + ["g", {}, "gap", OPEN, SELECTED, !CHANGE], + ["VK_DOWN", {}, "grid", OPEN, SELECTED, !CHANGE], + ["VK_DOWN", {}, "grid-area", OPEN, SELECTED, !CHANGE], + ["VK_TAB", {}, "", !OPEN, !SELECTED, !CHANGE], + "grid-line-names-updated", + ["c", {}, "col1-start", OPEN, SELECTED, CHANGE], + ["VK_BACK_SPACE", {}, "c", !OPEN, !SELECTED, CHANGE], + ["VK_BACK_SPACE", {}, "", OPEN, !SELECTED, CHANGE], + ["r", {}, "revert", OPEN, SELECTED, CHANGE], + ["VK_DOWN", {}, "revert-layer", OPEN, SELECTED, CHANGE], + ["VK_DOWN", {}, "row1-start", OPEN, SELECTED, CHANGE], + ["r", {}, "rr", !OPEN, !SELECTED, CHANGE], + ["VK_BACK_SPACE", {}, "r", !OPEN, !SELECTED, CHANGE], + ["o", {}, "row1-start", OPEN, SELECTED, CHANGE], + ["VK_RETURN", {}, "", !OPEN, !SELECTED, CHANGE], +]; + +// Creates a new CSS property value. +// Checks that grid-row only autocompletes row names. +const newRowTestData = [ + ["g", {}, "gap", OPEN, SELECTED, !CHANGE], + ["r", {}, "grid", OPEN, SELECTED, !CHANGE], + ["i", {}, "grid", OPEN, SELECTED, !CHANGE], + ["d", {}, "grid", OPEN, SELECTED, !CHANGE], + ["-", {}, "grid-area", OPEN, SELECTED, !CHANGE], + ["r", {}, "grid-row", OPEN, SELECTED, !CHANGE], + ["VK_RETURN", {}, "", !OPEN, !SELECTED, !CHANGE], + "grid-line-names-updated", + ["c", {}, "c", !OPEN, !SELECTED, CHANGE], + ["VK_BACK_SPACE", {}, "", OPEN, !SELECTED, CHANGE], + ["r", {}, "revert", OPEN, SELECTED, CHANGE], + ["VK_DOWN", {}, "revert-layer", OPEN, SELECTED, CHANGE], + ["VK_DOWN", {}, "row1-start", OPEN, SELECTED, CHANGE], + ["VK_TAB", {}, "", !OPEN, !SELECTED, CHANGE], +]; + +const TEST_URL = URL_ROOT + "doc_grid_names.html"; + +add_task(async function () { + await addTab(TEST_URL); + const { toolbox, inspector, view } = await openRuleView(); + + info("Test autocompletion changing a preexisting property"); + await runChangePropertyAutocompletionTest( + toolbox, + inspector, + view, + changeTestData + ); + + info("Test autocompletion creating a new property"); + await runNewPropertyAutocompletionTest( + toolbox, + inspector, + view, + newAreaTestData + ); + + info("Test autocompletion creating a new property"); + await runNewPropertyAutocompletionTest( + toolbox, + inspector, + view, + newRowTestData + ); +}); + +async function runNewPropertyAutocompletionTest( + toolbox, + inspector, + view, + testData +) { + info("Selecting the test node"); + await selectNode("#cell2", inspector); + + info("Focusing the css property editable field"); + const ruleEditor = getRuleViewRuleEditor(view, 0); + const editor = await focusNewRuleViewProperty(ruleEditor); + const gridLineNamesUpdated = inspector.once("grid-line-names-updated"); + + info("Starting to test for css property completion"); + for (const data of testData) { + if (data == "grid-line-names-updated") { + await gridLineNamesUpdated; + continue; + } + await testCompletion(data, editor, view); + } +} + +async function runChangePropertyAutocompletionTest( + toolbox, + inspector, + view, + testData +) { + info("Selecting the test node"); + await selectNode("#cell3", inspector); + + const ruleEditor = getRuleViewRuleEditor(view, 1).rule; + const prop = ruleEditor.textProps[0]; + + info("Focusing the css property editable value"); + const gridLineNamesUpdated = inspector.once("grid-line-names-updated"); + let editor = await focusEditableField(view, prop.editor.valueSpan); + await gridLineNamesUpdated; + + info("Starting to test for css property completion"); + for (const data of testData) { + // Re-define the editor at each iteration, because the focus may have moved + // from property to value and back + editor = inplaceEditor(view.styleDocument.activeElement); + await testCompletion(data, editor, view); + } +} + +async function testCompletion( + [key, modifiers, completion, open, selected, change], + editor, + view +) { + info("Pressing key " + key); + info("Expecting " + completion); + info("Is popup opened: " + open); + info("Is item selected: " + selected); + + let onDone; + if (change) { + // If the key triggers a ruleview-changed, wait for that event, it will + // always be the last to be triggered and tells us when the preview has + // been done. + onDone = view.once("ruleview-changed"); + } else { + // Otherwise, expect an after-suggest event (except if the popup gets + // closed). + onDone = + key !== "VK_RIGHT" && key !== "VK_BACK_SPACE" + ? editor.once("after-suggest") + : null; + } + + // Also listening for popup opened/closed events if needed. + const popupEvent = open ? "popup-opened" : "popup-closed"; + const onPopupEvent = + editor.popup.isOpen !== open ? once(editor.popup, popupEvent) : null; + + info("Synthesizing key " + key + ", modifiers: " + Object.keys(modifiers)); + + EventUtils.synthesizeKey(key, modifiers, view.styleWindow); + + // Flush the debounce for the preview text. + view.debounce.flush(); + + await onDone; + await onPopupEvent; + + // The key might have been a TAB or shift-TAB, in which case the editor will + // be a new one + editor = inplaceEditor(view.styleDocument.activeElement); + + info("Checking the state"); + if (completion !== null) { + is(editor.input.value, completion, "Correct value is autocompleted"); + } + + if (!open) { + ok(!(editor.popup && editor.popup.isOpen), "Popup is closed"); + } else { + ok(editor.popup.isOpen, "Popup is open"); + is(editor.popup.selectedIndex !== -1, selected, "An item is selected"); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_guessIndentation.js b/devtools/client/inspector/rules/test/browser_rules_guessIndentation.js new file mode 100644 index 0000000000..22bec84378 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_guessIndentation.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that we can guess indentation from a style sheet, not just a +// rule. + +// Use a weird indentation depth to avoid accidental success. +const TEST_URI = ` + <style type='text/css'> +div { + background-color: blue; +} + +* { +} +</style> + <div id='testid' class='testclass'>Styled Node</div> +`; + +const expectedText = ` +div { + background-color: blue; +} + +* { + color: chartreuse; +} +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { toolbox, inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + info("Add a new property in the rule-view"); + await addProperty(view, 2, "color", "chartreuse"); + + info("Switch to the style-editor"); + const { UI } = await toolbox.selectTool("styleeditor"); + + const styleEditor = await UI.editors[0].getSourceEditor(); + const text = styleEditor.sourceEditor.getText(); + is(text, expectedText, "style inspector changes are synced"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_highlight-element-rule.js b/devtools/client/inspector/rules/test/browser_rules_highlight-element-rule.js new file mode 100644 index 0000000000..f5e1357950 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_highlight-element-rule.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view's highlightElementRule scrolls to the specified rule. + +const TEST_URI = ` + <style type="text/css"> + .test::after { + content: "!"; + color: red; + } + </style> + <div class="test">Hello</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await selectNode(".test", inspector); + const { rules, styleWindow } = view; + + info("Highlight .test::after rule."); + const ruleId = rules[0].domRule.actorID; + + info("Wait for the view to scroll to the property."); + const onHighlightProperty = view.once("scrolled-to-element"); + + view.highlightElementRule(ruleId); + + await onHighlightProperty; + + ok( + isInViewport(rules[0].editor.element, styleWindow), + ".test::after is in view." + ); +}); + +function isInViewport(element, win) { + const { top, left, bottom, right } = element.getBoundingClientRect(); + return ( + top >= 0 && + bottom <= win.innerHeight && + left >= 0 && + right <= win.innerWidth + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_highlight-property.js b/devtools/client/inspector/rules/test/browser_rules_highlight-property.js new file mode 100644 index 0000000000..6c244cecfd --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_highlight-property.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view's highlightProperty scrolls to the specified declaration. + +const TEST_URI = ` + <style type="text/css"> + .test { + margin: 5px; + font-size: 12px; + border: 1px solid blue; + margin-top: 20px; + } + + .test::after { + content: "!"; + color: red; + } + </style> + <div class="test">Hello this is a test</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await selectNode(".test", inspector); + const { rules, styleWindow } = view; + + info( + "Highlight the computed border-left-width declaration in the rule view." + ); + const borderLeftWidthStyle = rules[2].textProps[2].computed.find( + ({ name }) => name === "border-left-width" + ); + + let onHighlightProperty = view.once("scrolled-to-element"); + let isHighlighted = view.highlightProperty("border-left-width"); + await onHighlightProperty; + + ok(isHighlighted, "border-left-property is highlighted."); + ok( + isInViewport(borderLeftWidthStyle.element, styleWindow), + "border-left-width is in view." + ); + + info("Highlight the font-size declaration in the rule view."); + const fontSize = rules[2].textProps[1].editor; + + info("Wait for the view to scroll to the property."); + onHighlightProperty = view.once("scrolled-to-element"); + isHighlighted = view.highlightProperty("font-size"); + await onHighlightProperty; + + ok(isHighlighted, "font-size property is highlighted."); + ok(isInViewport(fontSize.element, styleWindow), "font-size is in view."); + + info("Highlight the pseudo-element's color declaration in the rule view."); + const color = rules[0].textProps[1].editor; + + info("Wait for the view to scroll to the property."); + onHighlightProperty = view.once("scrolled-to-element"); + isHighlighted = view.highlightProperty("color"); + await onHighlightProperty; + + ok(isHighlighted, "color property is highlighted."); + ok(isInViewport(color.element, styleWindow), "color property is in view."); + + info("Highlight margin-top declaration in the rules view."); + const marginTop = rules[2].textProps[3].editor; + + info("Wait for the view to scroll to the property."); + onHighlightProperty = view.once("scrolled-to-element"); + isHighlighted = view.highlightProperty("margin-top"); + await onHighlightProperty; + + ok(isHighlighted, "margin-top property is highlighted."); + ok( + isInViewport(marginTop.element, styleWindow), + "margin-top property is in view." + ); +}); + +function isInViewport(element, win) { + const { top, left, bottom, right } = element.getBoundingClientRect(); + return ( + top >= 0 && + bottom <= win.innerHeight && + left >= 0 && + right <= win.innerWidth + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_highlight-used-fonts.js b/devtools/client/inspector/rules/test/browser_rules_highlight-used-fonts.js new file mode 100644 index 0000000000..f0dc95f15c --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_highlight-used-fonts.js @@ -0,0 +1,125 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that a used font-family is highlighted in the rule-view. + +const TEST_URI = ` + <style type="text/css"> + #id1 { + font-family: foo, bar, sans-serif; + } + #id2 { + font-family: serif; + } + #id3 { + font-family: foo, monospace, monospace, serif; + } + #id4 { + font-family: foo, bar; + } + #id5 { + font-family: "monospace"; + } + #id6 { + font-family: georgia, arial; + } + #id7 { + font-family: foo, serif !important; + } + #id8 { + font-family: important; + } + #id9::before { + content: ' '; + font-family: foo, monospace; + } + </style> + <div id="id1">Text</div> + <div id="id2">Text</div> + <div id="id3">Text</div> + <div id="id4">Text</div> + <div id="id5">Text</div> + <div id="id6">A Ɋ</div> + <div id="id7">Text</div> + <div id="id8">Text</div> + <div id="id9">Text</div> +`; + +// Tests that font-family properties in the rule-view correctly +// indicates which font is in use. +// Each entry in the test array should contain: +// { +// baseSelector: the rule-view selector to look for font-family in +// nb: the number of fonts this property should have +// used: the indexes of all the fonts that should be highlighted, or null if none should +// be highlighted +// selectBeforePseudoElement: Whether the before pseudo element should be selectd or not +// } +const TESTS = [ + { baseSelector: "#id1", nb: 3, used: [2] }, // sans-serif + { baseSelector: "#id2", nb: 1, used: [0] }, // serif + { baseSelector: "#id3", nb: 4, used: [1] }, // monospace + { baseSelector: "#id4", nb: 2, used: null }, + { baseSelector: "#id5", nb: 1, used: [0] }, // monospace + { baseSelector: "#id7", nb: 2, used: [1] }, // serif + { baseSelector: "#id8", nb: 1, used: null }, + { baseSelector: "#id9", nb: 2, used: [1], selectBeforePseudoElement: true }, // monospace +]; + +if (Services.appinfo.OS !== "Linux") { + // Both georgia and arial are used because the second character can't be rendered with + // georgia, so the browser falls back. Also, skip this on Linux which has neither of + // these fonts. + TESTS.push({ baseSelector: "#id6", nb: 2, used: [0, 1] }); +} + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + for (const { baseSelector, nb, used, selectBeforePseudoElement } of TESTS) { + const onFontHighlighted = view.once("font-highlighted"); + + if (selectBeforePseudoElement) { + // Query the first children node to get the before pseudo element: + const baseNode = await getNodeFront(baseSelector, inspector); + const pseudoElement = (await inspector.walker.children(baseNode)) + .nodes[0]; + await selectNode(pseudoElement, inspector); + } else { + await selectNode(baseSelector, inspector); + } + await onFontHighlighted; + + const selector = !selectBeforePseudoElement + ? baseSelector + : `${baseSelector}::before`; + info(`Looking for fonts in font-family property for: <${selector}>`); + + const prop = getRuleViewProperty(view, selector, "font-family").valueSpan; + const fonts = prop.querySelectorAll(".ruleview-font-family"); + + ok(fonts.length, "Fonts found in the property"); + is(fonts.length, nb, "Correct number of fonts found in the property"); + + const highlighted = [...fonts].filter(span => + span.classList.contains("used-font") + ); + const expectedHighlightedNb = used === null ? 0 : used.length; + is( + highlighted.length, + expectedHighlightedNb, + "Correct number of used fonts found" + ); + + let highlightedIndex = 0; + [...fonts].forEach((font, index) => { + if (font === highlighted[highlightedIndex]) { + is(index, used[highlightedIndex], "The right font is highlighted"); + highlightedIndex++; + } + }); + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_imported_stylesheet_edit.js b/devtools/client/inspector/rules/test/browser_rules_imported_stylesheet_edit.js new file mode 100644 index 0000000000..76c2b4a5ba --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_imported_stylesheet_edit.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const TEST_URI = URL_ROOT_SSL + "doc_rules_imported_stylesheet_edit.html"; +const SJS_URI = URL_ROOT_SSL + "sjs_imported_stylesheet_edit.sjs"; +/** + * Test that imported stylesheets are correctly handled by the inspector after + * being updated. + * The inspector used to retrieve an outdated version of the stylesheet text, + * which triggered many issues: outdated values, blank panels etc... + * + * This test involves an imported CSS which is generated by a sjs file. + * Using sjs here allows us to simulate an "update" of a stylesheet while still + * fetching the same URL, which closely matches what a developer would experience + * when manually editing a stylesheet in an IDE before reloading a page. + */ +add_task(async function () { + info("Call `?setup` on the test sjs"); + await fetch(SJS_URI + "?setup"); + + info("Add the test tab, open the rule-view and select the test node"); + await addTab(TEST_URI); + + const { inspector, view } = await openRuleView(); + + await selectNode("div", inspector); + const redColorProp = getTextProperty(view, 1, { color: "red" }); + ok(redColorProp, "RuleView displays a color:red property"); + + // The "?update-stylesheet" call will change the CSS returned by sjs_imported_stylesheet_edit.sjs: + // - some rules are added before the matching `div {}` rule + // - the value of the `color` property changes + info("Call `?update-stylesheet` on the test sjs"); + await fetch(SJS_URI + "?update-stylesheet"); + + info("Reload the page to restore the initial state"); + await navigateTo(TEST_URI); + + info("Wait until a rule is displayed at index 1"); + await waitFor(() => view.element.children[1]); + + info("Check that the displayed rule has been correctly updated."); + const goldColorProp = getTextProperty(view, 1, { color: "gold" }); + ok(goldColorProp, "RuleView displays a color:gold property"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_inactive_css_display-justify.js b/devtools/client/inspector/rules/test/browser_rules_inactive_css_display-justify.js new file mode 100644 index 0000000000..a196fcca40 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_inactive_css_display-justify.js @@ -0,0 +1,47 @@ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that a declaration's inactive state doesn't linger on its previous state when +// the declaration it depends on changes. Bug 1593944 + +const TEST_URI = ` +<style> + div { + justify-content: center; + /*! display: flex */ + } +</style> +<div>`; + +add_task(async function () { + await pushPref("devtools.inspector.inactive.css.enabled", true); + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await selectNode("div", inspector); + + const justifyContent = { "justify-content": "center" }; + const justifyItems = { "justify-items": "center" }; + const displayFlex = { display: "flex" }; + const displayGrid = { display: "grid" }; + + info("Enable display:flex and check that justify-content becomes active"); + await checkDeclarationIsInactive(view, 1, justifyContent); + await toggleDeclaration(view, 1, displayFlex); + await checkDeclarationIsActive(view, 1, justifyContent); + + info( + "Rename justify-content to justify-items and check that it becomes inactive" + ); + await updateDeclaration(view, 1, justifyContent, justifyItems); + await checkDeclarationIsInactive(view, 1, justifyItems); + + info( + "Rename display:flex to display:grid and check that justify-items becomes active" + ); + await updateDeclaration(view, 1, displayFlex, displayGrid); + await checkDeclarationIsActive(view, 1, justifyItems); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_inactive_css_flexbox.js b/devtools/client/inspector/rules/test/browser_rules_inactive_css_flexbox.js new file mode 100644 index 0000000000..23f1b39845 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_inactive_css_flexbox.js @@ -0,0 +1,161 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test inactive flex properties. + +const TEST_URI = ` +<head> + <style> + #container { + width: 200px; + height: 100px; + border: 1px solid #000; + align-content: space-between; + order: 1; + } + + .flex-item { + flex-basis: auto; + flex-grow: 1; + flex-shrink: 1; + flex-direction: row; + } + + #self-aligned { + align-self: stretch; + } + </style> +</head> +<body> + <h1>browser_rules_inactive_css_flexbox.js</h1> + <div id="container" style="display:flex"> + <div class="flex-item item-1" style="order:1">1</div> + <div class="flex-item item-2" style="order:2">2</div> + <div class="flex-item item-3" style="order:3">3</div> + </div> + <div id="self-aligned"></div> +</body>`; + +const BEFORE = [ + { + selector: "#self-aligned", + inactiveDeclarations: [ + { + declaration: { + "align-self": "stretch", + }, + ruleIndex: 1, + }, + ], + }, + { + selector: ".item-2", + 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, + }, + ], + }, + { + selector: "#container", + activeDeclarations: [ + { + declarations: { + display: "flex", + }, + ruleIndex: 0, + }, + { + declarations: { + width: "200px", + height: "100px", + border: "1px solid #000", + "align-content": "space-between", + }, + ruleIndex: 1, + }, + ], + inactiveDeclarations: [ + { + declaration: { + order: "1", + }, + ruleIndex: 1, + }, + ], + }, +]; + +const AFTER = [ + { + selector: ".item-2", + inactiveDeclarations: [ + { + declaration: { + order: "2", + }, + ruleIndex: 0, + }, + { + declaration: { + "flex-basis": "auto", + }, + ruleIndex: 1, + }, + { + declaration: { + "flex-grow": "1", + }, + ruleIndex: 1, + }, + { + declaration: { + "flex-shrink": "1", + }, + ruleIndex: 1, + }, + { + declaration: { + "flex-direction": "row", + }, + ruleIndex: 1, + }, + ], + }, +]; + +add_task(async function () { + await pushPref("devtools.inspector.inactive.css.enabled", true); + + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await runInactiveCSSTests(view, inspector, BEFORE); + + // Toggle `display:flex` to disabled. + await toggleDeclaration(view, 0, { + display: "flex", + }); + await runInactiveCSSTests(view, inspector, AFTER); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_inactive_css_grid.js b/devtools/client/inspector/rules/test/browser_rules_inactive_css_grid.js new file mode 100644 index 0000000000..a0afad08f7 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_inactive_css_grid.js @@ -0,0 +1,267 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test inactive grid properties. + +const TEST_URI = ` +<head> + <style> + html { + grid-area: foo; + } + #container { + width: 200px; + height: 100px; + border: 1px solid #000; + column-gap: 10px; + row-gap: 10px; + align-self: start; + position: relative; + } + + .item-1 { + grid-column-start: 1; + grid-column-end: 3; + grid-row-start: 1; + grid-row-end: auto; + flex-direction: row + } + + #abspos { + position: absolute; + grid-column: 2; + } + + #self-aligned { + align-self: stretch; + } + </style> +</head> +<body> + <h1>browser_rules_inactive_css_grid.js</h1> + <div id="container" style="display:grid"> + <div class="grid-item item-1">1</div> + <div class="grid-item item-2">2</div> + <div class="grid-item item-3">3</div> + <div class="grid-item item-4">4</div> + <div class="grid-item item-5"> + <div id="abspos">AbsPos item</div> + </div> + </div> + <div id="self-aligned"></div> +</body>`; + +const BEFORE = [ + { + // Check first that the getting grid-related data about the <html> node doesn't break. + // See bug 1576484. + selector: "html", + inactiveDeclarations: [ + { + declaration: { + "grid-area": "foo", + }, + ruleIndex: 1, + }, + ], + }, + { + selector: "#self-aligned", + inactiveDeclarations: [ + { + declaration: { + "align-self": "stretch", + }, + ruleIndex: 1, + }, + ], + }, + { + selector: ".item-1", + activeDeclarations: [ + { + declarations: { + "grid-column-start": "1", + "grid-column-end": "3", + "grid-row-start": "1", + "grid-row-end": "auto", + }, + ruleIndex: 1, + }, + ], + inactiveDeclarations: [ + { + declaration: { + "flex-direction": "row", + }, + ruleIndex: 1, + }, + ], + }, + { + selector: "#abspos", + activeDeclarations: [ + { + declarations: { + "grid-column": 2, + }, + ruleIndex: 1, + }, + ], + }, + { + selector: "#container", + activeDeclarations: [ + { + declarations: { + display: "grid", + }, + ruleIndex: 0, + }, + { + declarations: { + width: "200px", + height: "100px", + border: "1px solid #000", + "column-gap": "10px", + "row-gap": "10px", + }, + ruleIndex: 1, + }, + ], + inactiveDeclarations: [ + { + declaration: { + "align-self": "start", + }, + ruleIndex: 1, + }, + ], + }, +]; + +const AFTER = [ + { + activeDeclarations: [ + { + declarations: { + display: "grid", + }, + ruleIndex: 0, + }, + { + declarations: { + width: "200px", + height: "100px", + border: "1px solid #000", + }, + ruleIndex: 1, + }, + ], + inactiveDeclarations: [ + { + declaration: { + "column-gap": "10px", + }, + ruleIndex: 1, + }, + { + declaration: { + "row-gap": "10px", + }, + ruleIndex: 1, + }, + { + declaration: { + "align-self": "start", + }, + ruleIndex: 1, + }, + ], + }, + { + selector: "#abspos", + inactiveDeclarations: [ + { + declaration: { + "grid-column": 2, + }, + ruleIndex: 1, + }, + ], + }, +]; + +add_task(async function () { + await pushPref("devtools.inspector.inactive.css.enabled", true); + + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await runInactiveCSSTests(view, inspector, BEFORE); + + // Toggle `display:grid` to disabled. + await toggleDeclaration(view, 0, { + display: "grid", + }); + await view.once("ruleview-refreshed"); + await runInactiveCSSTests(view, inspector, AFTER); + + info("Toggle `display: grid` to enabled again."); + await selectNode("#container", inspector); + await toggleDeclaration(view, 0, { + display: "grid", + }); + await runAbsPosGridElementTests(view, inspector); +}); + +/** + * Tests for absolute positioned elements in a grid. + */ +async function runAbsPosGridElementTests(view, inspector) { + info("Toggling `position: relative` to disabled."); + await toggleDeclaration(view, 1, { + position: "relative", + }); + await runInactiveCSSTests(view, inspector, [ + { + selector: "#abspos", + inactiveDeclarations: [ + { + declaration: { + "grid-column": 2, + }, + ruleIndex: 1, + }, + ], + }, + ]); + + info("Toggling `position: relative` back to enabled."); + await selectNode("#container", inspector); + await toggleDeclaration(view, 1, { + position: "relative", + }); + + info("Toggling `position: absolute` on grid element to disabled."); + await selectNode("#abspos", inspector); + await toggleDeclaration(view, 1, { + position: "absolute", + }); + + await runInactiveCSSTests(view, inspector, [ + { + selector: "#abspos", + inactiveDeclarations: [ + { + declaration: { + "grid-column": 2, + }, + ruleIndex: 1, + }, + ], + }, + ]); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_inactive_css_inline.js b/devtools/client/inspector/rules/test/browser_rules_inactive_css_inline.js new file mode 100644 index 0000000000..2670e50d10 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_inactive_css_inline.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test css properties that are inactive on block-level elements. + +const TEST_URI = ` +<style> +#block { + border: 1px solid #000; + vertical-align: sub; +} +td { + vertical-align: super; +} +#flex { + display: inline-flex; + vertical-align: text-bottom; +} +</style> +<h1 style="vertical-align:text-bottom;">browser_rules_inactive_css_inline.js</h1> +<div id="block">Block</div> +<table> + <tr><td>A table cell</td></tr> +</table> +<div id="flex">Inline flex element</div> +`; + +const TEST_DATA = [ + { + selector: "h1", + inactiveDeclarations: [ + { + declaration: { "vertical-align": "text-bottom" }, + ruleIndex: 0, + }, + ], + }, + { + selector: "#block", + inactiveDeclarations: [ + { + declaration: { "vertical-align": "sub" }, + ruleIndex: 1, + }, + ], + }, + { + selector: "td", + activeDeclarations: [ + { + declarations: { "vertical-align": "super" }, + ruleIndex: 1, + }, + ], + }, + { + selector: "#flex", + activeDeclarations: [ + { + declarations: { "vertical-align": "text-bottom" }, + ruleIndex: 1, + }, + ], + }, +]; + +add_task(async function () { + await pushPref("devtools.inspector.inactive.css.enabled", true); + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await runInactiveCSSTests(view, inspector, TEST_DATA); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_inactive_css_split-condition.js b/devtools/client/inspector/rules/test/browser_rules_inactive_css_split-condition.js new file mode 100644 index 0000000000..d03bcd9e63 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_inactive_css_split-condition.js @@ -0,0 +1,31 @@ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that a CSS property is marked as inactive when a condition +// changes in other CSS rule matching the element. + +const TEST_URI = ` +<style> + .display { + display: grid; + } + .gap { + gap: 1em; + } +</style> +<div class="display gap">`; + +add_task(async function () { + await pushPref("devtools.inspector.inactive.css.enabled", true); + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await selectNode("div", inspector); + + await checkDeclarationIsActive(view, 1, { gap: "1em" }); + await toggleDeclaration(view, 2, { display: "grid" }); + await checkDeclarationIsInactive(view, 1, { gap: "1em" }); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_inactive_css_visited.js b/devtools/client/inspector/rules/test/browser_rules_inactive_css_visited.js new file mode 100644 index 0000000000..6b452f23bc --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_inactive_css_visited.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test css properties that are inactive in :visited rule. + +const TEST_URI = URL_ROOT + "doc_visited.html"; + +const TEST_DATA = [ + { + selector: "#visited", + inactiveDeclarations: [ + { + declaration: { "font-size": "100px" }, + ruleIndex: 2, + }, + { + declaration: { "margin-left": "50px" }, + ruleIndex: 2, + }, + ], + activeDeclarations: [ + { + declarations: { + "background-color": "transparent", + "border-color": "lime", + color: "rgba(0, 255, 0, 0.8)", + "text-decoration-color": "lime", + "text-emphasis-color": "seagreen", + }, + ruleIndex: 2, + }, + ], + }, + { + selector: "#visited-and-other-matched-selector", + activeDeclarations: [ + { + declarations: { + "background-color": "transparent", + "border-color": "lime", + color: "rgba(0, 255, 0, 0.8)", + "font-size": "100px", + "margin-left": "50px", + "text-decoration-color": "lime", + "text-emphasis-color": "seagreen", + }, + ruleIndex: 1, + }, + ], + }, +]; + +add_task(async () => { + info("Open a url which has visited links"); + const tab = await addTab(TEST_URI); + + info("Wait until the visited links are available"); + const selectors = TEST_DATA.map(t => t.selector); + await waitUntilVisitedState(tab, selectors); + + info("Open the inspector"); + const { inspector, view } = await openRuleView(); + + await runInactiveCSSTests(view, inspector, TEST_DATA); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_inactive_css_xul.js b/devtools/client/inspector/rules/test/browser_rules_inactive_css_xul.js new file mode 100644 index 0000000000..b9536f1f9e --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_inactive_css_xul.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test inactive css properties in XUL documents. + +const TEST_URI = URL_ROOT_SSL + "doc_inactive_css_xul.xhtml"; + +const TEST_DATA = [ + { + selector: "#test-img-in-xul", + inactiveDeclarations: [ + { + declaration: { "grid-column-gap": "5px" }, + ruleIndex: 0, + }, + ], + activeDeclarations: [ + { + declarations: { + width: "10px", + height: "10px", + }, + ruleIndex: 0, + }, + ], + }, +]; + +add_task(async () => { + await SpecialPowers.pushPermissions([ + { type: "allowXULXBL", allow: true, context: URL_ROOT_SSL }, + ]); + + info("Open a url to a XUL document"); + await addTab(TEST_URI); + + info("Open the inspector"); + const { inspector, view } = await openRuleView(); + + await runInactiveCSSTests(view, inspector, TEST_DATA); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_inherited-properties_01.js b/devtools/client/inspector/rules/test/browser_rules_inherited-properties_01.js new file mode 100644 index 0000000000..60e7966528 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_inherited-properties_01.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that inherited properties appear for a nested element in the +// rule view. + +const TEST_URI = ` + <style type="text/css"> + #test2 { + background-color: green; + color: purple; + } + </style> + <div id="test2"><div id="test1">Styled Node</div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#test1", inspector); + await simpleInherit(inspector, view); +}); + +function simpleInherit(inspector, view) { + const elementStyle = view._elementStyle; + is(elementStyle.rules.length, 2, "Should have 2 rules."); + + const elementRule = elementStyle.rules[0]; + ok( + !elementRule.inherited, + "Element style attribute should not consider itself inherited." + ); + + const inheritRule = elementStyle.rules[1]; + is( + inheritRule.selectorText, + "#test2", + "Inherited rule should be the one that includes inheritable properties." + ); + ok(!!inheritRule.inherited, "Rule should consider itself inherited."); + is(inheritRule.textProps.length, 2, "Rule should have two styles"); + const bgcProp = inheritRule.textProps[0]; + is( + bgcProp.name, + "background-color", + "background-color property should exist" + ); + ok(bgcProp.invisible, "background-color property should be invisible"); + const inheritProp = inheritRule.textProps[1]; + is(inheritProp.name, "color", "color should have been inherited."); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_inherited-properties_02.js b/devtools/client/inspector/rules/test/browser_rules_inherited-properties_02.js new file mode 100644 index 0000000000..da489b45d1 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_inherited-properties_02.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that no inherited properties appear when the property does not apply +// to the nested element. + +const TEST_URI = ` + <style type="text/css"> + #test2 { + background-color: green; + } + </style> + <div id="test2"><div id="test1">Styled Node</div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#test1", inspector); + await emptyInherit(inspector, view); +}); + +function emptyInherit(inspector, view) { + // No inheritable styles, this rule shouldn't show up. + const elementStyle = view._elementStyle; + is(elementStyle.rules.length, 1, "Should have 1 rule."); + + const elementRule = elementStyle.rules[0]; + ok( + !elementRule.inherited, + "Element style attribute should not consider itself inherited." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_inherited-properties_03.js b/devtools/client/inspector/rules/test/browser_rules_inherited-properties_03.js new file mode 100644 index 0000000000..995fd7f88d --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_inherited-properties_03.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that inline inherited properties appear in the nested element. + +var { + style: { ELEMENT_STYLE }, +} = require("resource://devtools/shared/constants.js"); + +const TEST_URI = ` + <div id="test2" style="color: red"> + <div id="test1">Styled Node</div> + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#test1", inspector); + await elementStyleInherit(inspector, view); +}); + +function elementStyleInherit(inspector, view) { + const elementStyle = view._elementStyle; + is(elementStyle.rules.length, 2, "Should have 2 rules."); + + const elementRule = elementStyle.rules[0]; + ok( + !elementRule.inherited, + "Element style attribute should not consider itself inherited." + ); + + const inheritRule = elementStyle.rules[1]; + is( + inheritRule.domRule.type, + ELEMENT_STYLE, + "Inherited rule should be an element style, not a rule." + ); + ok(!!inheritRule.inherited, "Rule should consider itself inherited."); + is( + inheritRule.textProps.length, + 1, + "Should only display one inherited style" + ); + const inheritProp = inheritRule.textProps[0]; + is(inheritProp.name, "color", "color should have been inherited."); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_inherited-properties_04.js b/devtools/client/inspector/rules/test/browser_rules_inherited-properties_04.js new file mode 100644 index 0000000000..2416b01910 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_inherited-properties_04.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that different inherited properties sections are created for rules +// inherited from several elements of the same type. + +const TEST_URI = ` + <div style="cursor:pointer"> + A + <div style="cursor:pointer"> + B<a>Cursor</a> + </div> + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("a", inspector); + await elementStyleInherit(inspector, view); +}); + +function elementStyleInherit(inspector, view) { + const gutters = view.element.querySelectorAll(".ruleview-header"); + is(gutters.length, 2, "Gutters should contains 2 sections"); + ok(gutters[0].textContent, "Inherited from div"); + ok(gutters[1].textContent, "Inherited from div"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_inline-source-map.js b/devtools/client/inspector/rules/test/browser_rules_inline-source-map.js new file mode 100644 index 0000000000..34a9943b56 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_inline-source-map.js @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that when a source map comment appears in an inline stylesheet, the +// rule-view still appears correctly. +// Bug 1255787. + +const TESTCASE_URI = URL_ROOT + "doc_inline_sourcemap.html"; +const PREF = "devtools.source-map.client-service.enabled"; + +add_task(async function () { + Services.prefs.setBoolPref(PREF, true); + + await addTab(TESTCASE_URI); + const { inspector, view } = await openRuleView(); + + await selectNode("div", inspector); + + const ruleEl = getRuleViewRule(view, "div"); + ok(ruleEl, "The 'div' rule exists in the rule-view"); + + Services.prefs.clearUserPref(PREF); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_inline-style-order.js b/devtools/client/inspector/rules/test/browser_rules_inline-style-order.js new file mode 100644 index 0000000000..6018b04a85 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_inline-style-order.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that when the order of properties in the inline style changes, the inline style +// rule updates accordingly. +// This can happen in cases such as this one: +// Given this DOM node: +// <div style="margin:0;color:red;"></div> +// Executing this: +// element.style.margin = "10px"; +// Will result in the following attribute value: +// <div style="color: red; margin: 10px;"></div> +// The inline style rule in the rule-view need to update to reflect the new order of +// properties accordingly. +// Note however that we do not want to expect a specific order in this test, and be +// subject to failures if it changes again. Instead, the test compares the order against +// what is in the style DOM attribute. +// See bug 1467076. + +// Test cases, these are { name, value } objects used to change the DOM element's style +// property. After each of these changes, the inline style rule's content will be checked +// against the style DOM attribute's content. +const TEST_CASES = [ + { name: "margin", value: "10px" }, + { name: "color", value: "blue" }, + { name: "padding", value: "20px" }, + { name: "margin", value: "0px" }, + { name: "color", value: "black" }, +]; + +add_task(async function () { + const { linkedBrowser: browser } = await addTab( + `data:text/html;charset=utf-8,<div style="margin:0;color:red;">Inspect me!</div>` + ); + + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + for (const { name, value } of TEST_CASES) { + info(`Setting style.${name} to ${value} on the test node`); + + const onStyleMutation = waitForStyleModification(inspector); + const onRuleRefreshed = inspector.once("rule-view-refreshed"); + await SpecialPowers.spawn( + browser, + [{ name, value }], + async function (change) { + content.document.querySelector("div").style[change.name] = change.value; + } + ); + await Promise.all([onStyleMutation, onRuleRefreshed]); + + info("Getting and parsing the content of the node's style attribute"); + const markupContainer = inspector.markup.getContainer( + inspector.selection.nodeFront + ); + const styleAttrValue = + markupContainer.elt.querySelector(".attr-value").textContent; + const parsedStyleAttr = styleAttrValue + .split(";") + .filter(v => v.trim()) + .map(decl => { + const nameValue = decl.split(":").map(v => v.trim()); + return { name: nameValue[0], value: nameValue[1] }; + }); + + info("Checking the content of the rule-view"); + const ruleEditor = getRuleViewRuleEditor(view, 0); + const propertiesEls = ruleEditor.propertyList.children; + + parsedStyleAttr.forEach((expected, i) => { + is( + propertiesEls[i].querySelector(".ruleview-propertyname").textContent, + expected.name, + `Correct name found for property ${i}` + ); + is( + propertiesEls[i].querySelector(".ruleview-propertyvalue").textContent, + expected.value, + `Correct value found for property ${i}` + ); + }); + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_invalid-source-map.js b/devtools/client/inspector/rules/test/browser_rules_invalid-source-map.js new file mode 100644 index 0000000000..0e99d11789 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_invalid-source-map.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that when a source map is missing/invalid, the rule view still loads +// correctly. + +const TESTCASE_URI = URL_ROOT + "doc_invalid_sourcemap.html"; +const PREF = "devtools.source-map.client-service.enabled"; +const CSS_LOC = "doc_invalid_sourcemap.css:1"; + +add_task(async function () { + Services.prefs.setBoolPref(PREF, true); + + await addTab(TESTCASE_URI); + const { inspector, view } = await openRuleView(); + + await selectNode("div", inspector); + + const ruleEl = getRuleViewRule(view, "div"); + ok(ruleEl, "The 'div' rule exists in the rule-view"); + + const prop = getRuleViewProperty(view, "div", "color"); + ok(prop, "The 'color' property exists in this rule"); + + const value = getRuleViewPropertyValue(view, "div", "color"); + is(value, "gold", "The 'color' property has the right value"); + + await verifyLinkText(view, CSS_LOC); + + Services.prefs.clearUserPref(PREF); +}); + +function verifyLinkText(view, text) { + info("Verifying that the rule-view stylesheet link is " + text); + const label = getRuleViewLinkByIndex(view, 1).querySelector( + ".ruleview-rule-source-label" + ); + return waitForSuccess( + () => label.textContent == text, + "Link text changed to display correct location: " + text + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_invalid.js b/devtools/client/inspector/rules/test/browser_rules_invalid.js new file mode 100644 index 0000000000..f6776b47c4 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_invalid.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that an invalid property still lets us display the rule view +// Bug 1235603. + +const TEST_URI = ` + <style> + div { + background: #fff; + font-family: sans-serif; + url(display-table.min.htc); + } + </style> + <body> + <div id="testid" class="testclass">Styled Node</div> + </body> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + is(view._elementStyle.rules.length, 2, "Should have 2 rules."); + // Have to actually get the rule in order to ensure that the + // elements were created. + ok(getRuleViewRule(view, "div"), "Rule with div selector exists"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_keybindings.js b/devtools/client/inspector/rules/test/browser_rules_keybindings.js new file mode 100644 index 0000000000..ec4d1c5bf5 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_keybindings.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that focus doesn't leave the style editor when adding a property +// (bug 719916) + +add_task(async function () { + await addTab("data:text/html;charset=utf-8,<h1>Some header text</h1>"); + const { inspector, view } = await openRuleView(); + await selectNode("h1", inspector); + + info("Getting the ruleclose brace element"); + const brace = view.styleDocument.querySelector(".ruleview-ruleclose"); + + info("Focus the new property editable field to create a color property"); + const ruleEditor = getRuleViewRuleEditor(view, 0); + let editor = await focusNewRuleViewProperty(ruleEditor); + editor.input.value = "color"; + + info("Typing ENTER to focus the next field: property value"); + let onFocus = once(brace.parentNode, "focus", true); + let onRuleViewChanged = view.once("ruleview-changed"); + + EventUtils.sendKey("return"); + + await onFocus; + await onRuleViewChanged; + ok(true, "The value field was focused"); + + info("Entering a property value"); + editor = getCurrentInplaceEditor(view); + editor.input.value = "green"; + + info("Typing ENTER again should focus a new property name"); + onFocus = once(brace.parentNode, "focus", true); + onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.sendKey("return"); + await onFocus; + await onRuleViewChanged; + ok(true, "The new property name field was focused"); + getCurrentInplaceEditor(view).input.blur(); +}); + +function getCurrentInplaceEditor(view) { + return inplaceEditor(view.styleDocument.activeElement); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_keyframeLineNumbers.js b/devtools/client/inspector/rules/test/browser_rules_keyframeLineNumbers.js new file mode 100644 index 0000000000..98b0452a9b --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_keyframeLineNumbers.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that editing a rule will update the line numbers of subsequent +// rules in the rule view. + +const TESTCASE_URI = URL_ROOT + "doc_keyframeLineNumbers.html"; + +add_task(async function () { + await addTab(TESTCASE_URI); + const { inspector, view } = await openRuleView(); + await selectNode("#outer", inspector); + + info("Insert a new property, which will affect the line numbers"); + await addProperty(view, 1, "font-size", "72px"); + + await selectNode("#inner", inspector); + + const value = getRuleViewLinkTextByIndex(view, 3); + // Note that this is relative to the <style>. + is(value.slice(-3), ":27", "rule line number is 27"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_keyframes-rule-shadowdom.js b/devtools/client/inspector/rules/test/browser_rules_keyframes-rule-shadowdom.js new file mode 100644 index 0000000000..1200fa3ab0 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_keyframes-rule-shadowdom.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that keyframes are displayed for elements nested under a shadow-root. + +const TEST_URI = `data:text/html;charset=utf-8, + <div></div> + <script> + document.querySelector('div').attachShadow({mode: 'open'}).innerHTML = \` + <span>text</span> + <style> + @keyframes blink { + 0% { + border: rgba(255,0,0,1) 2px dashed; + } + 100% { + border: rgba(255,0,0,0) 2px dashed; + } + } + span { + animation: blink .5s 0s infinite; + } + </style>\`; + </script>`; + +add_task(async function () { + await addTab(TEST_URI); + + const { inspector, view } = await openRuleView(); + + info("Expand the shadow-root parent"); + const divFront = await getNodeFront("div", inspector); + await inspector.markup.expandNode(divFront); + await waitForMultipleChildrenUpdates(inspector); + + const { markup } = inspector; + const divContainer = markup.getContainer(divFront); + + info("Expand the shadow-root"); + const shadowRootContainer = divContainer.getChildContainers()[0]; + await expandContainer(inspector, shadowRootContainer); + + info("Retrieve the rules displayed for the span under the shadow root"); + const spanContainer = shadowRootContainer.getChildContainers()[0]; + const rules = await getKeyframeRules(spanContainer.node, inspector, view); + + is( + convertTextPropsToString(rules.keyframeRules[0].textProps), + "border: rgba(255,0,0,1) 2px dashed", + "Keyframe blink (0%) property is correct" + ); + + is( + convertTextPropsToString(rules.keyframeRules[1].textProps), + "border: rgba(255,0,0,0) 2px dashed", + "Keyframe blink (100%) property is correct" + ); +}); + +function convertTextPropsToString(textProps) { + return textProps.map(t => t.name + ": " + t.value).join("; "); +} + +async function getKeyframeRules(selector, inspector, view) { + await selectNode(selector, inspector); + const elementStyle = view._elementStyle; + + const rules = { + elementRules: elementStyle.rules.filter(rule => !rule.keyframes), + keyframeRules: elementStyle.rules.filter(rule => rule.keyframes), + }; + + return rules; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_keyframes-rule_01.js b/devtools/client/inspector/rules/test/browser_rules_keyframes-rule_01.js new file mode 100644 index 0000000000..a5e7cf720a --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_keyframes-rule_01.js @@ -0,0 +1,121 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that keyframe rules and gutters are displayed correctly in the +// rule view. + +const TEST_URI = URL_ROOT + "doc_keyframeanimation.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + await testPacman(inspector, view); + await testBoxy(inspector, view); + await testMoxy(inspector, view); +}); + +async function testPacman(inspector, view) { + info("Test content and gutter in the keyframes rule of #pacman"); + + await assertKeyframeRules("#pacman", inspector, view, { + elementRulesNb: 2, + keyframeRulesNb: 2, + keyframesRules: ["pacman", "pacman"], + keyframeRules: ["100%", "100%"], + }); + + assertGutters(view, { + guttersNbs: 2, + gutterHeading: ["Keyframes pacman", "Keyframes pacman"], + }); +} + +async function testBoxy(inspector, view) { + info("Test content and gutter in the keyframes rule of #boxy"); + + await assertKeyframeRules("#boxy", inspector, view, { + elementRulesNb: 3, + keyframeRulesNb: 3, + keyframesRules: ["boxy", "boxy", "boxy"], + keyframeRules: ["10%", "20%", "100%"], + }); + + assertGutters(view, { + guttersNbs: 1, + gutterHeading: ["Keyframes boxy"], + }); +} + +async function testMoxy(inspector, view) { + info("Test content and gutter in the keyframes rule of #moxy"); + + await assertKeyframeRules("#moxy", inspector, view, { + elementRulesNb: 3, + keyframeRulesNb: 4, + keyframesRules: ["boxy", "boxy", "boxy", "moxy"], + keyframeRules: ["10%", "20%", "100%", "100%"], + }); + + assertGutters(view, { + guttersNbs: 2, + gutterHeading: ["Keyframes boxy", "Keyframes moxy"], + }); +} + +async function assertKeyframeRules(selector, inspector, view, expected) { + await selectNode(selector, inspector); + const elementStyle = view._elementStyle; + + const rules = { + elementRules: elementStyle.rules.filter(rule => !rule.keyframes), + keyframeRules: elementStyle.rules.filter(rule => rule.keyframes), + }; + + is( + rules.elementRules.length, + expected.elementRulesNb, + selector + " has the correct number of non keyframe element rules" + ); + is( + rules.keyframeRules.length, + expected.keyframeRulesNb, + selector + " has the correct number of keyframe rules" + ); + + let i = 0; + for (const keyframeRule of rules.keyframeRules) { + ok( + keyframeRule.keyframes.name == expected.keyframesRules[i], + keyframeRule.keyframes.name + " has the correct keyframes name" + ); + ok( + keyframeRule.domRule.keyText == expected.keyframeRules[i], + keyframeRule.domRule.keyText + " selector heading is correct" + ); + i++; + } +} + +function assertGutters(view, expected) { + const gutters = view.element.querySelectorAll(".ruleview-header"); + + is( + gutters.length, + expected.guttersNbs, + "There are " + gutters.length + " gutter headings" + ); + + let i = 0; + for (const gutter of gutters) { + is( + gutter.textContent, + expected.gutterHeading[i], + "Correct " + gutter.textContent + " gutter headings" + ); + i++; + } + + return gutters; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_keyframes-rule_02.js b/devtools/client/inspector/rules/test/browser_rules_keyframes-rule_02.js new file mode 100644 index 0000000000..63e8ae4e65 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_keyframes-rule_02.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that verifies the content of the keyframes rule and property changes +// to keyframe rules. + +const TEST_URI = URL_ROOT + "doc_keyframeanimation.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + await testPacman(inspector, view); + await testBoxy(inspector, view); +}); + +async function testPacman(inspector, view) { + info("Test content in the keyframes rule of #pacman"); + + const rules = await getKeyframeRules("#pacman", inspector, view); + + info("Test text properties for Keyframes #pacman"); + + is( + convertTextPropsToString(rules.keyframeRules[0].textProps), + "left: 750px", + "Keyframe pacman (100%) property is correct" + ); + + // Dynamic changes test disabled because of Bug 1050940 + // If this part of the test is ever enabled again, it should be changed to + // use addProperty (in head.js) and stop using _applyingModifications + + // info("Test dynamic changes to keyframe rule for #pacman"); + + // let defaultView = element.ownerDocument.defaultView; + // let ruleEditor = view.element.children[5].childNodes[0]._ruleEditor; + // ruleEditor.addProperty("opacity", "0", true); + + // yield ruleEditor._applyingModifications; + // yield once(element, "animationend"); + + // is + // ( + // convertTextPropsToString(rules.keyframeRules[1].textProps), + // "left: 750px; opacity: 0", + // "Keyframe pacman (100%) property is correct" + // ); + + // is(defaultView.getComputedStyle(element).getPropertyValue("opacity"), "0", + // "Added opacity property should have been used."); +} + +async function testBoxy(inspector, view) { + info("Test content in the keyframes rule of #boxy"); + + const rules = await getKeyframeRules("#boxy", inspector, view); + + info("Test text properties for Keyframes #boxy"); + + is( + convertTextPropsToString(rules.keyframeRules[0].textProps), + "background-color: blue", + "Keyframe boxy (10%) property is correct" + ); + + is( + convertTextPropsToString(rules.keyframeRules[1].textProps), + "background-color: green", + "Keyframe boxy (20%) property is correct" + ); + + is( + convertTextPropsToString(rules.keyframeRules[2].textProps), + "opacity: 0", + "Keyframe boxy (100%) property is correct" + ); +} + +function convertTextPropsToString(textProps) { + return textProps.map(t => t.name + ": " + t.value).join("; "); +} + +async function getKeyframeRules(selector, inspector, view) { + await selectNode(selector, inspector); + const elementStyle = view._elementStyle; + + const rules = { + elementRules: elementStyle.rules.filter(rule => !rule.keyframes), + keyframeRules: elementStyle.rules.filter(rule => rule.keyframes), + }; + + return rules; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_large_base64_background_image.js b/devtools/client/inspector/rules/test/browser_rules_large_base64_background_image.js new file mode 100644 index 0000000000..eb4bd1e050 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_large_base64_background_image.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// 1px red dot +const shortDataUrl = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P4z8DwHwAFAAH/F1FwBgAAAABJRU5ErkJggg=="; + +// Not a valid base64 image, but will still generate a long text property +const longDataUrl = new Array(1000 * 1000).join("a"); + +const TEST_STYLESHEET = ` +body { + background-image: url(data:image/png;base64,${shortDataUrl}); + background-image: url(data:image/png;base64,${longDataUrl}); +}`; + +// Serve the stylesheet dynamically from a test HTTPServer to avoid logging an +// extremely long data-url when adding the tab using our usual test helpers. +const server = createTestHTTPServer(); +const filepath = "/style.css"; +const cssuri = `http://localhost:${server.identity.primaryPort}${filepath}`; +server.registerContentType("css", "text/css"); +server.registerPathHandler(filepath, (metadata, response) => { + response.write(TEST_STYLESHEET); +}); + +const TEST_URL = + "data:text/html," + + encodeURIComponent(` + <!DOCTYPE html> + <html> + <head> + <link href="${cssuri}" rel="stylesheet" /> + </head> + <body></body> + </html> +`); + +// Check that long URLs are rendered correctly in the rule view. +add_task(async function () { + const { inspector } = await openInspectorForURL(TEST_URL); + const view = selectRuleView(inspector); + + await selectNode("body", inspector); + + const propertyValues = view.styleDocument.querySelectorAll( + ".ruleview-propertyvalue" + ); + + is(propertyValues.length, 2, "Ruleview has 2 propertyvalue elements"); + ok( + propertyValues[0].textContent.startsWith("url(data:image/png"), + "Property value for the background image was correctly rendered" + ); + + ok( + !propertyValues[0].querySelector(".propertyvalue-long-text"), + "The first background-image is short enough and does not need to be truncated" + ); + ok( + !!propertyValues[1].querySelector(".propertyvalue-long-text"), + "The second background-image has a special CSS class to be truncated" + ); + const ruleviewContainer = + view.styleDocument.getElementById("ruleview-container"); + ok( + ruleviewContainer.scrollHeight === ruleviewContainer.clientHeight, + "The ruleview container does not have a scrollbar" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_layer.js b/devtools/client/inspector/rules/test/browser_rules_layer.js new file mode 100644 index 0000000000..9db3577f20 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_layer.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the rule-view content is correct when the page defines layers. + +const TEST_URI = ` + <style type="text/css"> + @import url(${URL_ROOT_COM_SSL}doc_imported_anonymous_layer.css) layer; + @import url(${URL_ROOT_COM_SSL}doc_imported_named_layer.css) layer(importedLayer); + @import url(${URL_ROOT_COM_SSL}doc_imported_no_layer.css); + + @layer myLayer { + h1, [test-hint=named-layer] { + background-color: tomato; + color: lightgreen; + } + } + + @layer { + h1, [test-hint=anonymous-layer] { + color: green; + font-variant: small-caps + } + } + + h1, [test-hint=no-rule-layer] { + color: pink; + } + </style> + <h1>Hello @layer!</h1> +`; + +add_task(async function () { + await addTab( + "https://example.com/document-builder.sjs?html=" + + encodeURIComponent(TEST_URI) + ); + const { inspector, view } = await openRuleView(); + + await selectNode("h1", inspector); + + const expectedRules = [ + { selector: "element", ancestorRulesData: null }, + { selector: `h1, [test-hint="no-rule-layer"]`, ancestorRulesData: null }, + { + selector: `h1, [test-hint="imported-no-layer--no-rule-layer"]`, + ancestorRulesData: null, + }, + { + selector: `h1, [test-hint="anonymous-layer"]`, + ancestorRulesData: ["@layer"], + }, + { + selector: `h1, [test-hint="named-layer"]`, + ancestorRulesData: ["@layer myLayer"], + }, + { + selector: `h1, [test-hint="imported-named-layer--no-rule-layer"]`, + ancestorRulesData: ["@layer importedLayer", "@media screen"], + }, + { + selector: `h1, [test-hint="imported-named-layer--named-layer"]`, + ancestorRulesData: [ + "@layer importedLayer", + "@media screen", + "@layer in-imported-stylesheet", + ], + }, + { + selector: `h1, [test-hint="imported-anonymous-layer--no-rule-layer"]`, + ancestorRulesData: ["@layer"], + }, + ]; + + const rulesInView = Array.from(view.element.children); + is( + rulesInView.length, + expectedRules.length, + "All expected rules are displayed" + ); + + for (let i = 0; i < expectedRules.length; i++) { + const expectedRule = expectedRules[i]; + info(`Checking rule #${i}: ${expectedRule.selector}`); + + const selector = rulesInView[i].querySelector( + ".ruleview-selectorcontainer" + ).innerText; + is(selector, expectedRule.selector, `Expected selector for ${selector}`); + + if (expectedRule.ancestorRulesData == null) { + is( + getRuleViewAncestorRulesDataElementByIndex(view, i), + null, + `No ancestor rules data displayed for ${selector}` + ); + } else { + is( + getRuleViewAncestorRulesDataTextByIndex(view, i), + expectedRule.ancestorRulesData.join("\n"), + `Expected ancestor rules data displayed for ${selector}` + ); + } + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_lineNumbers.js b/devtools/client/inspector/rules/test/browser_rules_lineNumbers.js new file mode 100644 index 0000000000..9c9d1ef9ab --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_lineNumbers.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that editing a rule will update the line numbers of subsequent +// rules in the rule view. + +const TESTCASE_URI = URL_ROOT + "doc_ruleLineNumbers.html"; + +add_task(async function () { + await addTab(TESTCASE_URI); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const bodyRuleEditor = getRuleViewRuleEditor(view, 3); + const value = getRuleViewLinkTextByIndex(view, 2); + // Note that this is relative to the <style>. + is(value.slice(-2), ":6", "initial rule line number is 6"); + + const onLocationChanged = once( + bodyRuleEditor.rule.domRule, + "location-changed" + ); + await addProperty(view, 1, "font-size", "23px"); + await onLocationChanged; + + const newBodyTitle = getRuleViewLinkTextByIndex(view, 2); + // Note that this is relative to the <style>. + is(newBodyTitle.slice(-2), ":7", "updated rule line number is 7"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_linear-easing-swatch.js b/devtools/client/inspector/rules/test/browser_rules_linear-easing-swatch.js new file mode 100644 index 0000000000..3739e6ff9c --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_linear-easing-swatch.js @@ -0,0 +1,487 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that linear easing widget pickers appear when clicking on linear +// swatches. + +const TEST_URI = ` + <style type="text/css"> + div { + animation: move 3s linear(0, 0.2, 1); + transition: top 4s linear(0 10%, 0.5 20% 80%, 0 90%); + } + .test { + animation-timing-function: linear(0, 1 50% 100%); + transition-timing-function: linear(1 -10%, 0, -1 110%); + } + </style> + <div class="test">Testing the linear easing tooltip!</div> +`; + +add_task(async function testSwatches() { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + const tooltip = view.tooltips.getTooltip("linearEaseFunction"); + ok(tooltip, "The rule-view has the expected linear tooltip"); + const panel = tooltip.tooltip.panel; + ok(panel, "The XUL panel for the linear tooltip exists"); + + const expectedData = [ + { + selector: "div", + property: "animation", + expectedPoints: [ + [0, 0], + [0.5, 0.2], + [1, 1], + ], + }, + { + selector: "div", + property: "transition", + expectedPoints: [ + [0.1, 0], + [0.2, 0.5], + [0.8, 0.5], + [0.9, 0], + ], + }, + { + selector: ".test", + property: "animation-timing-function", + expectedPoints: [ + [0, 0], + [0.5, 1], + [1, 1], + ], + }, + { + selector: ".test", + property: "transition-timing-function", + expectedPoints: [ + [-0.1, 1], + [0.5, 0], + [1.1, -1], + ], + }, + ]; + + for (const { selector, property, expectedPoints } of expectedData) { + const messagePrefix = `[${selector}|${property}]`; + const swatch = getRuleViewLinearEasingSwatch(view, selector, property); + ok(swatch, `${messagePrefix} the swatch exists`); + + const onWidgetReady = tooltip.once("ready"); + swatch.click(); + await onWidgetReady; + ok(true, `${messagePrefix} clicking the swatch displayed the tooltip`); + + ok( + !inplaceEditor(swatch.parentNode), + `${messagePrefix} inplace editor wasn't shown` + ); + + checkChartState(panel, expectedPoints); + + await hideTooltipAndWaitForRuleViewChanged(tooltip, view); + } +}); + +add_task(async function testChart() { + await pushPref("ui.prefersReducedMotion", 0); + + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + const tooltip = view.tooltips.getTooltip("linearEaseFunction"); + ok(tooltip, "The rule-view has the expected linear tooltip"); + const panel = tooltip.tooltip.panel; + ok(panel, "The XUL panel for the linear tooltip exists"); + + const selector = ".test"; + const property = "animation-timing-function"; + + const swatch = getRuleViewLinearEasingSwatch(view, selector, property); + const onWidgetReady = tooltip.once("ready"); + swatch.click(); + await onWidgetReady; + const widget = await tooltip.widget; + + const svgEl = panel.querySelector(`svg.chart`); + const svgRect = svgEl.getBoundingClientRect(); + + checkChartState( + panel, + [ + [0, 0], + [0.5, 1], + [1, 1], + ], + "testChart - initial state:" + ); + + info("Check that double clicking a point removes it"); + const middlePoint = panel.querySelector( + `svg.chart .control-points-group .control-point:nth-of-type(2)` + ); + let onWidgetUpdated = widget.once("updated"); + let onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.sendMouseEvent( + { type: "dblclick" }, + middlePoint, + widget.parent.ownerGlobal + ); + + let newValue = await onWidgetUpdated; + is(newValue, `linear(0 0%, 1 100%)`); + checkChartState( + panel, + [ + [0, 0], + [1, 1], + ], + "testChart - after point removed:" + ); + await onRuleViewChanged; + await checkRuleView(view, selector, property, newValue); + + info( + "Check that double clicking a point when there are only 2 points on the line does not remove it" + ); + const timeoutRes = Symbol(); + let onTimeout = wait(1000).then(() => timeoutRes); + onWidgetUpdated = widget.once("updated"); + EventUtils.sendMouseEvent( + { type: "dblclick" }, + panel.querySelector(`svg.chart .control-points-group .control-point`), + widget.parent.ownerGlobal + ); + let raceWinner = await Promise.race([onWidgetUpdated, onTimeout]); + is( + raceWinner, + timeoutRes, + "The widget wasn't updated after double clicking one of the 2 last points" + ); + checkChartState( + panel, + [ + [0, 0], + [1, 1], + ], + "testChart - no point removed:" + ); + + info("Check that double clicking on the svg does add a point"); + onWidgetUpdated = widget.once("updated"); + onRuleViewChanged = view.once("ruleview-changed"); + // Clicking on svg center with shiftKey so it snaps to the grid + EventUtils.synthesizeMouseAtCenter( + panel.querySelector(`svg.chart`), + { clickCount: 2, shiftKey: true }, + widget.parent.ownerGlobal + ); + + newValue = await onWidgetUpdated; + is( + newValue, + `linear(0 0%, 0.5 50%, 1 100%)`, + "Widget was updated with expected value" + ); + checkChartState( + panel, + [ + [0, 0], + [0.5, 0.5], + [1, 1], + ], + "testChart - new point added" + ); + await onRuleViewChanged; + await checkRuleView(view, selector, property, newValue); + + info("Check that points can be moved"); + onWidgetUpdated = widget.once("updated"); + onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.synthesizeMouseAtCenter( + panel.querySelector(`svg.chart .control-points-group .control-point`), + { type: "mousedown" }, + widget.parent.ownerGlobal + ); + + EventUtils.synthesizeMouse( + svgEl, + svgRect.width / 3, + svgRect.height / 3, + { type: "mousemove", shiftKey: true }, + widget.parent.ownerGlobal + ); + newValue = await onWidgetUpdated; + is( + newValue, + `linear(0.7 30%, 0.5 50%, 1 100%)`, + "Widget was updated with expected value" + ); + checkChartState( + panel, + [ + [0.3, 0.7], + [0.5, 0.5], + [1, 1], + ], + "testChart - point moved" + ); + await onRuleViewChanged; + await checkRuleView(view, selector, property, newValue); + + info("Check that the points can be moved past the next point"); + onWidgetUpdated = widget.once("updated"); + onRuleViewChanged = view.once("ruleview-changed"); + + // the second point is at 50%, so simulate a mousemove all the way to the right (which + // should be ~100%) + EventUtils.synthesizeMouse( + svgEl, + svgRect.width, + svgRect.height / 3, + { type: "mousemove", shiftKey: true }, + widget.parent.ownerGlobal + ); + newValue = await onWidgetUpdated; + is( + newValue, + `linear(0.7 50%, 0.5 50%, 1 100%)`, + "point wasn't moved past the next point (50%)" + ); + checkChartState( + panel, + [ + [0.5, 0.7], + [0.5, 0.5], + [1, 1], + ], + "testChart - point moved constrained by next point" + ); + await onRuleViewChanged; + await checkRuleView(view, selector, property, newValue); + + info("Stop dragging"); + EventUtils.synthesizeMouseAtCenter( + svgEl, + { type: "mouseup" }, + widget.parent.ownerGlobal + ); + + onTimeout = wait(1000).then(() => timeoutRes); + onWidgetUpdated = widget.once("updated"); + EventUtils.synthesizeMouseAtCenter( + svgEl, + { type: "mousemove" }, + widget.parent.ownerGlobal + ); + raceWinner = await Promise.race([onWidgetUpdated, onTimeout]); + is(raceWinner, timeoutRes, "Dragging is disabled after mouseup"); + + info("Add another point, which should be the first one for the line"); + onWidgetUpdated = widget.once("updated"); + onRuleViewChanged = view.once("ruleview-changed"); + + EventUtils.synthesizeMouse( + svgEl, + svgRect.width / 3, + svgRect.height - 1, + { clickCount: 2, shiftKey: true }, + widget.parent.ownerGlobal + ); + + newValue = await onWidgetUpdated; + is( + newValue, + `linear(0 30%, 0.7 50%, 0.5 50%, 1 100%)`, + "Widget was updated with expected value" + ); + checkChartState( + panel, + [ + [0.3, 0], + [0.5, 0.7], + [0.5, 0.5], + [1, 1], + ], + "testChart - point added at beginning" + ); + await onRuleViewChanged; + await checkRuleView(view, selector, property, newValue); + + info("Check that the points can't be moved past previous point"); + onWidgetUpdated = widget.once("updated"); + onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.synthesizeMouseAtCenter( + panel.querySelector( + `svg.chart .control-points-group .control-point:nth-of-type(2)` + ), + { type: "mousedown" }, + widget.parent.ownerGlobal + ); + + EventUtils.synthesizeMouse( + svgEl, + 0, + svgRect.height / 3, + { type: "mousemove", shiftKey: true }, + widget.parent.ownerGlobal + ); + newValue = await onWidgetUpdated; + is( + newValue, + `linear(0 30%, 0.7 30%, 0.5 50%, 1 100%)`, + "point wasn't moved past previous point (30%)" + ); + checkChartState( + panel, + [ + [0.3, 0], + [0.3, 0.7], + [0.5, 0.5], + [1, 1], + ], + "testChart - point moved constrained by previous point" + ); + await onRuleViewChanged; + await checkRuleView(view, selector, property, newValue); + + info("Stop dragging"); + EventUtils.synthesizeMouseAtCenter( + svgEl, + { type: "mouseup" }, + widget.parent.ownerGlobal + ); + + info( + "Check that timing preview is destroyed if prefers-reduced-motion gets enabled" + ); + const getTimingFunctionPreview = () => + panel.querySelector(".timing-function-preview"); + ok(getTimingFunctionPreview(), "By default, timing preview is visible"); + info("Enable prefersReducedMotion"); + await pushPref("ui.prefersReducedMotion", 1); + await waitFor(() => !getTimingFunctionPreview()); + ok(true, "timing preview was removed after enabling prefersReducedMotion"); + + info("Disable prefersReducedMotion"); + await pushPref("ui.prefersReducedMotion", 0); + await waitFor(() => getTimingFunctionPreview()); + ok( + true, + "timing preview was added back after disabling prefersReducedMotion" + ); + + info("Hide tooltip with escape to cancel modification"); + const onHidden = tooltip.tooltip.once("hidden"); + const onModifications = view.once("ruleview-changed"); + focusAndSendKey(widget.parent.ownerDocument.defaultView, "ESCAPE"); + await onHidden; + await onModifications; + + await checkRuleView( + view, + selector, + property, + "linear(0, 1 50% 100%)", + "linear(0 0%, 1 50%, 1 100%)" + ); +}); + +/** + * Check that the svg chart line and control points are placed where we expect them. + * + * @param {ToolipPanel} panel + * @param {Array<Array<Number>>} expectedPoints: Array of coordinated + * @param {String} messagePrefix + */ +function checkChartState(panel, expectedPoints, messagePrefix = "") { + const svgLine = panel.querySelector("svg.chart .chart-linear"); + is( + svgLine.getAttribute("points"), + expectedPoints.map(([x, y]) => `${x},${1 - y}`).join(" "), + `${messagePrefix} line has the expected points` + ); + + const controlPoints = panel.querySelectorAll( + `svg.chart .control-points-group .control-point` + ); + + is( + controlPoints.length, + expectedPoints.length, + `${messagePrefix} the expected number of control points were created` + ); + controlPoints.forEach((controlPoint, i) => { + is( + parseFloat(controlPoint.getAttribute("cx")), + expectedPoints[i][0], + `${messagePrefix} Control point ${i} has correct cx` + ); + is( + parseFloat(controlPoint.getAttribute("cy")), + // XXX work around floating point issues + Math.round((1 - expectedPoints[i][1]) * 10) / 10, + `${messagePrefix} Control point ${i} has correct cy` + ); + }); +} + +/** + * Checks if the property in the rule view has the expected state + * + * @param {RuleView} view + * @param {String} selector + * @param {String} property + * @param {String} expectedLinearValue: Expected value in the rule view + * @param {String} expectedComputedLinearValue: Expected computed value. Defaults to expectedLinearValue. + * @returns {Element|null} + */ +async function checkRuleView( + view, + selector, + property, + expectedLinearValue, + expectedComputedLinearValue = expectedLinearValue +) { + await waitForComputedStyleProperty( + selector, + null, + property, + expectedComputedLinearValue + ); + + is( + getRuleViewProperty(view, selector, property).valueSpan.textContent, + expectedLinearValue, + `${selector} ${property} has expected value` + ); + const swatch = getRuleViewLinearEasingSwatch(view, selector, property); + is( + swatch.getAttribute("data-linear"), + expectedLinearValue, + `${selector} ${property} swatch has expected "data-linear" attribute` + ); +} + +/** + * Returns the linear easing swatch for a rule (defined by its selector), and a property. + * + * @param {RuleView} view + * @param {String} selector + * @param {String} property + * @returns {Element|null} + */ +function getRuleViewLinearEasingSwatch(view, selector, property) { + return getRuleViewProperty(view, selector, property).valueSpan.querySelector( + ".ruleview-lineareasingswatch" + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_livepreview.js b/devtools/client/inspector/rules/test/browser_rules_livepreview.js new file mode 100644 index 0000000000..38ad5ee1d0 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_livepreview.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that changes are previewed when editing a property value. + +const TEST_URI = ` + <style type="text/css"> + #testid { + display:block; + } + </style> + <div id="testid">Styled Node</div><span>inline element</span> +`; + +// Format +// { +// value : what to type in the field +// expected : expected computed style on the targeted element +// } +const TEST_DATA = [ + { value: "inline", expected: "inline" }, + { value: "inline-block", expected: "inline-block" }, + + // Invalid property values should not apply, and should fall back to default + { value: "red", expected: "block" }, + { value: "something", expected: "block" }, + + { escape: true, value: "inline", expected: "block" }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + for (const data of TEST_DATA) { + await testLivePreviewData(data, view, "#testid"); + } +}); + +async function testLivePreviewData(data, ruleView, selector) { + const rule = getRuleViewRuleEditor(ruleView, 1).rule; + const propEditor = rule.textProps[0].editor; + + info("Focusing the property value inplace-editor"); + const editor = await focusEditableField(ruleView, propEditor.valueSpan); + is( + inplaceEditor(propEditor.valueSpan), + editor, + "The focused editor is the value" + ); + + info("Entering value in the editor: " + data.value); + const onPreviewDone = ruleView.once("ruleview-changed"); + EventUtils.sendString(data.value, ruleView.styleWindow); + ruleView.debounce.flush(); + await onPreviewDone; + + const onValueDone = ruleView.once("ruleview-changed"); + if (data.escape) { + EventUtils.synthesizeKey("KEY_Escape"); + } else { + EventUtils.synthesizeKey("KEY_Enter"); + } + await onValueDone; + + // While the editor is still focused in, the display should have + // changed already + is( + await getComputedStyleProperty(selector, null, "display"), + data.expected, + "Element should be previewed as " + data.expected + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_mark_overridden_01.js b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_01.js new file mode 100644 index 0000000000..4ac9ea3498 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_01.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view marks overridden rules correctly based on the +// specificity of the rule. + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: blue; + } + .testclass { + background-color: green; + } + </style> + <div id='testid' class='testclass'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const idProp = getTextProperty(view, 1, { "background-color": "blue" }); + ok(!idProp.overridden, "ID prop should not be overridden."); + ok( + !idProp.editor.element.classList.contains("ruleview-overridden"), + "ID property editor should not have ruleview-overridden class" + ); + + const classProp = getTextProperty(view, 2, { "background-color": "green" }); + ok(classProp.overridden, "Class property should be overridden."); + ok( + classProp.editor.element.classList.contains("ruleview-overridden"), + "Class property editor should have ruleview-overridden class" + ); + + // Override background-color by changing the element style. + const elementProp = await addProperty(view, 0, "background-color", "purple"); + + ok( + !elementProp.overridden, + "Element style property should not be overridden" + ); + ok(idProp.overridden, "ID property should be overridden"); + ok( + idProp.editor.element.classList.contains("ruleview-overridden"), + "ID property editor should have ruleview-overridden class" + ); + ok(classProp.overridden, "Class property should be overridden"); + ok( + classProp.editor.element.classList.contains("ruleview-overridden"), + "Class property editor should have ruleview-overridden class" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_mark_overridden_02.js b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_02.js new file mode 100644 index 0000000000..283419def9 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_02.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view marks overridden rules correctly for short hand +// properties and the computed list properties + +const TEST_URI = ` + <style type='text/css'> + #testid { + margin-left: 1px; + } + .testclass { + margin: 2px; + } + </style> + <div id='testid' class='testclass'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testMarkOverridden(inspector, view); +}); + +function testMarkOverridden(inspector, view) { + const elementStyle = view._elementStyle; + + const classRule = elementStyle.rules[2]; + const classProp = classRule.textProps[0]; + ok( + !classProp.overridden, + "Class prop shouldn't be overridden, some props are still being used." + ); + + for (const computed of classProp.computed) { + if (computed.name.indexOf("margin-left") == 0) { + ok(computed.overridden, "margin-left props should be overridden."); + } else { + ok( + !computed.overridden, + "Non-margin-left props should not be overridden." + ); + } + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_mark_overridden_03.js b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_03.js new file mode 100644 index 0000000000..9acb4ae8cd --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_03.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view marks overridden rules correctly based on the +// priority for the rule + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: blue; + } + .testclass { + background-color: green !important; + } + </style> + <div id='testid' class='testclass'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const idProp = getTextProperty(view, 1, { "background-color": "blue" }); + ok(idProp.overridden, "Not-important rule should be overridden."); + + const classProp = getTextProperty(view, 2, { "background-color": "green" }); + ok(!classProp.overridden, "Important rule should not be overridden."); + + ok(idProp.overridden, "ID property should be overridden."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_mark_overridden_04.js b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_04.js new file mode 100644 index 0000000000..5a3de5b3fa --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_04.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view marks overridden rules correctly if a property gets +// disabled + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: blue; + } + .testclass { + background-color: green; + } + </style> + <div id='testid' class='testclass'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const idProp = getTextProperty(view, 1, { "background-color": "blue" }); + + await togglePropStatus(view, idProp); + const classProp = getTextProperty(view, 2, { "background-color": "green" }); + ok( + !classProp.overridden, + "Class prop should not be overridden after id prop was disabled." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_mark_overridden_05.js b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_05.js new file mode 100644 index 0000000000..892ebaa955 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_05.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view marks overridden rules correctly based on the +// order of the property. + +const TEST_URI = ` + <style type='text/css'> + #testid { + background-color: green; + } + </style> + <div id='testid' class='testclass'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + await addProperty(view, 1, "background-color", "red"); + + const firstProp = getTextProperty(view, 1, { "background-color": "green" }); + const secondProp = getTextProperty(view, 1, { "background-color": "red" }); + + ok(firstProp.overridden, "First property should be overridden."); + ok(!secondProp.overridden, "Second property should not be overridden."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_mark_overridden_06.js b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_06.js new file mode 100644 index 0000000000..7ae4e77a02 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_06.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view marks overridden rules correctly after +// editing the selector. + +const TEST_URI = ` + <style type='text/css'> + div { + background-color: blue; + background-color: chartreuse; + } + </style> + <div id='testid' class='testclass'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testMarkOverridden(inspector, view); +}); + +async function testMarkOverridden(inspector, view) { + const elementStyle = view._elementStyle; + const rule = elementStyle.rules[1]; + checkProperties(rule); + + const ruleEditor = getRuleViewRuleEditor(view, 1); + info("Focusing an existing selector name in the rule-view"); + const editor = await focusEditableField(view, ruleEditor.selectorText); + + info("Entering a new selector name and committing"); + editor.input.value = "div[class]"; + + const onRuleViewChanged = once(view, "ruleview-changed"); + info("Entering the commit key"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + view.searchField.focus(); + checkProperties(rule); +} + +// A helper to perform a repeated set of checks. +function checkProperties(rule) { + let prop = rule.textProps[0]; + is( + prop.name, + "background-color", + "First property should be background-color" + ); + is(prop.value, "blue", "First property value should be blue"); + ok(prop.overridden, "prop should be overridden."); + prop = rule.textProps[1]; + is( + prop.name, + "background-color", + "Second property should be background-color" + ); + is(prop.value, "chartreuse", "First property value should be chartreuse"); + ok(!prop.overridden, "prop should not be overridden."); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_mark_overridden_07.js b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_07.js new file mode 100644 index 0000000000..bdfe34a307 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_07.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view marks overridden rules correctly based on the +// specificity of the rule. + +const TEST_URI = ` + <style type='text/css'> + #testid { + margin-left: 23px; + } + + div { + margin-right: 23px; + margin-left: 1px !important; + } + + body { + color: blue; + } + + body { + margin-right: 1px !important; + font-size: 79px; + line-height: 100px !important; + color: green !important; + } + + body { + color: red; + } + + span { + font-size: 12px; + line-height: 10px; + } + </style> + <body> + <span> + <div id='testid' class='testclass'>Styled Node</div> + </span> + </body> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + testMarkOverridden(inspector, view); +}); + +function testMarkOverridden(inspector, view) { + const elementStyle = view._elementStyle; + + const RESULTS = [ + // We skip the first element + [], + [{ name: "margin-left", value: "23px", overridden: true }], + [ + { name: "margin-right", value: "23px", overridden: false }, + { name: "margin-left", value: "1px", overridden: false }, + ], + [ + { name: "font-size", value: "12px", overridden: false }, + { name: "line-height", value: "10px", overridden: false }, + ], + [{ name: "color", value: "red", overridden: true }], + [ + { name: "margin-right", value: "1px", overridden: true }, + { name: "font-size", value: "79px", overridden: true }, + { name: "line-height", value: "100px", overridden: true }, + { name: "color", value: "green", overridden: false }, + ], + [{ name: "color", value: "blue", overridden: true }], + ]; + + for (let i = 1; i < RESULTS.length; ++i) { + const idRule = elementStyle.rules[i]; + + for (const propIndex in RESULTS[i]) { + const expected = RESULTS[i][propIndex]; + const prop = idRule.textProps[propIndex]; + + info("Checking rule " + i + ", property " + propIndex); + + is(prop.name, expected.name, "check property name"); + is(prop.value, expected.value, "check property value"); + is(prop.overridden, expected.overridden, "check property overridden"); + } + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_mark_overridden_08.js b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_08.js new file mode 100644 index 0000000000..444c87cbd7 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_mark_overridden_08.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view marks overridden rules correctly in pseudo-elements when +// selecting their host node. + +const TEST_URI = ` + <style type='text/css'> + #testid::before { + content: 'Pseudo-element'; + color: red; + color: green; + } + #testid { + color: blue; + } + </style> + <div id='testid'>Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + info( + "Check the CSS declarations for ::before in the Pseudo-elements accordion." + ); + const pseudoRule = getRuleViewRuleEditor(view, 1, 0).rule; + const pseudoProp1 = pseudoRule.textProps[1]; + const pseudoProp2 = pseudoRule.textProps[2]; + ok( + pseudoProp1.overridden, + "First declaration of color in pseudo-element should be overridden." + ); + ok( + !pseudoProp2.overridden, + "Second declaration of color in pseudo-element should not be overridden." + ); + + info( + "Check that pseudo-element declarations do not override the host's declarations" + ); + const idProp = getTextProperty(view, 4, { color: "blue" }); + ok( + !idProp.overridden, + "The single declaration of color in ID selector should not be overridden" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_mathml-element.js b/devtools/client/inspector/rules/test/browser_rules_mathml-element.js new file mode 100644 index 0000000000..275426b105 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_mathml-element.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule-view displays correctly on MathML elements. + +const TEST_URI = ` + <div> + <math xmlns=\http://www.w3.org/1998/Math/MathML\> + <mfrac> + <msubsup> + <mi>a</mi> + <mi>i</mi> + <mi>j</mi> + </msubsup> + <msub> + <mi>x</mi> + <mn>0</mn> + </msub> + </mfrac> + </math> + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Select the DIV node and verify the rule-view shows rules"); + await selectNode("div", inspector); + ok( + view.element.querySelectorAll(".ruleview-rule").length, + "The rule-view shows rules for the div element" + ); + + info("Select various MathML nodes and verify the rule-view is empty"); + await selectNode("math", inspector); + ok( + !view.element.querySelectorAll(".ruleview-rule").length, + "The rule-view is empty for the math element" + ); + + await selectNode("msubsup", inspector); + ok( + !view.element.querySelectorAll(".ruleview-rule").length, + "The rule-view is empty for the msubsup element" + ); + + await selectNode("mn", inspector); + ok( + !view.element.querySelectorAll(".ruleview-rule").length, + "The rule-view is empty for the mn element" + ); + + info("Select again the DIV node and verify the rule-view shows rules"); + await selectNode("div", inspector); + ok( + view.element.querySelectorAll(".ruleview-rule").length, + "The rule-view shows rules for the div element" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_media-queries.js b/devtools/client/inspector/rules/test/browser_rules_media-queries.js new file mode 100644 index 0000000000..09be8da801 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_media-queries.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that we correctly display appropriate media query information in the rule view. + +const TEST_URI = URL_ROOT + "doc_media_queries.html?constructed"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + const elementStyle = view._elementStyle; + + const inline = STYLE_INSPECTOR_L10N.getStr("rule.sourceInline"); + const constructed = STYLE_INSPECTOR_L10N.getStr("rule.sourceConstructed"); + + is(elementStyle.rules.length, 4, "Should have 4 rules."); + is(elementStyle.rules[0].title, inline, "check rule 0 title"); + is( + elementStyle.rules[1].title, + constructed, + "check constracted sheet rule title" + ); + is(elementStyle.rules[2].title, inline + ":9", "check rule 2 title"); + is(elementStyle.rules[3].title, inline + ":2", "check rule 3 title"); + + is( + getRuleViewAncestorRulesDataTextByIndex(view, 2), + "@media screen and (min-width: 1px)", + "Media queries information are displayed" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_media-queries_reload.js b/devtools/client/inspector/rules/test/browser_rules_media-queries_reload.js new file mode 100644 index 0000000000..79bc9b9f8f --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_media-queries_reload.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that applicable media queries are updated in the Rule view after reloading +// the page and resizing the window. + +const TEST_URI = ` + <style type='text/css'> + @media all and (max-width: 500px) { + div { + color: red; + } + } + @media all and (min-width: 500px) { + div { + color: green; + } + } + </style> + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view: ruleView, toolbox } = await openRuleView(); + const hostWindow = toolbox.win.parent; + + const originalWidth = hostWindow.outerWidth; + const originalHeight = hostWindow.outerHeight; + + await selectNode("div", inspector); + + info("Resize window so the media query for small viewports applies"); + hostWindow.resizeTo(400, 400); + + await waitForMediaRuleColor(ruleView, "red"); + ok(true, "Small viewport media query inspected"); + + info("Reload the current page"); + await reloadBrowser(); + await selectNode("div", inspector); + + info("Resize window so the media query for large viewports applies"); + hostWindow.resizeTo(800, 800); + + info("Reselect the rule after page reload."); + await waitForMediaRuleColor(ruleView, "green"); + ok(true, "Large viewport media query inspected"); + + info("Resize window to original dimentions"); + const onResize = once(hostWindow, "resize"); + hostWindow.resizeTo(originalWidth, originalHeight); + await onResize; +}); + +function waitForMediaRuleColor(ruleView, color) { + return waitUntil(() => { + try { + const { value } = getTextProperty(ruleView, 1, { color }); + return value === color; + } catch (e) { + return false; + } + }); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_multiple-properties-duplicates.js b/devtools/client/inspector/rules/test/browser_rules_multiple-properties-duplicates.js new file mode 100644 index 0000000000..2eb93e4b70 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_multiple-properties-duplicates.js @@ -0,0 +1,117 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the rule-view behaves correctly when entering mutliple and/or +// unfinished properties/values in inplace-editors + +const TEST_URI = "<div>Test Element</div>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + const ruleEditor = getRuleViewRuleEditor(view, 0); + // Note that we wait for a markup mutation here because this new rule will end + // up creating a style attribute on the node shown in the markup-view. + // (we also wait for the rule-view to refresh). + const onMutation = inspector.once("markupmutation"); + const onRuleViewChanged = view.once("ruleview-changed"); + await createNewRuleViewProperty( + ruleEditor, + "color:red;color:orange;color:yellow;color:green;color:blue;color:indigo;" + + "color:violet;" + ); + await onMutation; + await onRuleViewChanged; + + is( + ruleEditor.rule.textProps.length, + 7, + "Should have created new text properties." + ); + is( + ruleEditor.propertyList.children.length, + 8, + "Should have created new property editors." + ); + + is( + getTextProperty(view, 0, { color: "red" }).name, + "color", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { color: "red" }).value, + "red", + "Should have correct property value" + ); + + is( + getTextProperty(view, 0, { color: "orange" }).name, + "color", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { color: "orange" }).value, + "orange", + "Should have correct property value" + ); + + is( + getTextProperty(view, 0, { color: "yellow" }).name, + "color", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { color: "yellow" }).value, + "yellow", + "Should have correct property value" + ); + + is( + getTextProperty(view, 0, { color: "green" }).name, + "color", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { color: "green" }).value, + "green", + "Should have correct property value" + ); + + is( + getTextProperty(view, 0, { color: "blue" }).name, + "color", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { color: "blue" }).value, + "blue", + "Should have correct property value" + ); + + is( + getTextProperty(view, 0, { color: "indigo" }).name, + "color", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { color: "indigo" }).value, + "indigo", + "Should have correct property value" + ); + + is( + getTextProperty(view, 0, { color: "violet" }).name, + "color", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { color: "violet" }).value, + "violet", + "Should have correct property value" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_multiple-properties-priority.js b/devtools/client/inspector/rules/test/browser_rules_multiple-properties-priority.js new file mode 100644 index 0000000000..f7713529b5 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_multiple-properties-priority.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the rule-view behaves correctly when entering mutliple and/or +// unfinished properties/values in inplace-editors. + +const TEST_URI = "<div>Test Element</div>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + const ruleEditor = getRuleViewRuleEditor(view, 0); + // Note that we wait for a markup mutation here because this new rule will end + // up creating a style attribute on the node shown in the markup-view. + // (we also wait for the rule-view to refresh). + const onMutation = inspector.once("markupmutation"); + const onRuleViewChanged = view.once("ruleview-changed"); + await createNewRuleViewProperty( + ruleEditor, + "color:red;width:100px;height: 100px;" + ); + await onMutation; + await onRuleViewChanged; + + is( + ruleEditor.rule.textProps.length, + 3, + "Should have created new text properties." + ); + is( + ruleEditor.propertyList.children.length, + 4, + "Should have created new property editors." + ); + + is( + getTextProperty(view, 0, { color: "red" }).name, + "color", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { color: "red" }).value, + "red", + "Should have correct property value" + ); + + is( + getTextProperty(view, 0, { width: "100px" }).name, + "width", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { width: "100px" }).value, + "100px", + "Should have correct property value" + ); + + is( + getTextProperty(view, 0, { height: "100px" }).name, + "height", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { height: "100px" }).value, + "100px", + "Should have correct property value" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_multiple-properties-unfinished_01.js b/devtools/client/inspector/rules/test/browser_rules_multiple-properties-unfinished_01.js new file mode 100644 index 0000000000..e451950aba --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_multiple-properties-unfinished_01.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the rule-view behaves correctly when entering multiple and/or +// unfinished properties/values in inplace-editors + +const TEST_URI = "<div>Test Element</div>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + await testCreateNewMultiUnfinished(inspector, view); +}); + +async function testCreateNewMultiUnfinished(inspector, view) { + const ruleEditor = getRuleViewRuleEditor(view, 0); + const onMutation = inspector.once("markupmutation"); + let onRuleViewChanged = view.once("ruleview-changed"); + await createNewRuleViewProperty( + ruleEditor, + "color:blue;background : orange ; text-align:center; border-color: " + ); + await onMutation; + await onRuleViewChanged; + + is( + ruleEditor.rule.textProps.length, + 4, + "Should have created new text properties." + ); + is( + ruleEditor.propertyList.children.length, + 4, + "Should have created property editors." + ); + + EventUtils.sendString("red", view.styleWindow); + onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + await onRuleViewChanged; + + is( + ruleEditor.rule.textProps.length, + 4, + "Should have the same number of text properties." + ); + is( + ruleEditor.propertyList.children.length, + 5, + "Should have added the changed value editor." + ); + + is( + getTextProperty(view, 0, { color: "blue" }).name, + "color", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { color: "blue" }).value, + "blue", + "Should have correct property value" + ); + + is( + getTextProperty(view, 0, { background: "orange" }).name, + "background", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { background: "orange" }).value, + "orange", + "Should have correct property value" + ); + + is( + getTextProperty(view, 0, { "text-align": "center" }).name, + "text-align", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { "text-align": "center" }).value, + "center", + "Should have correct property value" + ); + + is( + getTextProperty(view, 0, { "border-color": "red" }).name, + "border-color", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { "border-color": "red" }).value, + "red", + "Should have correct property value" + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_multiple-properties-unfinished_02.js b/devtools/client/inspector/rules/test/browser_rules_multiple-properties-unfinished_02.js new file mode 100644 index 0000000000..9c52d33e58 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_multiple-properties-unfinished_02.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the rule-view behaves correctly when entering mutliple and/or +// unfinished properties/values in inplace-editors + +const TEST_URI = "<div>Test Element</div>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + // Turn off throttling, which can cause intermittents. Throttling is used by + // the TextPropertyEditor. + view.debounce = () => {}; + + await selectNode("div", inspector); + + const ruleEditor = getRuleViewRuleEditor(view, 0); + // Note that we wait for a markup mutation here because this new rule will end + // up creating a style attribute on the node shown in the markup-view. + // (we also wait for the rule-view to refresh). + let onMutation = inspector.once("markupmutation"); + let onRuleViewChanged = view.once("ruleview-changed"); + await createNewRuleViewProperty(ruleEditor, "width: 100px; heig"); + await onMutation; + await onRuleViewChanged; + + is( + ruleEditor.rule.textProps.length, + 2, + "Should have created a new text property." + ); + is( + ruleEditor.propertyList.children.length, + 2, + "Should have created a property editor." + ); + + // Value is focused, lets add multiple rules here and make sure they get added + onMutation = inspector.once("markupmutation"); + onRuleViewChanged = view.once("ruleview-changed"); + const valueEditor = ruleEditor.propertyList.children[1].querySelector( + ".styleinspector-propertyeditor" + ); + valueEditor.value = "10px;background:orangered;color: black;"; + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + await onMutation; + await onRuleViewChanged; + + is( + ruleEditor.rule.textProps.length, + 4, + "Should have added the changed value." + ); + is( + ruleEditor.propertyList.children.length, + 5, + "Should have added the changed value editor." + ); + + is( + getTextProperty(view, 0, { width: "100px" }).name, + "width", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { width: "100px" }).value, + "100px", + "Should have correct property value" + ); + + is( + getTextProperty(view, 0, { heig: "10px" }).name, + "heig", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { heig: "10px" }).value, + "10px", + "Should have correct property value" + ); + + is( + getTextProperty(view, 0, { background: "orangered" }).name, + "background", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { background: "orangered" }).value, + "orangered", + "Should have correct property value" + ); + + is( + getTextProperty(view, 0, { color: "black" }).name, + "color", + "Should have correct property name" + ); + is( + getTextProperty(view, 0, { color: "black" }).value, + "black", + "Should have correct property value" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_multiple_properties_01.js b/devtools/client/inspector/rules/test/browser_rules_multiple_properties_01.js new file mode 100644 index 0000000000..65babeddf6 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_multiple_properties_01.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the rule-view behaves correctly when entering mutliple and/or +// unfinished properties/values in inplace-editors. + +const TEST_URI = "<div>Test Element</div>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + const ruleEditor = getRuleViewRuleEditor(view, 0); + // Note that we wait for a markup mutation here because this new rule will end + // up creating a style attribute on the node shown in the markup-view. + // (we also wait for the rule-view to refresh). + const onMutation = inspector.once("markupmutation"); + const onRuleViewChanged = view.once("ruleview-changed"); + await createNewRuleViewProperty( + ruleEditor, + "color:blue;background : orange ; text-align:center; " + + "border-color: green;" + ); + await onMutation; + await onRuleViewChanged; + + is( + ruleEditor.rule.textProps.length, + 4, + "Should have created a new text property." + ); + is( + ruleEditor.propertyList.children.length, + 5, + "Should have created a new property editor." + ); + + is( + ruleEditor.rule.textProps[0].name, + "color", + "Should have correct property name" + ); + is( + ruleEditor.rule.textProps[0].value, + "blue", + "Should have correct property value" + ); + + is( + ruleEditor.rule.textProps[1].name, + "background", + "Should have correct property name" + ); + is( + ruleEditor.rule.textProps[1].value, + "orange", + "Should have correct property value" + ); + + is( + ruleEditor.rule.textProps[2].name, + "text-align", + "Should have correct property name" + ); + is( + ruleEditor.rule.textProps[2].value, + "center", + "Should have correct property value" + ); + + is( + ruleEditor.rule.textProps[3].name, + "border-color", + "Should have correct property name" + ); + is( + ruleEditor.rule.textProps[3].value, + "green", + "Should have correct property value" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_multiple_properties_02.js b/devtools/client/inspector/rules/test/browser_rules_multiple_properties_02.js new file mode 100644 index 0000000000..c2ed334bbe --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_multiple_properties_02.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the rule-view behaves correctly when entering mutliple and/or +// unfinished properties/values in inplace-editors + +const TEST_URI = "<div>Test Element</div>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + const ruleEditor = getRuleViewRuleEditor(view, 0); + let onDone = view.once("ruleview-changed"); + await createNewRuleViewProperty(ruleEditor, "width:"); + await onDone; + + is( + ruleEditor.rule.textProps.length, + 1, + "Should have created a new text property." + ); + is( + ruleEditor.propertyList.children.length, + 1, + "Should have created a property editor." + ); + + // Value is focused, lets add multiple rules here and make sure they get added + onDone = view.once("ruleview-changed"); + const onMutation = inspector.once("markupmutation"); + const input = view.styleDocument.activeElement; + input.value = "height: 10px;color:blue"; + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + await onMutation; + await onDone; + + is( + ruleEditor.rule.textProps.length, + 2, + "Should have added the changed value." + ); + is( + ruleEditor.propertyList.children.length, + 3, + "Should have added the changed value editor." + ); + + EventUtils.synthesizeKey("VK_ESCAPE", {}, view.styleWindow); + is( + ruleEditor.propertyList.children.length, + 2, + "Should have removed the value editor." + ); + + is( + ruleEditor.rule.textProps[0].name, + "width", + "Should have correct property name" + ); + is( + ruleEditor.rule.textProps[0].value, + "height: 10px", + "Should have correct property value" + ); + + is( + ruleEditor.rule.textProps[1].name, + "color", + "Should have correct property name" + ); + is( + ruleEditor.rule.textProps[1].value, + "blue", + "Should have correct property value" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_nested_at_rules.js b/devtools/client/inspector/rules/test/browser_rules_nested_at_rules.js new file mode 100644 index 0000000000..db0f4d39c5 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_nested_at_rules.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the rule-view content is correct when the page defines nested at-rules (@media, @layer, @supports, …) +const TEST_URI = ` + <style type="text/css"> + body { + container: mycontainer / inline-size; + } + + @layer mylayer { + @supports (container-name: mycontainer) { + @container mycontainer (min-width: 1px) { + @media screen { + @container mycontainer (min-width: 2rem) { + h1, [test-hint="nested"] { + background: gold; + } + } + } + } + } + } + </style> + <h1>Hello nested at-rules!</h1> +`; + +add_task(async function () { + await pushPref("layout.css.container-queries.enabled", true); + + await addTab( + "https://example.com/document-builder.sjs?html=" + + encodeURIComponent(TEST_URI) + ); + const { inspector, view } = await openRuleView(); + + await selectNode("h1", inspector); + + const expectedRules = [ + { selector: "element", ancestorRulesData: null }, + { + selector: `h1, [test-hint="nested"]`, + ancestorRulesData: [ + `@layer mylayer`, + `@supports (container-name: mycontainer)`, + `@container mycontainer (min-width: 1px)`, + `@media screen`, + `@container mycontainer (min-width: 2rem)`, + ], + }, + ]; + + const rulesInView = Array.from(view.element.children); + is( + rulesInView.length, + expectedRules.length, + "All expected rules are displayed" + ); + + for (let i = 0; i < expectedRules.length; i++) { + const expectedRule = expectedRules[i]; + info(`Checking rule #${i}: ${expectedRule.selector}`); + + const selector = rulesInView[i].querySelector( + ".ruleview-selectorcontainer" + ).innerText; + is(selector, expectedRule.selector, `Expected selector for ${selector}`); + + if (expectedRule.ancestorRulesData == null) { + is( + getRuleViewAncestorRulesDataElementByIndex(view, i), + null, + `No ancestor rules data displayed for ${selector}` + ); + } else { + is( + getRuleViewAncestorRulesDataTextByIndex(view, i), + expectedRule.ancestorRulesData.join("\n"), + `Expected ancestor rules data displayed for ${selector}` + ); + } + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_non_ascii.js b/devtools/client/inspector/rules/test/browser_rules_non_ascii.js new file mode 100644 index 0000000000..b124c7513b --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_non_ascii.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that rule view can open when there are non-ASCII characters in +// the style sheet. Regression test for bug 1390455. + +// Use a few 4-byte UTF-8 sequences to make it so the rule column +// would be wrong when we had the bug. +const SHEET_TEXT = "/*🆒🆒🆒🆒🆒🆒🆒🆒🆒🆒🆒🆒🆒🆒🆒*/#q{color:orange}"; +const HTML = `<style type="text/css">\n${SHEET_TEXT} + </style><div id="q">Styled Node</div>`; +const TEST_URI = "data:text/html;charset=utf-8," + encodeURIComponent(HTML); + +add_task(async function () { + await addTab(TEST_URI); + + const { inspector, view } = await openRuleView(); + await selectNode("#q", inspector); + + const elementStyle = view._elementStyle; + + const expected = [{ name: "color", overridden: false }]; + + const rule = elementStyle.rules[1]; + + for (let i = 0; i < expected.length; ++i) { + const prop = rule.textProps[i]; + is(prop.name, expected[i].name, `Got expected property name ${prop.name}`); + is( + prop.overridden, + expected[i].overridden, + `Got expected overridden value ${prop.overridden}` + ); + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_original-source-link.js b/devtools/client/inspector/rules/test/browser_rules_original-source-link.js new file mode 100644 index 0000000000..30096791eb --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_original-source-link.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the stylesheet links in the rule view are correct when source maps +// are involved. + +const TESTCASE_URI = URL_ROOT_SSL + "doc_sourcemaps.html"; +const PREF = "devtools.source-map.client-service.enabled"; +const SCSS_LOC = "doc_sourcemaps.scss:4"; +const CSS_LOC = "doc_sourcemaps.css:1"; + +add_task(async function () { + info("Setting the " + PREF + " pref to true"); + Services.prefs.setBoolPref(PREF, true); + + await addTab(TESTCASE_URI); + const { toolbox, inspector, view } = await openRuleView(); + + info("Selecting the test node"); + await selectNode("div", inspector); + + await verifyLinkText(SCSS_LOC, view); + + info("Setting the " + PREF + " pref to false"); + Services.prefs.setBoolPref(PREF, false); + await verifyLinkText(CSS_LOC, view); + + info("Setting the " + PREF + " pref to true again"); + Services.prefs.setBoolPref(PREF, true); + + await testClickingLink(toolbox, view); + const selectedEditor = await waitForOriginalStyleSheetEditorSelection( + toolbox + ); + + const href = selectedEditor.styleSheet.href; + ok( + href.endsWith("doc_sourcemaps.scss"), + "selected stylesheet is correct one" + ); + + await selectedEditor.getSourceEditor(); + const { line } = selectedEditor.sourceEditor.getCursor(); + is(line, 3, "cursor is at correct line number in original source"); + + info("Clearing the " + PREF + " pref"); + Services.prefs.clearUserPref(PREF); +}); + +async function testClickingLink(toolbox, view) { + info("Listening for switch to the style editor"); + const onStyleEditorReady = toolbox.once("styleeditor-selected"); + + info("Finding the stylesheet link and clicking it"); + const link = getRuleViewLinkByIndex(view, 1); + link.scrollIntoView(); + link.click(); + await onStyleEditorReady; +} + +function waitForOriginalStyleSheetEditorSelection(toolbox) { + const panel = toolbox.getCurrentPanel(); + return new Promise((resolve, reject) => { + const maybeContinue = editor => { + // The style editor selects the first sheet at first load before + // selecting the desired sheet. + if (editor.styleSheet.href.endsWith("scss")) { + info("Original source editor selected"); + off(); + resolve(editor); + } + }; + const off = panel.UI.on("editor-selected", maybeContinue); + if (panel.UI.selectedEditor) { + maybeContinue(panel.UI.selectedEditor); + } + }); +} + +function verifyLinkText(text, view) { + info("Verifying that the rule-view stylesheet link is " + text); + const label = getRuleViewLinkByIndex(view, 1).querySelector( + ".ruleview-rule-source-label" + ); + return waitForSuccess(function () { + return ( + label.textContent == text && + label.getAttribute("title") === URL_ROOT_SSL + text + ); + }, "Link text changed to display correct location: " + text); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_original-source-link2.js b/devtools/client/inspector/rules/test/browser_rules_original-source-link2.js new file mode 100644 index 0000000000..87963f9ec5 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_original-source-link2.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the stylesheet links in the rule view are correct when source maps +// are involved. + +const TESTCASE_URI = URL_ROOT + "doc_sourcemaps2.html"; +const PREF = "devtools.source-map.client-service.enabled"; +const SCSS_LOC = "doc_sourcemaps.scss:4"; +const CSS_LOC = "doc_sourcemaps2.css:1"; + +add_task(async function () { + info("Setting the " + PREF + " pref to true"); + Services.prefs.setBoolPref(PREF, true); + + await addTab(TESTCASE_URI); + const { toolbox, inspector, view } = await openRuleView(); + + info("Selecting the test node"); + await selectNode("div", inspector); + + await verifyLinkText(SCSS_LOC, view); + + info("Setting the " + PREF + " pref to false"); + Services.prefs.setBoolPref(PREF, false); + await verifyLinkText(CSS_LOC, view); + + info("Setting the " + PREF + " pref to true again"); + Services.prefs.setBoolPref(PREF, true); + + await testClickingLink(toolbox, view); + const selectedEditor = await waitForOriginalStyleSheetEditorSelection( + toolbox + ); + const href = selectedEditor.styleSheet.href; + ok( + href.endsWith("doc_sourcemaps.scss"), + "selected stylesheet is correct one" + ); + await selectedEditor.getSourceEditor(); + + const { line } = selectedEditor.sourceEditor.getCursor(); + is(line, 3, "cursor is at correct line number in original source"); + + info("Clearing the " + PREF + " pref"); + Services.prefs.clearUserPref(PREF); +}); + +async function testClickingLink(toolbox, view) { + info("Listening for switch to the style editor"); + const onStyleEditorReady = toolbox.once("styleeditor-selected"); + + info("Finding the stylesheet link and clicking it"); + const link = getRuleViewLinkByIndex(view, 1); + link.scrollIntoView(); + link.click(); + await onStyleEditorReady; +} + +function waitForOriginalStyleSheetEditorSelection(toolbox) { + const panel = toolbox.getCurrentPanel(); + return new Promise((resolve, reject) => { + const maybeContinue = editor => { + // The style editor selects the first sheet at first load before + // selecting the desired sheet. + if (editor.styleSheet.href.endsWith("scss")) { + info("Original source editor selected"); + off(); + resolve(editor); + } + }; + const off = panel.UI.on("editor-selected", maybeContinue); + if (panel.UI.selectedEditor) { + maybeContinue(panel.UI.selectedEditor); + } + }); +} + +function verifyLinkText(text, view) { + info("Verifying that the rule-view stylesheet link is " + text); + const label = getRuleViewLinkByIndex(view, 1).querySelector( + ".ruleview-rule-source-label" + ); + return waitForSuccess(function () { + return label.textContent == text; + }, "Link text changed to display correct location: " + text); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_preview-tooltips-sizes.js b/devtools/client/inspector/rules/test/browser_rules_preview-tooltips-sizes.js new file mode 100644 index 0000000000..a90b6250cd --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_preview-tooltips-sizes.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the dimensions of the preview tooltips are correctly updated to fit their +// content. + +// Small 32x32 image. +const BASE_64_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr" + + "0AAAAUElEQVRYR+3UsQkAQAhD0TjJ7T+Wk3gbxMIizbcVITwwJWlkZtptpXp+v94TAAEE4gLTvgfOf770RB" + + "EAAQTiAvEiIgACCMQF4kVEAAQQSAt8xsyeAW6R8eIAAAAASUVORK5CYII="; + +add_task(async function () { + await addTab( + "data:text/html;charset=utf-8," + + encodeURIComponent(` + <style> + html { + /* Using a long variable name to ensure preview tooltip for variable will be */ + /* wider than the preview tooltip for the test 32x32 image. */ + --test-var-wider-than-image: red; + } + + #target { + color: var(--test-var-wider-than-image); + background: url(${BASE_64_URL}); + } + </style> + <div id="target">inspect me</div> + `) + ); + const { inspector, view } = await openRuleView(); + await selectNode("#target", inspector); + + // Note: See intermittent Bug 1721743. + // On linux webrender opt, the inspector might open the ruleview before it has + // been populated with the rules for the div. + info("Wait until the rule view property is rendered"); + const colorPropertyElement = await waitFor(() => + getRuleViewProperty(view, "#target", "color") + ); + + // Retrieve the element for `--test-var` on which the CSS variable tooltip will appear. + const colorPropertySpan = colorPropertyElement.valueSpan; + const colorVariableElement = + colorPropertySpan.querySelector(".ruleview-variable"); + + // Retrieve the element for the background url on which the image preview will appear. + const backgroundPropertySpan = getRuleViewProperty( + view, + "#target", + "background" + ).valueSpan; + const backgroundUrlElement = + backgroundPropertySpan.querySelector(".theme-link"); + + info("Show preview tooltip for CSS variable"); + let previewTooltip = await assertShowPreviewTooltip( + view, + colorVariableElement + ); + // Measure tooltip dimensions. + let tooltipRect = previewTooltip.panel.getBoundingClientRect(); + const originalHeight = tooltipRect.height; + const originalWidth = tooltipRect.width; + info(`Original dimensions: ${originalWidth} x ${originalHeight}`); + await assertTooltipHiddenOnMouseOut(previewTooltip, colorVariableElement); + + info("Show preview tooltip for background url"); + previewTooltip = await assertShowPreviewTooltip(view, backgroundUrlElement); + // Compare new tooltip dimensions to previous measures. + tooltipRect = previewTooltip.panel.getBoundingClientRect(); + info( + `Image preview dimensions: ${tooltipRect.width} x ${tooltipRect.height}` + ); + ok( + tooltipRect.height > originalHeight, + "Tooltip is taller for image preview" + ); + ok( + tooltipRect.width < originalWidth, + "Tooltip is narrower for image preview" + ); + await assertTooltipHiddenOnMouseOut(previewTooltip, colorVariableElement); + + info("Show preview tooltip for CSS variable again"); + previewTooltip = await assertShowPreviewTooltip(view, colorVariableElement); + // Check measures are identical to initial ones. + tooltipRect = previewTooltip.panel.getBoundingClientRect(); + info( + `CSS variable tooltip dimensions: ${tooltipRect.width} x ${tooltipRect.height}` + ); + is( + tooltipRect.height, + originalHeight, + "Tooltip has the same height as the original" + ); + is( + tooltipRect.width, + originalWidth, + "Tooltip has the same width as the original" + ); + await assertTooltipHiddenOnMouseOut(previewTooltip, colorVariableElement); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_print_media_simulation.js b/devtools/client/inspector/rules/test/browser_rules_print_media_simulation.js new file mode 100644 index 0000000000..1ba411b56a --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_print_media_simulation.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test print media simulation. + +// Load the test page under .com TLD, to make the inner .org iframe remote with +// Fission. +const TEST_URI = URL_ROOT_COM_SSL + "doc_print_media_simulation.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + + info("Check that the print simulation button exists"); + const button = inspector.panelDoc.querySelector("#print-simulation-toggle"); + ok(button, "The print simulation button exists"); + + ok(!button.classList.contains("checked"), "The print button is not checked"); + + // Helper to retrieve the background-color property of the selected element + // All the test elements are expected to have a single background-color rule + // for this test. + const ruleViewHasColor = async color => + (await getPropertiesForRuleIndex(view, 1)).has("background-color:" + color); + + info("Select a div that will change according to print simulation"); + await selectNode("div", inspector); + ok( + await ruleViewHasColor("#f00"), + "The rule view shows the expected initial rule" + ); + is( + getRuleViewAncestorRulesDataElementByIndex(view, 1), + null, + "No media query information are displayed initially" + ); + + info("Click on the button and wait for print media to be applied"); + button.click(); + + await waitFor(() => button.classList.contains("checked")); + ok(true, "The button is now checked"); + + await waitFor(() => ruleViewHasColor("#00f")); + ok( + true, + "The rules view was updated with the rule view from the print media query" + ); + is( + getRuleViewAncestorRulesDataTextByIndex(view, 1), + "@media print", + "Media queries information are displayed" + ); + + info("Select the node from the remote iframe"); + await selectNodeInFrames(["iframe", "html"], inspector); + + ok( + await ruleViewHasColor("#0ff"), + "The simulation is also applied on the remote iframe" + ); + is( + getRuleViewAncestorRulesDataTextByIndex(view, 1), + "@media print", + "Media queries information are displayed for the node on the remote iframe as well" + ); + + info("Select the top level div again"); + await selectNode("div", inspector); + + info("Click the button again to disable print simulation"); + button.click(); + + await waitFor(() => !button.classList.contains("checked")); + ok(true, "The button is no longer checked"); + + await waitFor(() => ruleViewHasColor("#f00")); + is( + getRuleViewAncestorRulesDataElementByIndex(view, 1), + null, + "media query is no longer displayed" + ); + + info("Select the node from the remote iframe again"); + await selectNodeInFrames(["iframe", "html"], inspector); + + await waitFor(() => ruleViewHasColor("#ff0")); + ok(true, "The simulation stopped on the remote iframe as well"); + is( + getRuleViewAncestorRulesDataElementByIndex(view, 1), + null, + "media query is no longer displayed on the remote iframe as well" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_pseudo-element_01.js b/devtools/client/inspector/rules/test/browser_rules_pseudo-element_01.js new file mode 100644 index 0000000000..ce49501687 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_pseudo-element_01.js @@ -0,0 +1,395 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that pseudoelements are displayed correctly in the rule view + +const TEST_URI = URL_ROOT + "doc_pseudoelement.html"; +const PSEUDO_PREF = "devtools.inspector.show_pseudo_elements"; + +add_task(async function () { + await pushPref(PSEUDO_PREF, true); + + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + + await testTopLeft(inspector, view); + await testTopRight(inspector, view); + await testBottomRight(inspector, view); + await testBottomLeft(inspector, view); + await testParagraph(inspector, view); + await testBody(inspector, view); + await testList(inspector, view); + await testDialogBackdrop(inspector, view); +}); + +async function testTopLeft(inspector, view) { + const id = "#topleft"; + const rules = await assertPseudoElementRulesNumbers(id, inspector, view, { + elementRulesNb: 4, + firstLineRulesNb: 2, + firstLetterRulesNb: 1, + selectionRulesNb: 1, + markerRulesNb: 0, + afterRulesNb: 1, + beforeRulesNb: 2, + }); + + const gutters = assertGutters(view); + + info("Make sure that clicking on the twisty hides pseudo elements"); + const expander = gutters[0].querySelector(".ruleview-expander"); + ok(!view.element.children[1].hidden, "Pseudo Elements are expanded"); + + expander.click(); + ok( + view.element.children[1].hidden, + "Pseudo Elements are collapsed by twisty" + ); + + expander.click(); + ok(!view.element.children[1].hidden, "Pseudo Elements are expanded again"); + + info( + "Make sure that dblclicking on the header container also toggles " + + "the pseudo elements" + ); + EventUtils.synthesizeMouseAtCenter( + gutters[0], + { clickCount: 2 }, + view.styleWindow + ); + ok( + view.element.children[1].hidden, + "Pseudo Elements are collapsed by dblclicking" + ); + + const elementRuleView = getRuleViewRuleEditor(view, 3); + + const elementFirstLineRule = rules.firstLineRules[0]; + const elementFirstLineRuleView = [ + ...view.element.children[1].children, + ].filter(e => { + return e._ruleEditor && e._ruleEditor.rule === elementFirstLineRule; + })[0]._ruleEditor; + + is( + convertTextPropsToString(elementFirstLineRule.textProps), + "color: orange", + "TopLeft firstLine properties are correct" + ); + + let onAdded = view.once("ruleview-changed"); + let firstProp = elementFirstLineRuleView.addProperty( + "background-color", + "rgb(0, 255, 0)", + "", + true + ); + await onAdded; + + onAdded = view.once("ruleview-changed"); + const secondProp = elementFirstLineRuleView.addProperty( + "font-style", + "italic", + "", + true + ); + await onAdded; + + is( + firstProp, + elementFirstLineRule.textProps[elementFirstLineRule.textProps.length - 2], + "First added property is on back of array" + ); + is( + secondProp, + elementFirstLineRule.textProps[elementFirstLineRule.textProps.length - 1], + "Second added property is on back of array" + ); + + is( + await getComputedStyleProperty(id, ":first-line", "background-color"), + "rgb(0, 255, 0)", + "Added property should have been used." + ); + is( + await getComputedStyleProperty(id, ":first-line", "font-style"), + "italic", + "Added property should have been used." + ); + is( + await getComputedStyleProperty(id, null, "text-decoration-line"), + "none", + "Added property should not apply to element" + ); + + await togglePropStatus(view, firstProp); + + is( + await getComputedStyleProperty(id, ":first-line", "background-color"), + "rgb(255, 0, 0)", + "Disabled property should now have been used." + ); + is( + await getComputedStyleProperty(id, null, "background-color"), + "rgb(221, 221, 221)", + "Added property should not apply to element" + ); + + await togglePropStatus(view, firstProp); + + is( + await getComputedStyleProperty(id, ":first-line", "background-color"), + "rgb(0, 255, 0)", + "Added property should have been used." + ); + is( + await getComputedStyleProperty(id, null, "text-decoration-line"), + "none", + "Added property should not apply to element" + ); + + onAdded = view.once("ruleview-changed"); + firstProp = elementRuleView.addProperty( + "background-color", + "rgb(0, 0, 255)", + "", + true + ); + await onAdded; + + is( + await getComputedStyleProperty(id, null, "background-color"), + "rgb(0, 0, 255)", + "Added property should have been used." + ); + is( + await getComputedStyleProperty(id, ":first-line", "background-color"), + "rgb(0, 255, 0)", + "Added prop does not apply to pseudo" + ); +} + +async function testTopRight(inspector, view) { + await assertPseudoElementRulesNumbers("#topright", inspector, view, { + elementRulesNb: 4, + firstLineRulesNb: 1, + firstLetterRulesNb: 1, + selectionRulesNb: 0, + markerRulesNb: 0, + beforeRulesNb: 2, + afterRulesNb: 1, + }); + + const gutters = assertGutters(view); + + const expander = gutters[0].querySelector(".ruleview-expander"); + ok( + !view.element.firstChild.classList.contains("show-expandable-container"), + "Pseudo Elements remain collapsed after switching element" + ); + + expander.scrollIntoView(); + expander.click(); + ok( + !view.element.children[1].hidden, + "Pseudo Elements are shown again after clicking twisty" + ); +} + +async function testBottomRight(inspector, view) { + await assertPseudoElementRulesNumbers("#bottomright", inspector, view, { + elementRulesNb: 4, + firstLineRulesNb: 1, + firstLetterRulesNb: 1, + selectionRulesNb: 0, + markerRulesNb: 0, + beforeRulesNb: 3, + afterRulesNb: 1, + }); +} + +async function testBottomLeft(inspector, view) { + await assertPseudoElementRulesNumbers("#bottomleft", inspector, view, { + elementRulesNb: 4, + firstLineRulesNb: 1, + firstLetterRulesNb: 1, + selectionRulesNb: 0, + markerRulesNb: 0, + beforeRulesNb: 2, + afterRulesNb: 1, + }); +} + +async function testParagraph(inspector, view) { + const rules = await assertPseudoElementRulesNumbers( + "#bottomleft p", + inspector, + view, + { + elementRulesNb: 3, + firstLineRulesNb: 1, + firstLetterRulesNb: 1, + selectionRulesNb: 2, + markerRulesNb: 0, + beforeRulesNb: 0, + afterRulesNb: 0, + } + ); + + assertGutters(view); + + const elementFirstLineRule = rules.firstLineRules[0]; + is( + convertTextPropsToString(elementFirstLineRule.textProps), + "background: blue", + "Paragraph first-line properties are correct" + ); + + const elementFirstLetterRule = rules.firstLetterRules[0]; + is( + convertTextPropsToString(elementFirstLetterRule.textProps), + "color: red; font-size: 130%", + "Paragraph first-letter properties are correct" + ); + + const elementSelectionRule = rules.selectionRules[0]; + is( + convertTextPropsToString(elementSelectionRule.textProps), + "color: white; background: black", + "Paragraph first-letter properties are correct" + ); +} + +async function testBody(inspector, view) { + await testNode("body", inspector, view); + + const gutters = getGutters(view); + is(gutters.length, 0, "There are no gutter headings"); +} + +async function testList(inspector, view) { + await assertPseudoElementRulesNumbers("#list", inspector, view, { + elementRulesNb: 4, + firstLineRulesNb: 1, + firstLetterRulesNb: 1, + selectionRulesNb: 0, + markerRulesNb: 1, + beforeRulesNb: 1, + afterRulesNb: 1, + }); + + assertGutters(view); +} + +async function testDialogBackdrop(inspector, view) { + await assertPseudoElementRulesNumbers("dialog", inspector, view, { + elementRulesNb: 3, + backdropRules: 1, + }); + + assertGutters(view); +} + +function convertTextPropsToString(textProps) { + return textProps.map(t => t.name + ": " + t.value).join("; "); +} + +async function testNode(selector, inspector, view) { + await selectNode(selector, inspector); + const elementStyle = view._elementStyle; + return elementStyle; +} + +async function assertPseudoElementRulesNumbers( + selector, + inspector, + view, + ruleNbs +) { + const elementStyle = await testNode(selector, inspector, view); + + const rules = { + elementRules: elementStyle.rules.filter(rule => !rule.pseudoElement), + firstLineRules: elementStyle.rules.filter( + rule => rule.pseudoElement === "::first-line" + ), + firstLetterRules: elementStyle.rules.filter( + rule => rule.pseudoElement === "::first-letter" + ), + selectionRules: elementStyle.rules.filter( + rule => rule.pseudoElement === "::selection" + ), + markerRules: elementStyle.rules.filter( + rule => rule.pseudoElement === "::marker" + ), + beforeRules: elementStyle.rules.filter( + rule => rule.pseudoElement === "::before" + ), + afterRules: elementStyle.rules.filter( + rule => rule.pseudoElement === "::after" + ), + backdropRules: elementStyle.rules.filter( + rule => rule.pseudoElement === "::backdrop" + ), + }; + + is( + rules.elementRules.length, + ruleNbs.elementRulesNb || 0, + selector + " has the correct number of non pseudo element rules" + ); + is( + rules.firstLineRules.length, + ruleNbs.firstLineRulesNb || 0, + selector + " has the correct number of ::first-line rules" + ); + is( + rules.firstLetterRules.length, + ruleNbs.firstLetterRulesNb || 0, + selector + " has the correct number of ::first-letter rules" + ); + is( + rules.selectionRules.length, + ruleNbs.selectionRulesNb || 0, + selector + " has the correct number of ::selection rules" + ); + is( + rules.markerRules.length, + ruleNbs.markerRulesNb || 0, + selector + " has the correct number of ::marker rules" + ); + is( + rules.beforeRules.length, + ruleNbs.beforeRulesNb || 0, + selector + " has the correct number of ::before rules" + ); + is( + rules.afterRules.length, + ruleNbs.afterRulesNb || 0, + selector + " has the correct number of ::after rules" + ); + + return rules; +} + +function getGutters(view) { + return view.element.querySelectorAll(".ruleview-header"); +} + +function assertGutters(view) { + const gutters = getGutters(view); + + is(gutters.length, 3, "There are 3 gutter headings"); + is(gutters[0].textContent, "Pseudo-elements", "Gutter heading is correct"); + is(gutters[1].textContent, "This Element", "Gutter heading is correct"); + is( + gutters[2].textContent, + "Inherited from body", + "Gutter heading is correct" + ); + + return gutters; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_pseudo-element_02.js b/devtools/client/inspector/rules/test/browser_rules_pseudo-element_02.js new file mode 100644 index 0000000000..d098542758 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_pseudo-element_02.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that pseudoelements are displayed correctly in the markup view. + +const TEST_URI = URL_ROOT + "doc_pseudoelement.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector } = await openRuleView(); + + const node = await getNodeFront("#topleft", inspector); + const children = await inspector.markup.walker.children(node); + + is(children.nodes.length, 3, "Element has correct number of children"); + + const beforeElement = children.nodes[0]; + is( + beforeElement.tagName, + "_moz_generated_content_before", + "tag name is correct" + ); + await selectNode(beforeElement, inspector); + + const afterElement = children.nodes[children.nodes.length - 1]; + is( + afterElement.tagName, + "_moz_generated_content_after", + "tag name is correct" + ); + await selectNode(afterElement, inspector); + + const listNode = await getNodeFront("#list", inspector); + const listChildren = await inspector.markup.walker.children(listNode); + + is(listChildren.nodes.length, 4, "<li> has correct number of children"); + const markerElement = listChildren.nodes[0]; + is( + markerElement.tagName, + "_moz_generated_content_marker", + "tag name is correct" + ); + await selectNode(markerElement, inspector); + + const listBeforeElement = listChildren.nodes[1]; + is( + listBeforeElement.tagName, + "_moz_generated_content_before", + "tag name is correct" + ); + await selectNode(listBeforeElement, inspector); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_pseudo-visited.js b/devtools/client/inspector/rules/test/browser_rules_pseudo-visited.js new file mode 100644 index 0000000000..6d37829160 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_pseudo-visited.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests for visited/unvisited rule. + +const TEST_URI = URL_ROOT + "doc_visited.html"; + +add_task(async () => { + info("Open a url which has a visited and an unvisited link"); + const tab = await addTab(TEST_URI); + + info("Wait until the visited link is available"); + await waitUntilVisitedState(tab, ["#visited"]); + + info("Open the inspector"); + const { inspector, view } = await openRuleView(); + + info("Check whether the rule view is shown correctly for visited element"); + await selectNode("#visited", inspector); + ok(getRuleViewRule(view, "a:visited"), "Rule of a:visited is shown"); + ok(!getRuleViewRule(view, "a:link"), "Rule of a:link is not shown"); + ok(getRuleViewRule(view, "a"), "Rule of a is shown"); + + info("Check whether the rule view is shown correctly for unvisited element"); + await selectNode("#unvisited", inspector); + ok(!getRuleViewRule(view, "a:visited"), "Rule of a:visited is not shown"); + ok(getRuleViewRule(view, "a:link"), "Rule of a:link is shown"); + ok(getRuleViewRule(view, "a"), "Rule of a is shown"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_pseudo-visited_in_media-query.js b/devtools/client/inspector/rules/test/browser_rules_pseudo-visited_in_media-query.js new file mode 100644 index 0000000000..48f62a2fa9 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_pseudo-visited_in_media-query.js @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests for visited/unvisited rule. + +const TEST_URI = URL_ROOT + "doc_visited_in_media_query.html"; + +add_task(async () => { + info("Open a url which has a visited link and the style in the media query"); + const tab = await addTab(TEST_URI); + + info("Wait until the visited link is available"); + await waitUntilVisitedState(tab, ["#visited"]); + + info("Open the inspector"); + const { inspector, view } = await openRuleView(); + + info("Check whether the rule view is shown correctly for visited element"); + await selectNode("#visited", inspector); + ok(getRuleViewRule(view, "a"), "Rule of a is shown"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_pseudo-visited_with_style-attribute.js b/devtools/client/inspector/rules/test/browser_rules_pseudo-visited_with_style-attribute.js new file mode 100644 index 0000000000..97d1458b72 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_pseudo-visited_with_style-attribute.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view works for an element with a visited pseudo class +// with a style attribute defined. + +const TEST_URI = URL_ROOT + "doc_visited_with_style_attribute.html"; + +add_task(async () => { + info( + "Open a page which has an element with a visited pseudo class and a style attribute" + ); + const tab = await addTab(TEST_URI); + + info("Wait until the link has been visited"); + await waitUntilVisitedState(tab, ["#visited"]); + + info("Open the inspector"); + const { inspector, view } = await openRuleView(); + + info("Check whether the rule view is shown correctly for visited element"); + await selectNode("#visited", inspector); + ok(getRuleViewRule(view, "element"), "Rule of a is shown"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_pseudo_lock_options.js b/devtools/client/inspector/rules/test/browser_rules_pseudo_lock_options.js new file mode 100644 index 0000000000..62a8c8684d --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_pseudo_lock_options.js @@ -0,0 +1,159 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view pseudo lock options work properly. + +const { + PSEUDO_CLASSES, +} = require("resource://devtools/shared/css/constants.js"); +const TEST_URI = ` + <style type='text/css'> + div { + color: red; + } + div:hover { + color: blue; + } + div:active { + color: yellow; + } + div:focus { + color: green; + } + div:focus-within { + color: papayawhip; + } + div:visited { + color: orange; + } + div:focus-visible { + color: wheat; + } + div:target { + color: crimson; + } + </style> + <div>test div</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + await assertPseudoPanelClosed(view); + + info("Toggle the pseudo class panel open"); + view.pseudoClassToggle.click(); + await assertPseudoPanelOpened(view); + + info("Toggle each pseudo lock and check that the pseudo lock is added"); + for (const pseudo of PSEUDO_CLASSES) { + await togglePseudoClass(inspector, view, pseudo); + await assertPseudoAdded(inspector, view, pseudo, 3, 1); + await togglePseudoClass(inspector, view, pseudo); + await assertPseudoRemoved(inspector, view, 2); + } + + info("Toggle all pseudo locks and check that the pseudo lock is added"); + await togglePseudoClass(inspector, view, ":hover"); + await togglePseudoClass(inspector, view, ":active"); + await togglePseudoClass(inspector, view, ":focus"); + await togglePseudoClass(inspector, view, ":target"); + await assertPseudoAdded(inspector, view, ":target", 6, 1); + await assertPseudoAdded(inspector, view, ":focus", 6, 2); + await assertPseudoAdded(inspector, view, ":active", 6, 3); + await assertPseudoAdded(inspector, view, ":hover", 6, 4); + await togglePseudoClass(inspector, view, ":hover"); + await togglePseudoClass(inspector, view, ":active"); + await togglePseudoClass(inspector, view, ":focus"); + await togglePseudoClass(inspector, view, ":target"); + await assertPseudoRemoved(inspector, view, 2); + + info("Select a null element"); + await view.selectElement(null); + + info("Check that all pseudo locks are unchecked and disabled"); + for (const pseudo of PSEUDO_CLASSES) { + const checkbox = getPseudoClassCheckbox(view, pseudo); + ok( + !checkbox.checked && checkbox.disabled, + `${pseudo} checkbox is unchecked and disabled` + ); + } + + info("Toggle the pseudo class panel close"); + view.pseudoClassToggle.click(); + await assertPseudoPanelClosed(view); +}); + +async function togglePseudoClass(inspector, view, pseudoClass) { + info(`Toggle the pseudo-class ${pseudoClass}, wait for it to be applied`); + const onRefresh = inspector.once("rule-view-refreshed"); + const checkbox = getPseudoClassCheckbox(view, pseudoClass); + if (checkbox) { + checkbox.click(); + } + await onRefresh; +} + +function assertPseudoAdded(inspector, view, pseudoClass, numRules, childIndex) { + info("Check that the rule view contains the pseudo-class rule"); + is( + view.element.children.length, + numRules, + "Should have " + numRules + " rules." + ); + is( + getRuleViewRuleEditor(view, childIndex).rule.selectorText, + "div" + pseudoClass, + "rule view is showing " + pseudoClass + " rule" + ); +} + +function assertPseudoRemoved(inspector, view, numRules) { + info("Check that the rule view no longer contains the pseudo-class rule"); + is( + view.element.children.length, + numRules, + "Should have " + numRules + " rules." + ); + is( + getRuleViewRuleEditor(view, 1).rule.selectorText, + "div", + "Second rule is div" + ); +} + +function assertPseudoPanelOpened(view) { + info("Check the opened state of the pseudo class panel"); + + ok(!view.pseudoClassPanel.hidden, "Pseudo Class Panel Opened"); + + for (const pseudo of PSEUDO_CLASSES) { + const checkbox = getPseudoClassCheckbox(view, pseudo); + ok(!checkbox.disabled, `${pseudo} checkbox is not disabled`); + is( + checkbox.getAttribute("tabindex"), + "0", + `${pseudo} checkbox has a tabindex of 0` + ); + } +} + +function assertPseudoPanelClosed(view) { + info("Check the closed state of the pseudo clas panel"); + + ok(view.pseudoClassPanel.hidden, "Pseudo Class Panel Hidden"); + + for (const pseudo of PSEUDO_CLASSES) { + const checkbox = getPseudoClassCheckbox(view, pseudo); + is( + checkbox.getAttribute("tabindex"), + "-1", + `${pseudo} checkbox has a tabindex of -1` + ); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_refresh-no-flicker.js b/devtools/client/inspector/rules/test/browser_rules_refresh-no-flicker.js new file mode 100644 index 0000000000..05574ab4c7 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_refresh-no-flicker.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the rule view does not go blank while selecting a new node. + +const TESTCASE_URI = + "data:text/html;charset=utf-8," + + '<div id="testdiv" style="font-size:10px;">' + + "Test div!</div>"; + +add_task(async function () { + await addTab(TESTCASE_URI); + + info("Opening the rule view and selecting the test node"); + const { inspector, view } = await openRuleView(); + const testdiv = await getNodeFront("#testdiv", inspector); + await selectNode(testdiv, inspector); + + const htmlBefore = view.element.innerHTML; + ok( + htmlBefore.indexOf("font-size") > -1, + "The rule view should contain a font-size property." + ); + + // Do the selectNode call manually, because otherwise it's hard to guarantee + // that we can make the below checks at a reasonable time. + info("refreshing the node"); + const p = view.selectElement(testdiv, true); + is( + view.element.innerHTML, + htmlBefore, + "The rule view is unchanged during selection." + ); + ok( + view.element.classList.contains("non-interactive"), + "The rule view is marked non-interactive." + ); + await p; + + info("node refreshed"); + ok( + !view.element.classList.contains("non-interactive"), + "The rule view is marked interactive again." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_refresh-on-attribute-change_01.js b/devtools/client/inspector/rules/test/browser_rules_refresh-on-attribute-change_01.js new file mode 100644 index 0000000000..d41ee2b00c --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_refresh-on-attribute-change_01.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that changing the current element's attributes refreshes the rule-view + +const TEST_URI = ` + <style type="text/css"> + #testid { + background-color: blue; + } + .testclass { + background-color: green; + } + </style> + <div id="testid" class="testclass" style="margin-top: 1px; padding-top: 5px;"> + Styled Node + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + info( + "Checking that the rule-view has the element, #testid and " + + ".testclass selectors" + ); + checkRuleViewContent(view, ["element", "#testid", ".testclass"]); + + info( + "Changing the node's ID attribute and waiting for the " + + "rule-view refresh" + ); + let ruleViewRefreshed = inspector.once("rule-view-refreshed"); + await setContentPageElementAttribute("#testid", "id", "differentid"); + await ruleViewRefreshed; + + info("Checking that the rule-view doesn't have the #testid selector anymore"); + checkRuleViewContent(view, ["element", ".testclass"]); + + info("Reverting the ID attribute change"); + ruleViewRefreshed = inspector.once("rule-view-refreshed"); + await setContentPageElementAttribute("#differentid", "id", "testid"); + await ruleViewRefreshed; + + info("Checking that the rule-view has all the selectors again"); + checkRuleViewContent(view, ["element", "#testid", ".testclass"]); +}); + +function checkRuleViewContent(view, expectedSelectors) { + const selectors = view.styleDocument.querySelectorAll( + ".ruleview-selectorcontainer" + ); + + is( + selectors.length, + expectedSelectors.length, + expectedSelectors.length + " selectors are displayed" + ); + + for (let i = 0; i < expectedSelectors.length; i++) { + is( + selectors[i].textContent.indexOf(expectedSelectors[i]), + 0, + "Selector " + (i + 1) + " is " + expectedSelectors[i] + ); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_refresh-on-style-change.js b/devtools/client/inspector/rules/test/browser_rules_refresh-on-style-change.js new file mode 100644 index 0000000000..9287f161de --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_refresh-on-style-change.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the rule view refreshes when the current node has its style +// changed + +const TEST_URI = "<div id='testdiv' style='font-size: 10px;''>Test div!</div>"; + +add_task(async function () { + Services.prefs.setCharPref("devtools.defaultColorUnit", "name"); + + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testdiv", inspector); + + let fontSize = getRuleViewPropertyValue(view, "element", "font-size"); + is(fontSize, "10px", "The rule view shows the right font-size"); + + info("Changing the node's style and waiting for the update"); + const onUpdated = inspector.once("rule-view-refreshed"); + await setContentPageElementAttribute( + "#testdiv", + "style", + "font-size: 3em; color: lightgoldenrodyellow; " + + "text-align: right; text-transform: uppercase" + ); + await onUpdated; + + const textAlign = getRuleViewPropertyValue(view, "element", "text-align"); + is(textAlign, "right", "The rule view shows the new text align."); + const color = getRuleViewPropertyValue(view, "element", "color"); + is(color, "lightgoldenrodyellow", "The rule view shows the new color."); + fontSize = getRuleViewPropertyValue(view, "element", "font-size"); + is(fontSize, "3em", "The rule view shows the new font size."); + const textTransform = getRuleViewPropertyValue( + view, + "element", + "text-transform" + ); + is(textTransform, "uppercase", "The rule view shows the new text transform."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_01.js b/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_01.js new file mode 100644 index 0000000000..8a4d78e843 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_01.js @@ -0,0 +1,180 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter and clear button works properly in +// the computed list. + +const TEST_URI = ` + <style type="text/css"> + #testid { + margin: 4px 0px; + } + .testclass { + background-color: red; + } + </style> + <h1 id="testid" class="testclass">Styled Node</h1> +`; + +const TEST_DATA = [ + { + desc: + "Tests that the search filter works properly in the computed list " + + "for property names", + search: "margin", + isExpanderOpen: false, + isFilterOpen: false, + isMarginHighlighted: true, + isMarginTopHighlighted: true, + isMarginRightHighlighted: true, + isMarginBottomHighlighted: true, + isMarginLeftHighlighted: true, + }, + { + desc: + "Tests that the search filter works properly in the computed list " + + "for property values", + search: "0px", + isExpanderOpen: false, + isFilterOpen: false, + isMarginHighlighted: true, + isMarginTopHighlighted: false, + isMarginRightHighlighted: true, + isMarginBottomHighlighted: false, + isMarginLeftHighlighted: true, + }, + { + desc: + "Tests that the search filter works properly in the computed list " + + "for property line input", + search: "margin-top:4px", + isExpanderOpen: true, + isFilterOpen: true, + isMarginHighlighted: false, + isMarginTopHighlighted: true, + isMarginRightHighlighted: false, + isMarginBottomHighlighted: false, + isMarginLeftHighlighted: false, + }, + { + desc: + "Tests that the search filter works properly in the computed list " + + "for parsed name", + search: "margin-top:", + isExpanderOpen: true, + isFilterOpen: true, + isMarginHighlighted: false, + isMarginTopHighlighted: true, + isMarginRightHighlighted: false, + isMarginBottomHighlighted: false, + isMarginLeftHighlighted: false, + }, + { + desc: + "Tests that the search filter works properly in the computed list " + + "for parsed property value", + search: ":4px", + isExpanderOpen: false, + isFilterOpen: false, + isMarginHighlighted: true, + isMarginTopHighlighted: true, + isMarginRightHighlighted: false, + isMarginBottomHighlighted: true, + isMarginLeftHighlighted: false, + }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testAddTextInFilter(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + for (const data of TEST_DATA) { + info(data.desc); + await setSearchFilter(view, data.search); + await checkRules(view, data); + await clearSearchAndCheckRules(view); + } +} + +function checkRules(view, data) { + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + const rule = getRuleViewRuleEditor(view, 1).rule; + const textPropEditor = rule.textProps[0].editor; + const computed = textPropEditor.computed; + + is(rule.selectorText, "#testid", "Second rule is #testid."); + is( + !!textPropEditor.expander.getAttribute("open"), + data.isExpanderOpen, + "Got correct expander state." + ); + is( + computed.hasAttribute("filter-open"), + data.isFilterOpen, + "Got correct expanded state for margin computed list." + ); + is( + textPropEditor.container.classList.contains("ruleview-highlight"), + data.isMarginHighlighted, + "Got correct highlight for margin text property." + ); + + is( + computed.children[0].classList.contains("ruleview-highlight"), + data.isMarginTopHighlighted, + "Got correct highlight for margin-top computed property." + ); + is( + computed.children[1].classList.contains("ruleview-highlight"), + data.isMarginRightHighlighted, + "Got correct highlight for margin-right computed property." + ); + is( + computed.children[2].classList.contains("ruleview-highlight"), + data.isMarginBottomHighlighted, + "Got correct highlight for margin-bottom computed property." + ); + is( + computed.children[3].classList.contains("ruleview-highlight"), + data.isMarginLeftHighlighted, + "Got correct highlight for margin-left computed property." + ); +} + +async function clearSearchAndCheckRules(view) { + const win = view.styleWindow; + const searchField = view.searchField; + const searchClearButton = view.searchClearButton; + + const rule = getRuleViewRuleEditor(view, 1).rule; + const textPropEditor = rule.textProps[0].editor; + const computed = textPropEditor.computed; + + info("Clearing the search filter"); + EventUtils.synthesizeMouseAtCenter(searchClearButton, {}, win); + await view.inspector.once("ruleview-filtered"); + + info("Check the search filter is cleared and no rules are highlighted"); + is(view.element.children.length, 3, "Should have 3 rules."); + ok(!searchField.value, "Search filter is cleared"); + ok( + !view.styleDocument.querySelectorAll(".ruleview-highlight").length, + "No rules are higlighted" + ); + + ok(!textPropEditor.expander.getAttribute("open"), "Expander is closed."); + ok(!computed.hasAttribute("filter-open"), "margin computed list is closed."); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_02.js b/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_02.js new file mode 100644 index 0000000000..98c48c9f79 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_02.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter works properly in the computed list +// when modifying the existing search filter value + +const SEARCH = "margin-"; + +const TEST_URI = ` + <style type="text/css"> + #testid { + margin: 4px 0px; + } + .testclass { + background-color: red; + } + </style> + <h1 id="testid" class="testclass">Styled Node</h1> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testAddTextInFilter(inspector, view); + await testRemoveTextInFilter(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + await setSearchFilter(view, SEARCH); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + const rule = getRuleViewRuleEditor(view, 1).rule; + const ruleEditor = getTextProperty(view, 1, { margin: "4px 0px" }).editor; + const computed = ruleEditor.computed; + + is(rule.selectorText, "#testid", "Second rule is #testid."); + ok(ruleEditor.expander.getAttribute("open"), "Expander is open."); + ok( + !ruleEditor.container.classList.contains("ruleview-highlight"), + "margin text property is not highlighted." + ); + ok(computed.hasAttribute("filter-open"), "margin computed list is open."); + + ok( + computed.children[0].classList.contains("ruleview-highlight"), + "margin-top computed property is correctly highlighted." + ); + ok( + computed.children[1].classList.contains("ruleview-highlight"), + "margin-right computed property is correctly highlighted." + ); + ok( + computed.children[2].classList.contains("ruleview-highlight"), + "margin-bottom computed property is correctly highlighted." + ); + ok( + computed.children[3].classList.contains("ruleview-highlight"), + "margin-left computed property is correctly highlighted." + ); +} + +async function testRemoveTextInFilter(inspector, view) { + info('Press backspace and set filter text to "margin"'); + + const win = view.styleWindow; + const searchField = view.searchField; + + searchField.focus(); + EventUtils.synthesizeKey("VK_BACK_SPACE", {}, win); + await inspector.once("ruleview-filtered"); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + const rule = getRuleViewRuleEditor(view, 1).rule; + const ruleEditor = getTextProperty(view, 1, { margin: "4px 0px" }).editor; + const computed = ruleEditor.computed; + + is(rule.selectorText, "#testid", "Second rule is #testid."); + ok(!ruleEditor.expander.getAttribute("open"), "Expander is closed."); + ok( + ruleEditor.container.classList.contains("ruleview-highlight"), + "margin text property is correctly highlighted." + ); + ok(!computed.hasAttribute("filter-open"), "margin computed list is closed."); + + ok( + computed.children[0].classList.contains("ruleview-highlight"), + "margin-top computed property is correctly highlighted." + ); + ok( + computed.children[1].classList.contains("ruleview-highlight"), + "margin-right computed property is correctly highlighted." + ); + ok( + computed.children[2].classList.contains("ruleview-highlight"), + "margin-bottom computed property is correctly highlighted." + ); + ok( + computed.children[3].classList.contains("ruleview-highlight"), + "margin-left computed property is correctly highlighted." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_03.js b/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_03.js new file mode 100644 index 0000000000..5c2a48a7fb --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_03.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter works properly in the computed list +// for color values. + +// The color format here is chosen to match the default returned by +// CssColor.toString. +const SEARCH = "background-color: rgb(243, 243, 243)"; + +const TEST_URI = ` + <style type="text/css"> + .testclass { + background: rgb(243, 243, 243) none repeat scroll 0% 0%; + } + </style> + <div class="testclass">Styled Node</h1> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode(".testclass", inspector); + await testAddTextInFilter(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + await setSearchFilter(view, SEARCH); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + const rule = getRuleViewRuleEditor(view, 1).rule; + const ruleEditor = rule.textProps[0].editor; + const computed = ruleEditor.computed; + + is(rule.selectorText, ".testclass", "Second rule is .testclass."); + ok(ruleEditor.expander.getAttribute("open"), "Expander is open."); + ok( + !ruleEditor.container.classList.contains("ruleview-highlight"), + "background property is not highlighted." + ); + ok(computed.hasAttribute("filter-open"), "background computed list is open."); + ok( + computed.children[0].classList.contains("ruleview-highlight"), + "background-color computed property is highlighted." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_04.js b/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_04.js new file mode 100644 index 0000000000..6b2344b6a6 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_04.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter works properly in the computed list +// for newly modified property values. + +const SEARCH = "0px"; + +const TEST_URI = ` + <style type='text/css'> + #testid { + margin: 4px; + top: 0px; + } + </style> + <h1 id='testid'>Styled Node</h1> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testModifyPropertyValueFilter(inspector, view); +}); + +async function testModifyPropertyValueFilter(inspector, view) { + await setSearchFilter(view, SEARCH); + + const rule = getRuleViewRuleEditor(view, 1).rule; + const propEditor = getTextProperty(view, 1, { margin: "4px" }).editor; + const computed = propEditor.computed; + const editor = await focusEditableField(view, propEditor.valueSpan); + + info("Check that the correct rules are visible"); + is(rule.selectorText, "#testid", "Second rule is #testid."); + ok( + !propEditor.container.classList.contains("ruleview-highlight"), + "margin text property is not highlighted." + ); + ok( + rule.textProps[1].editor.container.classList.contains("ruleview-highlight"), + "top text property is correctly highlighted." + ); + + const onBlur = once(editor.input, "blur"); + const onModification = view.once("ruleview-changed"); + EventUtils.sendString("4px 0px", view.styleWindow); + EventUtils.synthesizeKey("KEY_Enter"); + await onBlur; + await onModification; + + ok( + propEditor.container.classList.contains("ruleview-highlight"), + "margin text property is correctly highlighted." + ); + ok(!computed.hasAttribute("filter-open"), "margin computed list is closed."); + ok( + !computed.children[0].classList.contains("ruleview-highlight"), + "margin-top computed property is not highlighted." + ); + ok( + computed.children[1].classList.contains("ruleview-highlight"), + "margin-right computed property is correctly highlighted." + ); + ok( + !computed.children[2].classList.contains("ruleview-highlight"), + "margin-bottom computed property is not highlighted." + ); + ok( + computed.children[3].classList.contains("ruleview-highlight"), + "margin-left computed property is correctly highlighted." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_expander.js b/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_expander.js new file mode 100644 index 0000000000..5eeff9b539 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter-computed-list_expander.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the expanded computed list for a property remains open after +// clearing the rule view search filter. + +const SEARCH = "0px"; + +const TEST_URI = ` + <style type="text/css"> + #testid { + margin: 4px 0px; + } + .testclass { + background-color: red; + } + </style> + <h1 id="testid" class="testclass">Styled Node</h1> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testOpenExpanderAndAddTextInFilter(inspector, view); + await testClearSearchFilter(inspector, view); +}); + +async function testOpenExpanderAndAddTextInFilter(inspector, view) { + const rule = getRuleViewRuleEditor(view, 1).rule; + const ruleEditor = getTextProperty(view, 1, { margin: "4px 0px" }).editor; + const computed = ruleEditor.computed; + + info("Opening the computed list of margin property"); + ruleEditor.expander.click(); + + await setSearchFilter(view, SEARCH); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + is(rule.selectorText, "#testid", "Second rule is #testid."); + ok(ruleEditor.expander.getAttribute("open"), "Expander is open."); + ok( + ruleEditor.container.classList.contains("ruleview-highlight"), + "margin text property is correctly highlighted." + ); + ok( + !computed.hasAttribute("filter-open"), + "margin computed list does not contain filter-open class." + ); + ok( + computed.hasAttribute("user-open"), + "margin computed list contains user-open attribute." + ); + + ok( + !computed.children[0].classList.contains("ruleview-highlight"), + "margin-top computed property is not highlighted." + ); + ok( + computed.children[1].classList.contains("ruleview-highlight"), + "margin-right computed property is correctly highlighted." + ); + ok( + !computed.children[2].classList.contains("ruleview-highlight"), + "margin-bottom computed property is not highlighted." + ); + ok( + computed.children[3].classList.contains("ruleview-highlight"), + "margin-left computed property is correctly highlighted." + ); +} + +async function testClearSearchFilter(inspector, view) { + info("Clearing the search filter"); + + const searchField = view.searchField; + const searchClearButton = view.searchClearButton; + const onRuleViewFiltered = inspector.once("ruleview-filtered"); + + EventUtils.synthesizeMouseAtCenter(searchClearButton, {}, view.styleWindow); + + await onRuleViewFiltered; + + info("Check the search filter is cleared and no rules are highlighted"); + is(view.element.children.length, 3, "Should have 3 rules."); + ok(!searchField.value, "Search filter is cleared"); + ok( + !view.styleDocument.querySelectorAll(".ruleview-highlight").length, + "No rules are higlighted" + ); + + const ruleEditor = getRuleViewRuleEditor(view, 1).rule.textProps[0].editor; + const computed = ruleEditor.computed; + + ok(ruleEditor.expander.getAttribute("open"), "Expander is open."); + ok( + !computed.hasAttribute("filter-open"), + "margin computed list does not contain filter-open class." + ); + ok( + computed.hasAttribute("user-open"), + "margin computed list contains user-open attribute." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter-media-queries-layers.js b/devtools/client/inspector/rules/test/browser_rules_search-filter-media-queries-layers.js new file mode 100644 index 0000000000..fb95e6adf8 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter-media-queries-layers.js @@ -0,0 +1,195 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter works properly for @media / @layer rules. +// The document uses selectors so we can identify rule more easily +const TEST_URI = ` + <!DOCTYPE html> + <style type='text/css'> + h1, simple { + color: tomato; + } + @layer { + h1, anonymous { + color: tomato; + } + } + @layer myLayer { + h1, named { + color: tomato; + } + } + @media screen { + h1, skreen { + color: tomato; + } + } + @layer { + @layer myLayer { + @media (min-width: 1px) { + @media (min-height: 1px) { + h1, nested { + color: tomato; + } + } + } + } + } + </style> + <h1>Hello Mochi</h1>`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("h1", inspector); + + info(`Check initial state and rules order`); + await checkRuleView(view, { + rules: [ + { selector: "h1, skreen", highlighted: [] }, + { selector: "h1, simple", highlighted: [] }, + { selector: "h1, nested", highlighted: [] }, + { selector: "h1, named", highlighted: [] }, + { selector: "h1, anonymous", highlighted: [] }, + ], + }); + + info(`Check filtering on "layer"`); + await setSearchFilter(view, `layer`); + await checkRuleView(view, { + rules: [ + { selector: "h1, nested", highlighted: ["@layer", "@layer myLayer"] }, + { selector: "h1, named", highlighted: ["@layer myLayer"] }, + { selector: "h1, anonymous", highlighted: ["@layer"] }, + ], + }); + + info(`Check filtering on "@layer"`); + await setNewSearchFilter(view, `@layer`); + await checkRuleView(view, { + rules: [ + { selector: "h1, nested", highlighted: ["@layer", "@layer myLayer"] }, + { selector: "h1, named", highlighted: ["@layer myLayer"] }, + { selector: "h1, anonymous", highlighted: ["@layer"] }, + ], + }); + + info("Check filtering on exact `@layer`"); + await setNewSearchFilter(view, "`@layer`"); + await checkRuleView(view, { + rules: [ + { selector: "h1, nested", highlighted: ["@layer"] }, + { selector: "h1, anonymous", highlighted: ["@layer"] }, + ], + }); + + info(`Check filtering on layer name "myLayer"`); + await setNewSearchFilter(view, `myLayer`); + await checkRuleView(view, { + rules: [ + { selector: "h1, nested", highlighted: ["@layer myLayer"] }, + { selector: "h1, named", highlighted: ["@layer myLayer"] }, + ], + }); + + info(`Check filtering on "@layer myLayer"`); + await setNewSearchFilter(view, `@layer myLayer`); + await checkRuleView(view, { + rules: [ + { selector: "h1, nested", highlighted: ["@layer myLayer"] }, + { selector: "h1, named", highlighted: ["@layer myLayer"] }, + ], + }); + + info(`Check filtering on "media"`); + await setNewSearchFilter(view, `media`); + await checkRuleView(view, { + rules: [ + { selector: "h1, skreen", highlighted: ["@media screen"] }, + { + selector: "h1, nested", + highlighted: ["@media (min-width: 1px)", "@media (min-height: 1px)"], + }, + ], + }); + + info(`Check filtering on "@media"`); + await setNewSearchFilter(view, `@media`); + await checkRuleView(view, { + rules: [ + { selector: "h1, skreen", highlighted: ["@media screen"] }, + { + selector: "h1, nested", + highlighted: ["@media (min-width: 1px)", "@media (min-height: 1px)"], + }, + ], + }); + + info(`Check filtering on media query content "1px"`); + await setNewSearchFilter(view, `1px`); + await checkRuleView(view, { + rules: [ + { + selector: "h1, nested", + highlighted: ["@media (min-width: 1px)", "@media (min-height: 1px)"], + }, + ], + }); + + info(`Check filtering on media query content "height"`); + await setNewSearchFilter(view, `height`); + await checkRuleView(view, { + rules: [ + { + selector: "h1, nested", + highlighted: ["@media (min-height: 1px)"], + }, + ], + }); + + info("Check filtering on exact `@media`"); + await setNewSearchFilter(view, "`@media`"); + await checkRuleView(view, { + rules: [], + }); +}); + +async function checkRuleView(view, { rules }) { + info("Check that the correct rules are visible"); + + const rulesInView = Array.from(view.element.children); + // The `element` "rule" is never filtered, so remove it from the list of element we check. + rulesInView.shift(); + + is(rulesInView.length, rules.length, "All expected rules are displayed"); + + for (let i = 0; i < rulesInView.length; i++) { + const rule = rulesInView[i]; + const selector = rule.querySelector( + ".ruleview-selectorcontainer" + ).innerText; + is(selector, rules[i]?.selector, `Expected selector at index ${i}`); + + const highlightedElements = Array.from( + rule.querySelectorAll(".ruleview-highlight") + ).map(el => el.innerText); + Assert.deepEqual( + highlightedElements, + rules[i]?.highlighted, + "The expected ancestor rules information element are highlighted" + ); + } +} + +async function setNewSearchFilter(view, newSearchText) { + const win = view.styleWindow; + const searchClearButton = view.searchClearButton; + + const onRuleViewCleared = view.inspector.once("ruleview-filtered"); + EventUtils.synthesizeMouseAtCenter(searchClearButton, {}, win); + await onRuleViewCleared; + + await setSearchFilter(view, newSearchText); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter-overridden-property.js b/devtools/client/inspector/rules/test/browser_rules_search-filter-overridden-property.js new file mode 100644 index 0000000000..442c543289 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter-overridden-property.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view overriden search filter works properly for +// overridden properties. + +const TEST_URI = ` + <style type='text/css'> + #testid { + width: 100%; + } + h1 { + width: 50%; + } + </style> + <h1 id='testid' class='testclass'>Styled Node</h1> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testFilterOverriddenProperty(inspector, view); +}); + +async function testFilterOverriddenProperty(inspector, ruleView) { + info("Check that the correct rules are visible"); + is(ruleView.element.children.length, 3, "Should have 3 rules."); + + let rule = getRuleViewRuleEditor(ruleView, 1).rule; + let textPropEditor = getTextProperty(ruleView, 1, { width: "100%" }).editor; + is(rule.selectorText, "#testid", "Second rule is #testid."); + ok( + !textPropEditor.element.classList.contains("ruleview-overridden"), + "width property is not overridden." + ); + ok( + textPropEditor.filterProperty.hidden, + "Overridden search button is hidden." + ); + + rule = getRuleViewRuleEditor(ruleView, 2).rule; + textPropEditor = getTextProperty(ruleView, 2, { width: "50%" }).editor; + is(rule.selectorText, "h1", "Third rule is h1."); + ok( + textPropEditor.element.classList.contains("ruleview-overridden"), + "width property is overridden." + ); + ok( + !textPropEditor.filterProperty.hidden, + "Overridden search button is not hidden." + ); + + const searchField = ruleView.searchField; + const onRuleViewFiltered = inspector.once("ruleview-filtered"); + + info("Click the overridden search"); + textPropEditor.filterProperty.click(); + await onRuleViewFiltered; + + info("Check that the overridden search is applied"); + is(searchField.value, "`width`", "The search field value is width."); + + rule = getRuleViewRuleEditor(ruleView, 1).rule; + textPropEditor = getTextProperty(ruleView, 1, { width: "100%" }).editor; + is(rule.selectorText, "#testid", "Second rule is #testid."); + ok( + textPropEditor.container.classList.contains("ruleview-highlight"), + "width property is correctly highlighted." + ); + + rule = getRuleViewRuleEditor(ruleView, 2).rule; + textPropEditor = getTextProperty(ruleView, 2, { width: "50%" }).editor; + is(rule.selectorText, "h1", "Third rule is h1."); + ok( + textPropEditor.container.classList.contains("ruleview-highlight"), + "width property is correctly highlighted." + ); + ok( + textPropEditor.element.classList.contains("ruleview-overridden"), + "width property is overridden." + ); + ok( + !textPropEditor.filterProperty.hidden, + "Overridden search button is not hidden." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter_01.js b/devtools/client/inspector/rules/test/browser_rules_search-filter_01.js new file mode 100644 index 0000000000..a0a3ed32ff --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter_01.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter and clear button works properly. + +const TEST_URI = ` + <style type="text/css"> + #testid, h1 { + background-color: #00F !important; + } + .testclass { + width: 100%; + } + </style> + <h1 id="testid" class="testclass">Styled Node</h1> +`; + +const TEST_DATA = [ + { + desc: "Tests that the search filter works properly for property names", + search: "color", + }, + { + desc: "Tests that the search filter works properly for property values", + search: "00F", + }, + { + desc: "Tests that the search filter works properly for property line input", + search: "background-color:#00F", + }, + { + desc: + "Tests that the search filter works properly for parsed property " + + "names", + search: "background:", + }, + { + desc: + "Tests that the search filter works properly for parsed property " + + "values", + search: ":00F", + }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testAddTextInFilter(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + for (const data of TEST_DATA) { + info(data.desc); + await setSearchFilter(view, data.search); + await checkRules(view); + await clearSearchAndCheckRules(view); + } +} + +function checkRules(view) { + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + const rule = getRuleViewRuleEditor(view, 1).rule; + + is(rule.selectorText, "#testid, h1", "Second rule is #testid, h1."); + ok( + rule.textProps[0].editor.container.classList.contains("ruleview-highlight"), + "background-color text property is correctly highlighted." + ); +} + +async function clearSearchAndCheckRules(view) { + const doc = view.styleDocument; + const win = view.styleWindow; + const searchField = view.searchField; + const searchClearButton = view.searchClearButton; + + info("Clearing the search filter"); + EventUtils.synthesizeMouseAtCenter(searchClearButton, {}, win); + await view.inspector.once("ruleview-filtered"); + + info("Check the search filter is cleared and no rules are highlighted"); + is(view.element.children.length, 3, "Should have 3 rules."); + ok(!searchField.value, "Search filter is cleared."); + ok( + !doc.querySelectorAll(".ruleview-highlight").length, + "No rules are higlighted." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter_02.js b/devtools/client/inspector/rules/test/browser_rules_search-filter_02.js new file mode 100644 index 0000000000..173da66296 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter_02.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter works properly for keyframe rule +// selectors. + +const SEARCH = "20%"; +const TEST_URI = URL_ROOT + "doc_keyframeanimation.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + await selectNode("#boxy", inspector); + await testAddTextInFilter(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + await setSearchFilter(view, SEARCH); + + info("Check that the correct rules are visible"); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + const ruleEditor = getRuleViewRuleEditor(view, 2, 0); + + is(ruleEditor.rule.domRule.keyText, "20%", "Second rule is 20%."); + ok( + ruleEditor.selectorText.classList.contains("ruleview-highlight"), + "20% selector is highlighted." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter_03.js b/devtools/client/inspector/rules/test/browser_rules_search-filter_03.js new file mode 100644 index 0000000000..18335c121c --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter_03.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter works properly for inline styles. + +const SEARCH = "color"; + +const TEST_URI = ` + <style type="text/css"> + #testid { + width: 100%; + } + </style> + <div id="testid" style="background-color:aliceblue">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testAddTextInFilter(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + await setSearchFilter(view, SEARCH); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 1, "Should have 1 rule."); + + const rule = getRuleViewRuleEditor(view, 0).rule; + + is(rule.selectorText, "element", "First rule is inline element."); + ok( + rule.textProps[0].editor.container.classList.contains("ruleview-highlight"), + "background-color text property is correctly highlighted." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter_04.js b/devtools/client/inspector/rules/test/browser_rules_search-filter_04.js new file mode 100644 index 0000000000..fc0eee9f19 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter_04.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter works properly when modifying the +// existing search filter value. + +const SEARCH = "00F"; + +const TEST_URI = ` + <style type="text/css"> + #testid { + background-color: #00F; + } + .testclass { + width: 100%; + } + </style> + <div id="testid" class="testclass">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testAddTextInFilter(inspector, view); + await testRemoveTextInFilter(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + await setSearchFilter(view, SEARCH); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + const rule = getRuleViewRuleEditor(view, 1).rule; + + is(rule.selectorText, "#testid", "Second rule is #testid."); + ok( + rule.textProps[0].editor.container.classList.contains("ruleview-highlight"), + "background-color text property is correctly highlighted." + ); +} + +async function testRemoveTextInFilter(inspector, view) { + info('Press backspace and set filter text to "00"'); + + const win = view.styleWindow; + const searchField = view.searchField; + + searchField.focus(); + EventUtils.synthesizeKey("VK_BACK_SPACE", {}, win); + await inspector.once("ruleview-filtered"); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 3, "Should have 3 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + let rule = getRuleViewRuleEditor(view, 1).rule; + is(rule.selectorText, "#testid", "Second rule is #testid."); + ok( + rule.textProps[0].editor.container.classList.contains("ruleview-highlight"), + "background-color text property is correctly highlighted." + ); + + rule = getRuleViewRuleEditor(view, 2).rule; + is(rule.selectorText, ".testclass", "Second rule is .testclass."); + ok( + rule.textProps[0].editor.container.classList.contains("ruleview-highlight"), + "width text property is correctly highlighted." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter_05.js b/devtools/client/inspector/rules/test/browser_rules_search-filter_05.js new file mode 100644 index 0000000000..8138528ee4 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter_05.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter works properly for stylesheet source. + +const SEARCH = "doc_urls_clickable.css"; +const TEST_URI = URL_ROOT + "doc_urls_clickable.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + await selectNode(".relative1", inspector); + await testAddTextInFilter(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + await setSearchFilter(view, SEARCH); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + const rule = getRuleViewRuleEditor(view, 1).rule; + const source = rule.textProps[0].editor.ruleEditor.source; + + is(rule.selectorText, ".relative1", "Second rule is .relative1."); + ok( + source.classList.contains("ruleview-highlight"), + "stylesheet source is correctly highlighted." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter_06.js b/devtools/client/inspector/rules/test/browser_rules_search-filter_06.js new file mode 100644 index 0000000000..a73fb87ff9 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter_06.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter does not highlight the source with +// input that could be parsed as a property line. + +const SEARCH = "doc_urls_clickable.css: url"; +const TEST_URI = URL_ROOT + "doc_urls_clickable.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + await selectNode(".relative1", inspector); + await testAddTextInFilter(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + await setSearchFilter(view, SEARCH); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 1, "Should have 1 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter_07.js b/devtools/client/inspector/rules/test/browser_rules_search-filter_07.js new file mode 100644 index 0000000000..e2c7494bf7 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter_07.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter works properly for newly modified +// property name. + +const SEARCH = "e"; + +const TEST_URI = ` + <style type='text/css'> + #testid { + width: 100%; + height: 50%; + } + </style> + <h1 id='testid'>Styled Node</h1> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + info("Enter the test value in the search filter"); + await setSearchFilter(view, SEARCH); + + info("Focus the width property name"); + const ruleEditor = getRuleViewRuleEditor(view, 1); + const rule = ruleEditor.rule; + const propEditor = rule.textProps[0].editor; + await focusEditableField(view, propEditor.nameSpan); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is(rule.selectorText, "#testid", "Second rule is #testid."); + ok( + !propEditor.container.classList.contains("ruleview-highlight"), + "width text property is not highlighted." + ); + ok( + rule.textProps[1].editor.container.classList.contains("ruleview-highlight"), + "height text property is correctly highlighted." + ); + + info("Change the width property to margin-left"); + EventUtils.sendString("margin-left", view.styleWindow); + + info("Submit the change"); + let onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + ok( + propEditor.container.classList.contains("ruleview-highlight"), + "margin-left text property is correctly highlighted." + ); + + // After pressing return on the property name, the value has been focused + // automatically. Blur it now and wait for the rule-view to refresh to avoid + // pending requests. + onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.synthesizeKey("KEY_Escape"); + await onRuleViewChanged; +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter_08.js b/devtools/client/inspector/rules/test/browser_rules_search-filter_08.js new file mode 100644 index 0000000000..b35afd7d10 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter_08.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter works properly for newly modified +// property value. + +const SEARCH = "100%"; + +const TEST_URI = ` + <style type='text/css'> + #testid { + width: 100%; + height: 50%; + } + </style> + <h1 id='testid'>Styled Node</h1> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + info("Enter the test value in the search filter"); + await setSearchFilter(view, SEARCH); + + info("Focus the height property value"); + const ruleEditor = getRuleViewRuleEditor(view, 1); + const rule = ruleEditor.rule; + const propEditor = rule.textProps[1].editor; + await focusEditableField(view, propEditor.valueSpan); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is(rule.selectorText, "#testid", "Second rule is #testid."); + ok( + rule.textProps[0].editor.container.classList.contains("ruleview-highlight"), + "width text property is correctly highlighted." + ); + ok( + !propEditor.container.classList.contains("ruleview-highlight"), + "height text property is not highlighted." + ); + + info("Change the height property value to 100%"); + const onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.sendString("100%", view.styleWindow); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + ok( + propEditor.container.classList.contains("ruleview-highlight"), + "height text property is correctly highlighted." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter_09.js b/devtools/client/inspector/rules/test/browser_rules_search-filter_09.js new file mode 100644 index 0000000000..0d1c8c8a45 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter_09.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter works properly for newly added +// property. + +const SEARCH = "100%"; + +const TEST_URI = ` + <style type='text/css'> + #testid { + width: 100%; + height: 50%; + } + </style> + <h1 id='testid'>Styled Node</h1> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + info("Enter the test value in the search filter"); + await setSearchFilter(view, SEARCH); + + info("Start entering a new property in the rule"); + const ruleEditor = getRuleViewRuleEditor(view, 1); + const rule = ruleEditor.rule; + const prop = getTextProperty(view, 1, { width: "100%" }); + let editor = await focusNewRuleViewProperty(ruleEditor); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is(rule.selectorText, "#testid", "Second rule is #testid."); + ok( + prop.editor.container.classList.contains("ruleview-highlight"), + "width text property is correctly highlighted." + ); + ok( + !getTextProperty(view, 1, { + height: "50%", + }).editor.container.classList.contains("ruleview-highlight"), + "height text property is not highlighted." + ); + + info("Test creating a new property"); + + info("Entering margin-left in the property name editor"); + // Changing the value doesn't cause a rule-view refresh, no need to wait for + // ruleview-changed here. + editor.input.value = "margin-left"; + + info("Pressing return to commit and focus the new value field"); + let onRuleViewChanged = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + await onRuleViewChanged; + + // Getting the new value editor after focus + editor = inplaceEditor(view.styleDocument.activeElement); + const propEditor = ruleEditor.rule.textProps[2].editor; + + info("Entering a value and bluring the field to expect a rule change"); + onRuleViewChanged = view.once("ruleview-changed"); + editor.input.value = "100%"; + view.debounce.flush(); + await onRuleViewChanged; + + onRuleViewChanged = view.once("ruleview-changed"); + editor.input.blur(); + await onRuleViewChanged; + + ok( + propEditor.container.classList.contains("ruleview-highlight"), + "margin-left text property is correctly highlighted." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter_10.js b/devtools/client/inspector/rules/test/browser_rules_search-filter_10.js new file mode 100644 index 0000000000..b3ee69c2b9 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter_10.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter works properly for rule selectors. + +const TEST_URI = ` + <style type="text/css"> + html, body, div { + background-color: #00F; + } + #testid { + width: 100%; + } + </style> + <div id="testid" class="testclass">Styled Node</div> +`; + +const TEST_DATA = [ + { + desc: + "Tests that the search filter works properly for a single rule " + + "selector", + search: "#test", + selectorText: "#testid", + index: 0, + }, + { + desc: + "Tests that the search filter works properly for multiple rule " + + "selectors", + search: "body", + selectorText: "html, body, div", + index: 2, + }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testAddTextInFilter(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + for (const data of TEST_DATA) { + info(data.desc); + await setSearchFilter(view, data.search); + await checkRules(view, data); + await clearSearchAndCheckRules(view); + } +} + +function checkRules(view, data) { + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + const ruleEditor = getRuleViewRuleEditor(view, 1); + + is( + ruleEditor.rule.selectorText, + data.selectorText, + "Second rule is " + data.selectorText + "." + ); + ok( + ruleEditor.selectorText.children[data.index].classList.contains( + "ruleview-highlight" + ), + data.selectorText + " selector is highlighted." + ); +} + +async function clearSearchAndCheckRules(view) { + const doc = view.styleDocument; + const win = view.styleWindow; + const searchField = view.searchField; + const searchClearButton = view.searchClearButton; + + info("Clearing the search filter"); + EventUtils.synthesizeMouseAtCenter(searchClearButton, {}, win); + await view.inspector.once("ruleview-filtered"); + + info("Check the search filter is cleared and no rules are highlighted"); + is(view.element.children.length, 3, "Should have 3 rules."); + ok(!searchField.value, "Search filter is cleared."); + ok( + !doc.querySelectorAll(".ruleview-highlight").length, + "No rules are higlighted." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter_context-menu.js b/devtools/client/inspector/rules/test/browser_rules_search-filter_context-menu.js new file mode 100644 index 0000000000..881b5274ee --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter_context-menu.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test rule view search filter context menu works properly. + +const TEST_INPUT = "h1"; +const TEST_URI = "<h1>test filter context menu</h1>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { toolbox, inspector, view } = await openRuleView(); + await selectNode("h1", inspector); + + const searchField = view.searchField; + + info("Opening context menu"); + + emptyClipboard(); + + const onFocus = once(searchField, "focus"); + searchField.focus(); + await onFocus; + + let onContextMenuOpen = toolbox.once("menu-open"); + synthesizeContextMenuEvent(searchField); + await onContextMenuOpen; + + let searchContextMenu = toolbox.getTextBoxContextMenu(); + ok( + searchContextMenu, + "The search filter context menu is loaded in the rule view" + ); + + let cmdUndo = searchContextMenu.querySelector("#editmenu-undo"); + let cmdDelete = searchContextMenu.querySelector("#editmenu-delete"); + let cmdSelectAll = searchContextMenu.querySelector("#editmenu-selectAll"); + let cmdCut = searchContextMenu.querySelector("#editmenu-cut"); + let cmdCopy = searchContextMenu.querySelector("#editmenu-copy"); + let cmdPaste = searchContextMenu.querySelector("#editmenu-paste"); + + is(cmdUndo.getAttribute("disabled"), "true", "cmdUndo is disabled"); + is(cmdDelete.getAttribute("disabled"), "true", "cmdDelete is disabled"); + is(cmdSelectAll.getAttribute("disabled"), "true", "cmdSelectAll is disabled"); + is(cmdCut.getAttribute("disabled"), "true", "cmdCut is disabled"); + is(cmdCopy.getAttribute("disabled"), "true", "cmdCopy is disabled"); + + if (isWindows()) { + // emptyClipboard only works on Windows (666254), assert paste only for this OS. + is(cmdPaste.getAttribute("disabled"), "true", "cmdPaste is disabled"); + } + + info("Closing context menu"); + let onContextMenuClose = toolbox.once("menu-close"); + searchContextMenu.hidePopup(); + await onContextMenuClose; + + info("Copy text in search field using the context menu"); + searchField.setUserInput(TEST_INPUT); + searchField.select(); + + onContextMenuOpen = toolbox.once("menu-open"); + synthesizeContextMenuEvent(searchField); + await onContextMenuOpen; + + searchContextMenu = toolbox.getTextBoxContextMenu(); + cmdCopy = searchContextMenu.querySelector("#editmenu-copy"); + await waitForClipboardPromise(() => cmdCopy.click(), TEST_INPUT); + + onContextMenuClose = toolbox.once("menu-close"); + searchContextMenu.hidePopup(); + await onContextMenuClose; + + info("Reopen context menu and check command properties"); + + onContextMenuOpen = toolbox.once("menu-open"); + synthesizeContextMenuEvent(searchField); + await onContextMenuOpen; + + searchContextMenu = toolbox.getTextBoxContextMenu(); + cmdUndo = searchContextMenu.querySelector("#editmenu-undo"); + cmdDelete = searchContextMenu.querySelector("#editmenu-delete"); + cmdSelectAll = searchContextMenu.querySelector("#editmenu-selectAll"); + cmdCut = searchContextMenu.querySelector("#editmenu-cut"); + cmdCopy = searchContextMenu.querySelector("#editmenu-copy"); + cmdPaste = searchContextMenu.querySelector("#editmenu-paste"); + + is(cmdUndo.getAttribute("disabled"), "", "cmdUndo is enabled"); + is(cmdDelete.getAttribute("disabled"), "", "cmdDelete is enabled"); + is(cmdSelectAll.getAttribute("disabled"), "", "cmdSelectAll is enabled"); + is(cmdCut.getAttribute("disabled"), "", "cmdCut is enabled"); + is(cmdCopy.getAttribute("disabled"), "", "cmdCopy is enabled"); + is(cmdPaste.getAttribute("disabled"), "", "cmdPaste is enabled"); + + const onContextMenuHidden = toolbox.once("menu-close"); + searchContextMenu.hidePopup(); + await onContextMenuHidden; +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_search-filter_escape-keypress.js b/devtools/client/inspector/rules/test/browser_rules_search-filter_escape-keypress.js new file mode 100644 index 0000000000..600aefe536 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_search-filter_escape-keypress.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view search filter escape keypress will clear the search +// field. + +const SEARCH = "00F"; + +const TEST_URI = ` + <style type="text/css"> + #testid { + background-color: #00F; + } + .testclass { + width: 100%; + } + </style> + <div id="testid" class="testclass">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testAddTextInFilter(inspector, view); + await testEscapeKeypress(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + await setSearchFilter(view, SEARCH); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + const rule = getRuleViewRuleEditor(view, 1).rule; + const prop = getTextProperty(view, 1, { "background-color": "#00F" }); + + is(rule.selectorText, "#testid", "Second rule is #testid."); + ok( + prop.editor.container.classList.contains("ruleview-highlight"), + "background-color text property is correctly highlighted." + ); +} + +async function testEscapeKeypress(inspector, view) { + info("Pressing the escape key on search filter"); + + const doc = view.styleDocument; + const win = view.styleWindow; + const searchField = view.searchField; + const onRuleViewFiltered = inspector.once("ruleview-filtered"); + + searchField.focus(); + EventUtils.synthesizeKey("VK_ESCAPE", {}, win); + await onRuleViewFiltered; + + info("Check the search filter is cleared and no rules are highlighted"); + is(view.element.children.length, 3, "Should have 3 rules."); + ok(!searchField.value, "Search filter is cleared"); + ok( + !doc.querySelectorAll(".ruleview-highlight").length, + "No rules are higlighted" + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_select-and-copy-styles.js b/devtools/client/inspector/rules/test/browser_rules_select-and-copy-styles.js new file mode 100644 index 0000000000..8558c40cab --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_select-and-copy-styles.js @@ -0,0 +1,210 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that properties can be selected and copied from the rule view + +const osString = Services.appinfo.OS; + +const TEST_URI = ` + <style type="text/css"> + html { + color: #000000; + } + span { + font-variant: small-caps; color: #000000; + } + .nomatches { + color: #ff0000; + } + </style> + <div id="first" style="margin: 10em; + font-size: 14pt; font-family: helvetica, sans-serif; color: #AAA"> + <h1>Some header text</h1> + <p id="salutation" style="font-size: 12pt">hi.</p> + <p id="body" style="font-size: 12pt">I am a test-case. This text exists + solely to provide some things to <span style="color: yellow"> + highlight</span> and <span style="font-weight: bold">count</span> + style list-items in the box at right. If you are reading this, + you should go do something else instead. Maybe read a book. Or better + yet, write some test-cases for another bit of code. + <span style="font-style: italic">some text</span></p> + <p id="closing">more text</p> + <p>even more text</p> + </div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + await checkCopySelection(view); + await checkSelectAll(view); + await checkCopyEditorValue(view); +}); + +async function checkCopySelection(view) { + info("Testing selection copy"); + + const contentDoc = view.styleDocument; + const win = view.styleWindow; + const prop = contentDoc.querySelector(".ruleview-property"); + const values = contentDoc.querySelectorAll( + ".ruleview-propertyvaluecontainer" + ); + + let range = contentDoc.createRange(); + range.setStart(prop, 0); + range.setEnd(values[4], 2); + win.getSelection().addRange(range); + info("Checking that _Copy() returns the correct clipboard value"); + + const expectedPattern = + " margin: 10em;[\\r\\n]+" + + " font-size: 14pt;[\\r\\n]+" + + " font-family: helvetica, sans-serif;[\\r\\n]+" + + " color: #AAA;[\\r\\n]+" + + "}[\\r\\n]+" + + "html {[\\r\\n]+" + + " color: #000000;[\\r\\n]*"; + + const allMenuItems = openStyleContextMenuAndGetAllItems(view, prop); + const menuitemCopy = allMenuItems.find( + item => + item.label === + STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copy") + ); + + ok(menuitemCopy.visible, "Copy menu item is displayed as expected"); + + try { + await waitForClipboardPromise( + () => menuitemCopy.click(), + () => checkClipboardData(expectedPattern) + ); + } catch (e) { + failedClipboard(expectedPattern); + } + + info("Check copying from keyboard"); + win.getSelection().removeRange(range); + // Selecting the declaration `margin: 10em;` + range = contentDoc.createRange(); + range.setStart(prop, 0); + range.setEnd(prop, 1); + win.getSelection().addRange(range); + + // Dispatching the copy event from the checkbox to make sure we cover Bug 1680893. + const declarationCheckbox = contentDoc.querySelector( + "input[type=checkbox].ruleview-enableproperty" + ); + const copyEvent = new win.Event("copy", { bubbles: true }); + await waitForClipboardPromise( + () => declarationCheckbox.dispatchEvent(copyEvent), + () => checkClipboardData("^margin: 10em;$") + ); +} + +async function checkSelectAll(view) { + info("Testing select-all copy"); + + const contentDoc = view.styleDocument; + const prop = contentDoc.querySelector(".ruleview-property"); + + info( + "Checking that _SelectAll() then copy returns the correct " + + "clipboard value" + ); + view.contextMenu._onSelectAll(); + const expectedPattern = + "element {[\\r\\n]+" + + " margin: 10em;[\\r\\n]+" + + " font-size: 14pt;[\\r\\n]+" + + " font-family: helvetica, sans-serif;[\\r\\n]+" + + " color: #AAA;[\\r\\n]+" + + "}[\\r\\n]+" + + "html {[\\r\\n]+" + + " color: #000000;[\\r\\n]+" + + "}[\\r\\n]*"; + + const allMenuItems = openStyleContextMenuAndGetAllItems(view, prop); + const menuitemCopy = allMenuItems.find( + item => + item.label === + STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copy") + ); + + ok(menuitemCopy.visible, "Copy menu item is displayed as expected"); + + try { + await waitForClipboardPromise( + () => menuitemCopy.click(), + () => checkClipboardData(expectedPattern) + ); + } catch (e) { + failedClipboard(expectedPattern); + } +} + +async function checkCopyEditorValue(view) { + info("Testing CSS property editor value copy"); + + const ruleEditor = getRuleViewRuleEditor(view, 0); + const propEditor = ruleEditor.rule.textProps[0].editor; + + const editor = await focusEditableField(view, propEditor.valueSpan); + + info( + "Checking that copying a css property value editor returns the correct" + + " clipboard value" + ); + + const expectedPattern = "10em"; + + const allMenuItems = openStyleContextMenuAndGetAllItems(view, editor.input); + const menuitemCopy = allMenuItems.find( + item => + item.label === + STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copy") + ); + + ok(menuitemCopy.visible, "Copy menu item is displayed as expected"); + + try { + await waitForClipboardPromise( + () => menuitemCopy.click(), + () => checkClipboardData(expectedPattern) + ); + } catch (e) { + failedClipboard(expectedPattern); + } +} + +function checkClipboardData(expectedPattern) { + const actual = SpecialPowers.getClipboardData("text/plain"); + const expectedRegExp = new RegExp(expectedPattern, "g"); + return expectedRegExp.test(actual); +} + +function failedClipboard(expectedPattern) { + // Format expected text for comparison + const terminator = osString == "WINNT" ? "\r\n" : "\n"; + expectedPattern = expectedPattern.replace(/\[\\r\\n\][+*]/g, terminator); + expectedPattern = expectedPattern.replace(/\\\(/g, "("); + expectedPattern = expectedPattern.replace(/\\\)/g, ")"); + + let actual = SpecialPowers.getClipboardData("text/plain"); + + // Trim the right hand side of our strings. This is because expectedPattern + // accounts for windows sometimes adding a newline to our copied data. + expectedPattern = expectedPattern.trimRight(); + actual = actual.trimRight(); + + dump( + "TEST-UNEXPECTED-FAIL | Clipboard text does not match expected ... " + + "results (escaped for accurate comparison):\n" + ); + info("Actual: " + escape(actual)); + info("Expected: " + escape(expectedPattern)); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_selector-highlighter-iframe-picker.js b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter-iframe-picker.js new file mode 100644 index 0000000000..51d4a9b371 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter-iframe-picker.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the selector highlighter is hidden when selecting frames in the iframe picker + +const TEST_URI = ` + <style type="text/css"> + body { + background: red; + } + </style> + <h1>Test the selector highlighter</h1> + <iframe src="data:text/html,<meta charset=utf8><style>h2 {background: yellow;}</style><h2>In iframe</h2>"> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, toolbox, view } = await openRuleView(); + + info("Clicking on a selector icon"); + const { highlighter, isShown } = await clickSelectorIcon(view, "body"); + + ok(highlighter, "The selector highlighter instance was created"); + ok(isShown, "The selector highlighter was shown"); + is( + highlighter, + inspector.highlighters.getActiveHighlighter( + inspector.highlighters.TYPES.SELECTOR + ), + "The selector highlighter is the active highlighter" + ); + + // Open frame menu and wait till it's available on the screen. + const panel = toolbox.doc.getElementById("command-button-frames-panel"); + const btn = toolbox.doc.getElementById("command-button-frames"); + btn.click(); + ok(panel, "popup panel has created."); + await waitUntil(() => panel.classList.contains("tooltip-visible")); + + // Verify that the menu is populated. + const menuList = toolbox.doc.getElementById("toolbox-frame-menu"); + const frames = Array.from(menuList.querySelectorAll(".command")); + + // Wait for the inspector to be reloaded + // (instead of only new-root) in order to wait for full + // async update of the inspector. + const onNewRoot = inspector.once("reloaded"); + frames[1].click(); + await onNewRoot; + + await waitFor( + () => + !inspector.highlighters.getActiveHighlighter( + inspector.highlighters.TYPES.SELECTOR + ) + ); + ok(true, "The selector highlighter gets hidden after selecting a frame"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_selector-highlighter-on-navigate.js b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter-on-navigate.js new file mode 100644 index 0000000000..3a014a68f7 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter-on-navigate.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the selector highlighter is hidden on page navigation. + +const TEST_URI = ` + <style type="text/css"> + body, p, td { + background: red; + } + </style> + Test the selector highlighter +`; + +const TEST_URI_2 = "data:text/html,<html><body>test</body></html>"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + info("Clicking on a selector icon"); + const { highlighter, isShown } = await clickSelectorIcon(view, "body, p, td"); + + ok(highlighter, "The selector highlighter instance was created"); + ok(isShown, "The selector highlighter was shown"); + + await navigateTo(TEST_URI_2); + + const activeHighlighter = inspector.highlighters.getActiveHighlighter( + inspector.highlighters.TYPES.SELECTOR + ); + ok(!activeHighlighter, "No selector highlighter is active"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_01.js b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_01.js new file mode 100644 index 0000000000..a1010131a9 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_01.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the selector highlighter is created when clicking on a selector +// icon in the rule view. + +const TEST_URI = ` + <style type="text/css"> + body, p, td { + background: red; + } + </style> + Test the selector highlighter +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + const activeHighlighter = inspector.highlighters.getActiveHighlighter( + inspector.highlighters.TYPES.SELECTOR + ); + ok(!activeHighlighter, "No selector highlighter is active"); + + info("Clicking on a selector icon"); + const { highlighter, isShown } = await clickSelectorIcon(view, "body, p, td"); + + ok(highlighter, "The selector highlighter instance was created"); + ok(isShown, "The selector highlighter was shown"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_02.js b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_02.js new file mode 100644 index 0000000000..3d6ca9cbb4 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_02.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the selector highlighter is shown when clicking on a selector icon +// in the rule-view + +const TEST_URI = ` + <style type="text/css"> + body { + background: red; + } + p { + color: white; + } + </style> + <p>Testing the selector highlighter</p> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + let data; + + info("Clicking once on the body selector highlighter icon"); + data = await clickSelectorIcon(view, "body"); + ok(data.isShown, "The highlighter is shown"); + + info("Clicking once again on the body selector highlighter icon"); + data = await clickSelectorIcon(view, "body"); + ok(!data.isShown, "The highlighter is hidden"); + + info("Checking that the right NodeFront reference and options are passed"); + await selectNode("p", inspector); + data = await clickSelectorIcon(view, "p"); + + is( + data.nodeFront.tagName, + "P", + "The right NodeFront is passed to the highlighter" + ); + is( + data.options.selector, + "p", + "The right selector option is passed to the highlighter" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_03.js b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_03.js new file mode 100644 index 0000000000..1c43e6adad --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_03.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 selector highlighter toggling mechanism works correctly. + +const TEST_URI = ` + <style type="text/css"> + div {text-decoration: underline;} + .node-1 {color: red;} + .node-2 {color: green;} + </style> + <div class="node-1">Node 1</div> + <div class="node-2">Node 2</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + let data; + + info("Select .node-1 and click on the .node-1 selector icon"); + await selectNode(".node-1", inspector); + data = await clickSelectorIcon(view, ".node-1"); + ok(data.isShown, "The highlighter is shown"); + + info("With .node-1 still selected, click again on the .node-1 selector icon"); + data = await clickSelectorIcon(view, ".node-1"); + ok(!data.isShown, "The highlighter is now hidden"); + + info("With .node-1 still selected, click on the div selector icon"); + data = await clickSelectorIcon(view, "div"); + ok(data.isShown, "The highlighter is shown again"); + + info("With .node-1 still selected, click again on the .node-1 selector icon"); + data = await clickSelectorIcon(view, ".node-1"); + ok( + data.isShown, + "The highlighter is shown again since the clicked selector was different" + ); + + info("Selecting .node-2"); + await selectNode(".node-2", inspector); + const activeHighlighter = inspector.highlighters.getActiveHighlighter( + inspector.highlighters.TYPES.SELECTOR + ); + ok(activeHighlighter, "The highlighter is still shown after selection"); + + info("With .node-2 selected, click on the div selector icon"); + data = await clickSelectorIcon(view, "div"); + ok( + data.isShown, + "The highlighter is shown still since the selected was different" + ); + + info("Switching back to .node-1 and clicking on the div selector"); + await selectNode(".node-1", inspector); + data = await clickSelectorIcon(view, "div"); + ok( + !data.isShown, + "The highlighter is hidden now that the same selector was clicked" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_04.js b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_04.js new file mode 100644 index 0000000000..46def76f05 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_04.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the selector highlighter is shown when clicking on a selector icon +// for the 'element {}' rule + +const TEST_URI = ` +<p>Testing the selector highlighter for the 'element {}' rule</p> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + let data; + + info("Checking that the right NodeFront reference and options are passed"); + await selectNode("p", inspector); + data = await clickSelectorIcon(view, "element"); + is( + data.nodeFront.tagName, + "P", + "The right NodeFront is passed to the highlighter (1)" + ); + is( + data.options.selector, + "body > p:nth-child(1)", + "The right selector option is passed to the highlighter (1)" + ); + ok(data.isShown, "The toggle event says the highlighter is visible"); + + data = await clickSelectorIcon(view, "element"); + ok(!data.isShown, "The toggle event says the highlighter is not visible"); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_05.js b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_05.js new file mode 100644 index 0000000000..e5538c682f --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_05.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the selector highlighter is correctly shown when clicking on a +// inherited element + +const TEST_URI = ` +<div style="cursor:pointer"> + A + <div style="cursor:pointer"> + B<a>Cursor</a> + </div> +</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + let data; + + info("Checking that the right NodeFront reference and options are passed"); + await selectNode("a", inspector); + + data = await clickSelectorIcon(view, "element"); + is( + data.options.selector, + "body > div:nth-child(1) > div:nth-child(1) > a:nth-child(1)", + "The right selector option is passed to the highlighter (1)" + ); + + data = await clickSelectorIcon(view, "element", 1); + is( + data.options.selector, + "body > div:nth-child(1) > div:nth-child(1)", + "The right selector option is passed to the highlighter (1)" + ); + + data = await clickSelectorIcon(view, "element", 2); + is( + data.options.selector, + "body > div:nth-child(1)", + "The right selector option is passed to the highlighter (1)" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_order.js b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_order.js new file mode 100644 index 0000000000..856ab7a840 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_selector-highlighter_order.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = ` +<style type="text/css"> + #rule-from-stylesheet { + color: red; + } +</style> +<div id=inline style="cursor:pointer"> + A + <div id=inherited>B</div> +</div> +<div id=rule-from-stylesheet>C</a> +`; + +// This test will assert that specific elements of a ruleview rule have been +// rendered in the expected order. This is specifically done to check the fix +// for Bug 1664511, where some elements were rendered out of order due to +// unexpected async processing. +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await selectNode("#inline", inspector); + checkRuleViewRuleMarkupOrder(view, "element"); + await selectNode("#inherited", inspector); + checkRuleViewRuleMarkupOrder(view, "element", 1); + await selectNode("#rule-from-stylesheet", inspector); + checkRuleViewRuleMarkupOrder(view, "#rule-from-stylesheet"); +}); + +function checkRuleViewRuleMarkupOrder(view, selectorText, index = 0) { + const rule = getRuleViewRule(view, selectorText, index); + + // Retrieve the individual elements to assert. + const selectorContainer = rule.querySelector(".ruleview-selectorcontainer"); + const highlighterIcon = rule.querySelector(".js-toggle-selector-highlighter"); + const ruleOpenBrace = rule.querySelector(".ruleview-ruleopen"); + + const parentNode = selectorContainer.parentNode; + const childNodes = [...parentNode.childNodes]; + + ok( + childNodes.indexOf(selectorContainer) < childNodes.indexOf(highlighterIcon), + "Selector text is rendered before the highlighter icon" + ); + ok( + childNodes.indexOf(highlighterIcon) < childNodes.indexOf(ruleOpenBrace), + "Highlighter icon is rendered before the opening brace" + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_selector_highlight.js b/devtools/client/inspector/rules/test/browser_rules_selector_highlight.js new file mode 100644 index 0000000000..1d3620e24b --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_selector_highlight.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view selector text is highlighted correctly according +// to the components of the selector. + +const TEST_URI = [ + "<style type='text/css'>", + " h1 {}", + " h1#testid {}", + " h1 + p {}", + ' div[hidden="true"] {}', + ' div[title="test"][checked=true] {}', + " p:empty {}", + " p:lang(en) {}", + " .testclass:active {}", + " .testclass:focus {}", + " .testclass:hover {}", + "</style>", + "<h1>Styled Node</h1>", + "<p>Paragraph</p>", + '<h1 id="testid">Styled Node</h1>', + '<div hidden="true"></div>', + '<div title="test" checked="true"></div>', + "<p></p>", + '<p lang="en">Paragraph<p>', + '<div class="testclass">Styled Node</div>', +].join("\n"); + +const SELECTOR_ATTRIBUTE = "ruleview-selector-attribute"; +const SELECTOR_ELEMENT = "ruleview-selector"; +const SELECTOR_PSEUDO_CLASS = "ruleview-selector-pseudo-class"; +const SELECTOR_PSEUDO_CLASS_LOCK = "ruleview-selector-pseudo-class-lock"; + +const TEST_DATA = [ + { + node: "h1", + expected: [{ value: "h1", class: SELECTOR_ELEMENT }], + }, + { + node: "h1 + p", + expected: [{ value: "h1 + p", class: SELECTOR_ELEMENT }], + }, + { + node: "h1#testid", + expected: [{ value: "h1#testid", class: SELECTOR_ELEMENT }], + }, + { + node: "div[hidden='true']", + expected: [ + { value: "div", class: SELECTOR_ELEMENT }, + { value: '[hidden="true"]', class: SELECTOR_ATTRIBUTE }, + ], + }, + { + node: 'div[title="test"][checked="true"]', + expected: [ + { value: "div", class: SELECTOR_ELEMENT }, + { value: '[title="test"]', class: SELECTOR_ATTRIBUTE }, + { value: '[checked="true"]', class: SELECTOR_ATTRIBUTE }, + ], + }, + { + node: "p:empty", + expected: [ + { value: "p", class: SELECTOR_ELEMENT }, + { value: ":empty", class: SELECTOR_PSEUDO_CLASS }, + ], + }, + { + node: "p:lang(en)", + expected: [ + { value: "p", class: SELECTOR_ELEMENT }, + { value: ":lang(en)", class: SELECTOR_PSEUDO_CLASS }, + ], + }, + { + node: ".testclass", + pseudoClass: ":active", + expected: [ + { value: ".testclass", class: SELECTOR_ELEMENT }, + { value: ":active", class: SELECTOR_PSEUDO_CLASS_LOCK }, + ], + }, + { + node: ".testclass", + pseudoClass: ":focus", + expected: [ + { value: ".testclass", class: SELECTOR_ELEMENT }, + { value: ":focus", class: SELECTOR_PSEUDO_CLASS_LOCK }, + ], + }, + { + node: ".testclass", + pseudoClass: ":hover", + expected: [ + { value: ".testclass", class: SELECTOR_ELEMENT }, + { value: ":hover", class: SELECTOR_PSEUDO_CLASS_LOCK }, + ], + }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + for (const { node, pseudoClass, expected } of TEST_DATA) { + await selectNode(node, inspector); + + if (pseudoClass) { + const onRefresh = inspector.once("rule-view-refreshed"); + inspector.togglePseudoClass(pseudoClass); + await onRefresh; + } + + const selectorContainer = getRuleViewRuleEditor(view, 1).selectorText + .firstChild; + + if (selectorContainer.children.length === expected.length) { + for (let i = 0; i < expected.length; i++) { + is( + expected[i].value, + selectorContainer.children[i].textContent, + "Got expected selector value: " + + expected[i].value + + " == " + + selectorContainer.children[i].textContent + ); + is( + expected[i].class, + selectorContainer.children[i].className, + "Got expected class name: " + + expected[i].class + + " == " + + selectorContainer.children[i].className + ); + } + } else { + for (const selector of selectorContainer.children) { + info( + "Actual selector components: { value: " + + selector.textContent + + ", class: " + + selector.className + + " }\n" + ); + } + } + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_shadowdom_slot_rules.js b/devtools/client/inspector/rules/test/browser_rules_shadowdom_slot_rules.js new file mode 100644 index 0000000000..f0d3a17d1e --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_shadowdom_slot_rules.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that when selecting a slot element, the rule view displays the rules for the +// corresponding element. + +const TEST_URL = + `data:text/html;charset=utf-8,` + + encodeURIComponent(` + <html> + <head> + <style> + #el1 { color: red } + #el2 { color: blue } + </style> + </head> + <body> + <test-component> + <div slot="slot1" id="el1">slot1-1</div> + <div slot="slot1" id="el2">slot1-2</div> + <div slot="slot1" id="el3">slot1-2</div> + </test-component> + + <script> + 'use strict'; + customElements.define('test-component', class extends HTMLElement { + constructor() { + super(); + let shadowRoot = this.attachShadow({mode: 'open'}); + shadowRoot.innerHTML = \` + <style> + ::slotted(#el3) { + color: green; + } + </style> + <slot name="slot1"></slot> + \`; + } + }); + </script> + </body> + </html> +`); + +add_task(async function () { + const { inspector } = await openInspectorForURL(TEST_URL); + const { markup } = inspector; + const ruleview = inspector.getPanel("ruleview").view; + + // <test-component> is a shadow host. + info("Find and expand the test-component shadow DOM host."); + const hostFront = await getNodeFront("test-component", inspector); + + await markup.expandNode(hostFront); + await waitForMultipleChildrenUpdates(inspector); + + info( + "Test that expanding a shadow host shows shadow root and one host child." + ); + const hostContainer = markup.getContainer(hostFront); + + info("Expand the shadow root"); + const childContainers = hostContainer.getChildContainers(); + const shadowRootContainer = childContainers[0]; + await expandContainer(inspector, shadowRootContainer); + + info("Expand the slot"); + const shadowChildContainers = shadowRootContainer.getChildContainers(); + // shadowChildContainers[0] is the style node. + const slotContainer = shadowChildContainers[1]; + await expandContainer(inspector, slotContainer); + + const slotChildContainers = slotContainer.getChildContainers(); + is(slotChildContainers.length, 3, "Expecting 3 slotted children"); + + info( + "Select slotted node and check that the rule view displays correct content" + ); + await selectNode(slotChildContainers[0].node, inspector); + checkRule(ruleview, "#el1", "color", "red"); + + info("Select another slotted node and check the rule view"); + await selectNode(slotChildContainers[1].node, inspector); + checkRule(ruleview, "#el2", "color", "blue"); + + info("Select the last slotted node and check the rule view"); + await selectNode(slotChildContainers[2].node, inspector); + checkRule(ruleview, "::slotted(#el3)", "color", "green"); +}); + +function checkRule(ruleview, selector, name, expectedValue) { + const rule = getRuleViewRule(ruleview, selector); + ok(rule, "ruleview shows the expected rule for slotted " + selector); + const value = getRuleViewPropertyValue(ruleview, selector, name); + is( + value, + expectedValue, + "ruleview shows the expected value for slotted " + selector + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_01.js b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_01.js new file mode 100644 index 0000000000..a3fd4ccd3e --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_01.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the shapes highlighter in the rule view and the display of the +// shapes highlighter. + +const TEST_URI = ` + <style type='text/css'> + #shape { + width: 800px; + height: 800px; + clip-path: circle(25%); + } + </style> + <div id="shape"></div> +`; + +const HIGHLIGHTER_TYPE = "ShapesHighlighter"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = view.highlighters; + + info("Select a node with a shape value"); + await selectNode("#shape", inspector); + const container = getRuleViewProperty(view, "#shape", "clip-path").valueSpan; + const shapesToggle = container.querySelector(".ruleview-shapeswatch"); + + info("Checking the initial state of the CSS shape toggle in the rule-view."); + ok(shapesToggle, "Shapes highlighter toggle is visible."); + ok( + !shapesToggle.classList.contains("active"), + "Shapes highlighter toggle button is not active." + ); + ok( + !highlighters.highlighters[HIGHLIGHTER_TYPE], + "No CSS shapes highlighter exists in the rule-view." + ); + ok( + !highlighters.shapesHighlighterShown, + "No CSS shapes highlighter is shown." + ); + info("Toggling ON the CSS shapes highlighter from the rule-view."); + const onHighlighterShown = highlighters.once("shapes-highlighter-shown"); + shapesToggle.click(); + await onHighlighterShown; + + info( + "Checking the CSS shapes highlighter is created and toggle button is active in " + + "the rule-view." + ); + ok( + shapesToggle.classList.contains("active"), + "Shapes highlighter toggle is active." + ); + ok( + inspector.inspectorFront.getKnownHighlighter(HIGHLIGHTER_TYPE).actorID, + "CSS shapes highlighter created in the rule-view." + ); + ok(highlighters.shapesHighlighterShown, "CSS shapes highlighter is shown."); + + info("Toggling OFF the CSS shapes highlighter from the rule-view."); + const onHighlighterHidden = highlighters.once("shapes-highlighter-hidden"); + shapesToggle.click(); + await onHighlighterHidden; + + info( + "Checking the CSS shapes highlighter is not shown and toggle button is not " + + "active in the rule-view." + ); + ok( + !shapesToggle.classList.contains("active"), + "shapes highlighter toggle button is not active." + ); + ok( + !highlighters.shapesHighlighterShown, + "No CSS shapes highlighter is shown." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_02.js b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_02.js new file mode 100644 index 0000000000..3d2cd0d9e0 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_02.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the swatch to toggle a shapes highlighter does not show up +// on overwritten properties. + +const TEST_URI = ` + <style type='text/css'> + #shape { + width: 800px; + height: 800px; + clip-path: circle(25%); + } + div { + clip-path: circle(30%); + } + </style> + <div id="shape"></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await selectNode("#shape", inspector); + const container = getRuleViewProperty(view, "#shape", "clip-path").valueSpan; + const shapeToggle = container.querySelector(".ruleview-shapeswatch"); + const shapeToggleStyle = getComputedStyle(shapeToggle); + const overriddenContainer = getRuleViewProperty( + view, + "div", + "clip-path" + ).valueSpan; + const overriddenShapeToggle = overriddenContainer.querySelector( + ".ruleview-shapeswatch" + ); + const overriddenShapeToggleStyle = getComputedStyle(overriddenShapeToggle); + + ok( + shapeToggle && overriddenShapeToggle, + "Shapes highlighter toggles exist in the DOM." + ); + ok( + !shapeToggle.classList.contains("active") && + !overriddenShapeToggle.classList.contains("active"), + "Shapes highlighter toggle buttons are not active." + ); + + isnot( + shapeToggleStyle.display, + "none", + "Shape highlighter toggle is not hidden" + ); + is( + overriddenShapeToggleStyle.display, + "none", + "Overwritten shape highlighter toggle is not visible" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_03.js b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_03.js new file mode 100644 index 0000000000..9af14e3c8b --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_03.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the shapes highlighter in the rule view with multiple shapes in the page. + +const TEST_URI = ` + <style type='text/css'> + .shape { + width: 800px; + height: 800px; + clip-path: circle(25%); + } + </style> + <div class="shape" id="shape1"></div> + <div class="shape" id="shape2"></div> +`; + +const HIGHLIGHTER_TYPE = "ShapesHighlighter"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = view.highlighters; + + info("Selecting the first shape container."); + await selectNode("#shape1", inspector); + let container = getRuleViewProperty(view, ".shape", "clip-path").valueSpan; + let shapeToggle = container.querySelector(".ruleview-shapeswatch"); + + info( + "Checking the state of the CSS shape toggle for the first shape container " + + "in the rule-view." + ); + ok(shapeToggle, "shape highlighter toggle is visible."); + ok( + !shapeToggle.classList.contains("active"), + "shape highlighter toggle button is not active." + ); + ok( + !highlighters.highlighters[HIGHLIGHTER_TYPE], + "No CSS shape highlighter exists in the rule-view." + ); + ok( + !highlighters.shapesHighlighterShown, + "No CSS shapes highlighter is shown." + ); + + info( + "Toggling ON the CSS shapes highlighter for the first shapes container from the " + + "rule-view." + ); + let onHighlighterShown = highlighters.once("shapes-highlighter-shown"); + shapeToggle.click(); + await onHighlighterShown; + + info( + "Checking the CSS shapes highlighter is created and toggle button is active in " + + "the rule-view." + ); + ok( + shapeToggle.classList.contains("active"), + "shapes highlighter toggle is active." + ); + ok( + inspector.inspectorFront.getKnownHighlighter(HIGHLIGHTER_TYPE).actorID, + "CSS shapes highlighter created in the rule-view." + ); + ok(highlighters.shapesHighlighterShown, "CSS shapes highlighter is shown."); + + info("Selecting the second shapes container."); + await selectNode("#shape2", inspector); + const firstShapesHighlighterShown = highlighters.shapesHighlighterShown; + container = getRuleViewProperty(view, ".shape", "clip-path").valueSpan; + shapeToggle = container.querySelector(".ruleview-shapeswatch"); + + info( + "Checking the state of the CSS shapes toggle for the second shapes container " + + "in the rule-view." + ); + ok(shapeToggle, "shapes highlighter toggle is visible."); + ok( + !shapeToggle.classList.contains("active"), + "shapes highlighter toggle button is not active." + ); + ok( + !highlighters.shapesHighlighterShown, + "CSS shapes highlighter is still no longer" + + "shown due to selecting another node." + ); + + info( + "Toggling ON the CSS shapes highlighter for the second shapes container " + + "from the rule-view." + ); + onHighlighterShown = highlighters.once("shapes-highlighter-shown"); + shapeToggle.click(); + await onHighlighterShown; + + info( + "Checking the CSS shapes highlighter is created for the second shapes container " + + "and toggle button is active in the rule-view." + ); + ok( + shapeToggle.classList.contains("active"), + "shapes highlighter toggle is active." + ); + ok( + highlighters.shapesHighlighterShown != firstShapesHighlighterShown, + "shapes highlighter for the second shapes container is shown." + ); + + info("Selecting the first shapes container."); + await selectNode("#shape1", inspector); + container = getRuleViewProperty(view, ".shape", "clip-path").valueSpan; + shapeToggle = container.querySelector(".ruleview-shapeswatch"); + + info( + "Checking the state of the CSS shapes toggle for the first shapes container " + + "in the rule-view." + ); + ok(shapeToggle, "shapes highlighter toggle is visible."); + ok( + !shapeToggle.classList.contains("active"), + "shapes highlighter toggle button is not active." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_04.js b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_04.js new file mode 100644 index 0000000000..0add21381f --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_04.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the shapes highlighter in the rule view and modifying the 'clip-path' +// declaration. + +const TEST_URI = ` + <style type='text/css'> + #shape { + width: 800px; + height: 800px; + clip-path: circle(25%); + } + </style> + <div id="shape"></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = view.highlighters; + + info("Select a node with a shape value"); + await selectNode("#shape", inspector); + const container = getRuleViewProperty(view, "#shape", "clip-path").valueSpan; + let shapeToggle = container.querySelector(".ruleview-shapeswatch"); + + info("Toggling ON the CSS shape highlighter from the rule-view."); + const onHighlighterShown = highlighters.once("shapes-highlighter-shown"); + shapeToggle.click(); + await onHighlighterShown; + + info("Edit the clip-path property to ellipse."); + const editor = await focusEditableField(view, container, 30); + const onDone = view.once("ruleview-changed"); + editor.input.value = "ellipse(30% 20%);"; + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + await onDone; + + info( + "Check the shape highlighter and shape toggle button are still visible." + ); + shapeToggle = container.querySelector(".ruleview-shapeswatch"); + ok(shapeToggle, "Shape highlighter toggle is visible."); + ok(highlighters.shapesHighlighterShown, "CSS shape highlighter is shown."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_05.js b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_05.js new file mode 100644 index 0000000000..cf5722144f --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_05.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the shapes highlighter is hidden when the highlighted shape container is +// removed from the page. + +const TEST_URI = ` + <style type='text/css'> + #shape { + width: 800px; + height: 800px; + clip-path: circle(25%); + } + </style> + <div id="shape"></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = view.highlighters; + + info("Select a node with a shape value"); + await selectNode("#shape", inspector); + const container = getRuleViewProperty(view, "#shape", "clip-path").valueSpan; + const shapeToggle = container.querySelector(".ruleview-shapeswatch"); + + info("Toggling ON the CSS shapes highlighter from the rule-view."); + const onHighlighterShown = highlighters.once("shapes-highlighter-shown"); + shapeToggle.click(); + await onHighlighterShown; + ok(highlighters.shapesHighlighterShown, "CSS shapes highlighter is shown."); + + const onHighlighterHidden = highlighters.once("shapes-highlighter-hidden"); + info("Remove the #shapes container in the content page"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => + content.document.querySelector("#shape").remove() + ); + await onHighlighterHidden; + ok(!highlighters.shapesHighlighterShown, "CSS shapes highlighter is hidden."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_06.js b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_06.js new file mode 100644 index 0000000000..f1395cc1c4 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_06.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the shapes highlighter in the rule view with clip-path and shape-outside +// on the same element. + +const TEST_URI = ` + <style type='text/css'> + .shape { + width: 800px; + height: 800px; + clip-path: circle(25%); + shape-outside: circle(25%); + } + </style> + <div class="shape" id="shape1"></div> + <div class="shape" id="shape2"></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = view.highlighters; + + info("Selecting the first shapes container."); + await selectNode("#shape1", inspector); + let clipPathContainer = getRuleViewProperty( + view, + ".shape", + "clip-path" + ).valueSpan; + let clipPathShapeToggle = clipPathContainer.querySelector( + ".ruleview-shapeswatch" + ); + let shapeOutsideContainer = getRuleViewProperty( + view, + ".shape", + "shape-outside" + ).valueSpan; + let shapeOutsideToggle = shapeOutsideContainer.querySelector( + ".ruleview-shapeswatch" + ); + + info( + "Toggling ON the CSS shapes highlighter for clip-path from the rule-view." + ); + let onHighlighterShown = highlighters.once("shapes-highlighter-shown"); + clipPathShapeToggle.click(); + await onHighlighterShown; + ok(highlighters.shapesHighlighterShown, "CSS shapes highlighter is shown."); + ok( + clipPathShapeToggle.classList.contains("active"), + "clip-path toggle button is active." + ); + ok( + !shapeOutsideToggle.classList.contains("active"), + "shape-outside toggle button is not active." + ); + + info( + "Toggling ON the CSS shapes highlighter for shape-outside from the rule-view." + ); + onHighlighterShown = highlighters.once("shapes-highlighter-shown"); + shapeOutsideToggle.click(); + await onHighlighterShown; + ok(highlighters.shapesHighlighterShown, "CSS shapes highlighter is shown."); + ok( + !clipPathShapeToggle.classList.contains("active"), + "clip-path toggle button is not active." + ); + ok( + shapeOutsideToggle.classList.contains("active"), + "shape-outside toggle button is active." + ); + + info("Selecting the second shapes container."); + await selectNode("#shape2", inspector); + clipPathContainer = getRuleViewProperty( + view, + ".shape", + "clip-path" + ).valueSpan; + clipPathShapeToggle = clipPathContainer.querySelector( + ".ruleview-shapeswatch" + ); + shapeOutsideContainer = getRuleViewProperty( + view, + ".shape", + "shape-outside" + ).valueSpan; + shapeOutsideToggle = shapeOutsideContainer.querySelector( + ".ruleview-shapeswatch" + ); + ok( + !clipPathShapeToggle.classList.contains("active"), + "clip-path toggle button is not active." + ); + ok( + !shapeOutsideToggle.classList.contains("active"), + "shape-outside toggle button is not active." + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_07.js b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_07.js new file mode 100644 index 0000000000..d117d51b91 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_07.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling transform mode of the shapes highlighter + +const TEST_URI = ` + <style type='text/css'> + #shape { + width: 800px; + height: 800px; + clip-path: circle(25%); + } + </style> + <div id="shape"></div> +`; + +const HIGHLIGHTER_TYPE = "ShapesHighlighter"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = view.highlighters; + + info("Select a node with a shape value"); + await selectNode("#shape", inspector); + const container = getRuleViewProperty(view, "#shape", "clip-path").valueSpan; + const shapesToggle = container.querySelector(".ruleview-shapeswatch"); + + info("Toggling ON the CSS shapes highlighter with transform mode on."); + let onHighlighterShown = highlighters.once("shapes-highlighter-shown"); + EventUtils.sendMouseEvent( + { type: "click", metaKey: true, ctrlKey: true }, + shapesToggle, + view.styleWindow + ); + await onHighlighterShown; + + info( + "Checking the CSS shapes highlighter is created and transform mode is on" + ); + ok( + inspector.inspectorFront.getKnownHighlighter(HIGHLIGHTER_TYPE).actorID, + "CSS shapes highlighter created in the rule-view." + ); + ok(highlighters.shapesHighlighterShown, "CSS shapes highlighter is shown."); + ok(highlighters.state.shapes.options.transformMode, "Transform mode is on."); + + info("Toggling OFF the CSS shapes highlighter from the rule-view."); + const onHighlighterHidden = highlighters.once("shapes-highlighter-hidden"); + EventUtils.sendMouseEvent({ type: "click" }, shapesToggle, view.styleWindow); + await onHighlighterHidden; + + info("Checking the CSS shapes highlighter is not shown."); + ok( + !highlighters.shapesHighlighterShown, + "No CSS shapes highlighter is shown." + ); + + info("Toggling ON the CSS shapes highlighter with transform mode off."); + onHighlighterShown = highlighters.once("shapes-highlighter-shown"); + EventUtils.sendMouseEvent({ type: "click" }, shapesToggle, view.styleWindow); + await onHighlighterShown; + + info( + "Checking the CSS shapes highlighter is created and transform mode is off" + ); + ok( + inspector.inspectorFront.getKnownHighlighter(HIGHLIGHTER_TYPE).actorID, + "CSS shapes highlighter created in the rule-view." + ); + ok(highlighters.shapesHighlighterShown, "CSS shapes highlighter is shown."); + ok( + !highlighters.state.shapes.options.transformMode, + "Transform mode is off." + ); + + info( + "Clicking shapes toggle to turn on transform mode while highlighter is shown." + ); + onHighlighterShown = highlighters.once("shapes-highlighter-shown"); + EventUtils.sendMouseEvent( + { type: "click", metaKey: true, ctrlKey: true }, + shapesToggle, + view.styleWindow + ); + await onHighlighterShown; + + info( + "Checking the CSS shapes highlighter is created and transform mode is on" + ); + ok( + inspector.inspectorFront.getKnownHighlighter(HIGHLIGHTER_TYPE).actorID, + "CSS shapes highlighter created in the rule-view." + ); + ok(highlighters.shapesHighlighterShown, "CSS shapes highlighter is shown."); + ok(highlighters.state.shapes.options.transformMode, "Transform mode is on."); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_basic-shapes-default.js b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_basic-shapes-default.js new file mode 100644 index 0000000000..46afb2ac57 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_shapes-toggle_basic-shapes-default.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that the shapes highlighter can be toggled for basic shapes with default values. + +const TEST_URI = ` + <style type='text/css'> + #shape-circle { + clip-path: circle(); + } + #shape-ellipse { + clip-path: ellipse(); + } + #shape-inset { + clip-path: inset(); + } + #shape-polygon { + clip-path: polygon(); + } + </style> + <div id="shape-circle"></div> + <div id="shape-ellipse"></div> + <div id="shape-inset"></div> + <div id="shape-polygon"></div> +`; + +const HIGHLIGHTER_TYPE = "ShapesHighlighter"; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + const highlighters = view.highlighters; + + const selectors = new Map([ + ["#shape-circle", true], + ["#shape-ellipse", true], + // Basic shapes inset() and polygon() expect explicit coordinates. + // They don't have default values and are invalid without coordinates. + ["#shape-inset", false], + ["#shape-polygon", false], + ]); + + for (const [selector, expectShapesToogle] of selectors) { + await selectNode(selector, inspector); + const container = getRuleViewProperty( + view, + selector, + "clip-path" + ).valueSpan; + const shapesToggle = container.querySelector(".ruleview-shapeswatch"); + + if (expectShapesToogle) { + ok( + shapesToggle, + `Shapes highlighter toggle expected and found for ${selector}` + ); + } else { + is( + shapesToggle, + null, + `Shapes highlighter toggle not expected and not found for ${selector}` + ); + + // Skip the rest of the test. + continue; + } + + info(`Toggling ON the shapes highlighter for ${selector}`); + const onHighlighterShown = highlighters.once("shapes-highlighter-shown"); + shapesToggle.click(); + await onHighlighterShown; + + ok( + shapesToggle.classList.contains("active"), + `Shapes highlighter toggle active for ${selector}` + ); + ok( + inspector.inspectorFront.getKnownHighlighter(HIGHLIGHTER_TYPE).actorID, + `Shapes highlighter instance created for ${selector}` + ); + ok( + highlighters.shapesHighlighterShown, + `Shapes highlighter shown for ${selector}` + ); + + info(`Toggling OFF the shapes highlighter for ${selector}`); + const onHighlighterHidden = highlighters.once("shapes-highlighter-hidden"); + shapesToggle.click(); + await onHighlighterHidden; + + ok( + !shapesToggle.classList.contains("active"), + `Shapes highlighter toggle no longer active for ${selector}` + ); + ok( + !highlighters.shapesHighlighterShown, + `Shapes highlighter no longer shown for ${selector}` + ); + } +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_shorthand-overridden-lists.js b/devtools/client/inspector/rules/test/browser_rules_shorthand-overridden-lists.js new file mode 100644 index 0000000000..2ad037d443 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_shorthand-overridden-lists.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view shorthand overridden list works correctly, +// can be shown and hidden correctly, and contain the right subproperties. + +var TEST_URI = ` + <style type="text/css"> + div { + margin: 0px 1px 2px 3px; + top: 0px; + } + #testid { + margin-left: 10px; + margin-right: 10px; + } + </style> + <div id="testid">Styled Node</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testComputedList(inspector, view); +}); + +function testComputedList(inspector, view) { + const rule = getRuleViewRuleEditor(view, 2).rule; + const propEditor = rule.textProps[0].editor; + const expander = propEditor.expander; + const overriddenItems = propEditor.shorthandOverridden.children; + const propNames = ["margin-right", "margin-left"]; + + ok(!expander.hasAttribute("open"), "margin computed list is closed."); + ok( + !propEditor.shorthandOverridden.hasAttribute("hidden"), + "The shorthandOverridden list should be open." + ); + + is( + overriddenItems.length, + propNames.length, + "There should be 2 overridden shorthand value." + ); + for (let i = 0; i < propNames.length; i++) { + const overriddenItem = overriddenItems[i].querySelector( + ".ruleview-propertyname" + ); + is( + overriddenItem.textContent, + propNames[i], + "The overridden item #" + i + " should be " + propNames[i] + ); + } + + info("Opening the computed list of margin property."); + expander.click(); + ok(expander.hasAttribute("open"), "margin computed list is open."); + ok( + propEditor.shorthandOverridden.hasAttribute("hidden"), + "The shorthandOverridden list should be hidden." + ); + + info("Closing the computed list of margin property."); + expander.click(); + ok(!expander.hasAttribute("open"), "margin computed list is closed."); + ok( + !propEditor.shorthandOverridden.hasAttribute("hidden"), + "The shorthandOverridden list should be open." + ); + + for (let i = 0; i < propNames.length; i++) { + const overriddenItem = overriddenItems[i].querySelector( + ".ruleview-propertyname" + ); + is( + overriddenItem.textContent, + propNames[i], + "The overridden item #" + i + " should still be " + propNames[i] + ); + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_shorthand-overridden-lists_01.js b/devtools/client/inspector/rules/test/browser_rules_shorthand-overridden-lists_01.js new file mode 100644 index 0000000000..f33fd57148 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_shorthand-overridden-lists_01.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that overridden longhand properties aren't shown when the shorthand's value +// contains a CSS variable. When this happens, the longhand values can't be computed +// properly and are hidden. So the overridden longhand that are normally auto-expanded +// should be hidden too. + +var TEST_URI = ` + <style type="text/css"> + div { + --color: red; + background: var(--color); + background-repeat: no-repeat; + } + </style> + <div>Inspect me</div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + const rule = getRuleViewRuleEditor(view, 1).rule; + const shorthandOverridden = rule.textProps[1].editor.shorthandOverridden; + + is( + shorthandOverridden.children.length, + 0, + "The shorthandOverridden list is empty" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_strict-search-filter-computed-list_01.js b/devtools/client/inspector/rules/test/browser_rules_strict-search-filter-computed-list_01.js new file mode 100644 index 0000000000..d1c51acedc --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_strict-search-filter-computed-list_01.js @@ -0,0 +1,208 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view strict search filter and clear button works properly +// in the computed list + +const TEST_URI = ` + <style type="text/css"> + #testid { + margin: 4px 0px 10px 44px; + } + .testclass { + background-color: red; + } + </style> + <h1 id="testid" class="testclass">Styled Node</h1> +`; + +const TEST_DATA = [ + { + desc: + "Tests that the strict search filter works properly in the " + + "computed list for property names", + search: "`margin-left`", + isExpanderOpen: true, + isFilterOpen: true, + isMarginHighlighted: false, + isMarginTopHighlighted: false, + isMarginRightHighlighted: false, + isMarginBottomHighlighted: false, + isMarginLeftHighlighted: true, + }, + { + desc: + "Tests that the strict search filter works properly in the " + + "computed list for property values", + search: "`0px`", + isExpanderOpen: true, + isFilterOpen: true, + isMarginHighlighted: false, + isMarginTopHighlighted: false, + isMarginRightHighlighted: true, + isMarginBottomHighlighted: false, + isMarginLeftHighlighted: false, + }, + { + desc: + "Tests that the strict search filter works properly in the " + + "computed list for parsed property names", + search: "`margin-left`:", + isExpanderOpen: true, + isFilterOpen: true, + isMarginHighlighted: false, + isMarginTopHighlighted: false, + isMarginRightHighlighted: false, + isMarginBottomHighlighted: false, + isMarginLeftHighlighted: true, + }, + { + desc: + "Tests that the strict search filter works properly in the " + + "computed list for parsed property values", + search: ":`4px`", + isExpanderOpen: true, + isFilterOpen: true, + isMarginHighlighted: false, + isMarginTopHighlighted: true, + isMarginRightHighlighted: false, + isMarginBottomHighlighted: false, + isMarginLeftHighlighted: false, + }, + { + desc: + "Tests that the strict search filter works properly in the " + + "computed list for property line input", + search: "`margin-top`:`4px`", + isExpanderOpen: true, + isFilterOpen: true, + isMarginHighlighted: false, + isMarginTopHighlighted: true, + isMarginRightHighlighted: false, + isMarginBottomHighlighted: false, + isMarginLeftHighlighted: false, + }, + { + desc: + "Tests that the strict search filter works properly in the " + + "computed list for a parsed strict property name and non-strict " + + "property value", + search: "`margin-top`:4px", + isExpanderOpen: true, + isFilterOpen: true, + isMarginHighlighted: false, + isMarginTopHighlighted: true, + isMarginRightHighlighted: false, + isMarginBottomHighlighted: false, + isMarginLeftHighlighted: false, + }, + { + desc: + "Tests that the strict search filter works properly in the " + + "computed list for a parsed strict property value and non-strict " + + "property name", + search: "i:`4px`", + isExpanderOpen: true, + isFilterOpen: true, + isMarginHighlighted: false, + isMarginTopHighlighted: true, + isMarginRightHighlighted: false, + isMarginBottomHighlighted: false, + isMarginLeftHighlighted: false, + }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testAddTextInFilter(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + for (const data of TEST_DATA) { + info(data.desc); + await setSearchFilter(view, data.search); + await checkRules(view, data); + await clearSearchAndCheckRules(view); + } +} + +function checkRules(view, data) { + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + const rule = getRuleViewRuleEditor(view, 1).rule; + const textPropEditor = rule.textProps[0].editor; + const computed = textPropEditor.computed; + + is(rule.selectorText, "#testid", "Second rule is #testid."); + is( + !!textPropEditor.expander.getAttribute("open"), + data.isExpanderOpen, + "Got correct expander state." + ); + is( + computed.hasAttribute("filter-open"), + data.isFilterOpen, + "Got correct expanded state for margin computed list." + ); + is( + textPropEditor.container.classList.contains("ruleview-highlight"), + data.isMarginHighlighted, + "Got correct highlight for margin text property." + ); + + is( + computed.children[0].classList.contains("ruleview-highlight"), + data.isMarginTopHighlighted, + "Got correct highlight for margin-top computed property." + ); + is( + computed.children[1].classList.contains("ruleview-highlight"), + data.isMarginRightHighlighted, + "Got correct highlight for margin-right computed property." + ); + is( + computed.children[2].classList.contains("ruleview-highlight"), + data.isMarginBottomHighlighted, + "Got correct highlight for margin-bottom computed property." + ); + is( + computed.children[3].classList.contains("ruleview-highlight"), + data.isMarginLeftHighlighted, + "Got correct highlight for margin-left computed property." + ); +} + +async function clearSearchAndCheckRules(view) { + const win = view.styleWindow; + const searchField = view.searchField; + const searchClearButton = view.searchClearButton; + + const rule = getRuleViewRuleEditor(view, 1).rule; + const textPropEditor = rule.textProps[0].editor; + const computed = textPropEditor.computed; + + info("Clearing the search filter"); + EventUtils.synthesizeMouseAtCenter(searchClearButton, {}, win); + await view.inspector.once("ruleview-filtered"); + + info("Check the search filter is cleared and no rules are highlighted"); + is(view.element.children.length, 3, "Should have 3 rules."); + ok(!searchField.value, "Search filter is cleared"); + ok( + !view.styleDocument.querySelectorAll(".ruleview-highlight").length, + "No rules are higlighted" + ); + + ok(!textPropEditor.expander.getAttribute("open"), "Expander is closed."); + ok(!computed.hasAttribute("filter-open"), "margin computed list is closed."); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_strict-search-filter_01.js b/devtools/client/inspector/rules/test/browser_rules_strict-search-filter_01.js new file mode 100644 index 0000000000..e70f02e37e --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_strict-search-filter_01.js @@ -0,0 +1,150 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view strict search filter works properly for property +// names. + +const TEST_URI = ` + <style type="text/css"> + #testid { + width: 2%; + color: red; + } + .testclass { + width: 22%; + background-color: #00F; + } + </style> + <h1 id="testid" class="testclass">Styled Node</h1> +`; + +const TEST_DATA = [ + { + desc: + "Tests that the strict search filter works properly for property " + + "names", + search: "`color`", + ruleCount: 2, + propertyIndex: 1, + }, + { + desc: + "Tests that the strict search filter works properly for property " + + "values", + search: "`2%`", + ruleCount: 2, + propertyIndex: 0, + }, + { + desc: + "Tests that the strict search filter works properly for parsed " + + "property names", + search: "`color`:", + ruleCount: 2, + propertyIndex: 1, + }, + { + desc: + "Tests that the strict search filter works properly for parsed " + + "property values", + search: ":`2%`", + ruleCount: 2, + propertyIndex: 0, + }, + { + desc: + "Tests that the strict search filter works properly for property " + + "line input", + search: "`width`:`2%`", + ruleCount: 2, + propertyIndex: 0, + }, + { + desc: + "Tests that the search filter works properly for a parsed strict " + + "property name and non-strict property value.", + search: "`width`:2%", + ruleCount: 3, + propertyIndex: 0, + }, + { + desc: + "Tests that the search filter works properly for a parsed strict " + + "property value and non-strict property name.", + search: "i:`2%`", + ruleCount: 2, + propertyIndex: 0, + }, +]; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testAddTextInFilter(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + for (const data of TEST_DATA) { + info(data.desc); + await setSearchFilter(view, data.search); + await checkRules(view, data); + await clearSearchAndCheckRules(view); + } +} + +function checkRules(view, data) { + info("Check that the correct rules are visible"); + is( + view.element.children.length, + data.ruleCount, + "Should have " + data.ruleCount + " rules." + ); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + let rule = getRuleViewRuleEditor(view, 1).rule; + + is(rule.selectorText, "#testid", "Second rule is #testid."); + ok( + rule.textProps[data.propertyIndex].editor.container.classList.contains( + "ruleview-highlight" + ), + "Text property is correctly highlighted." + ); + + if (data.ruleCount > 2) { + rule = getRuleViewRuleEditor(view, 2).rule; + is(rule.selectorText, ".testclass", "Third rule is .testclass."); + ok( + rule.textProps[data.propertyIndex].editor.container.classList.contains( + "ruleview-highlight" + ), + "Text property is correctly highlighted." + ); + } +} + +async function clearSearchAndCheckRules(view) { + const doc = view.styleDocument; + const win = view.styleWindow; + const searchField = view.searchField; + const searchClearButton = view.searchClearButton; + + info("Clearing the search filter"); + EventUtils.synthesizeMouseAtCenter(searchClearButton, {}, win); + await view.inspector.once("ruleview-filtered"); + + info("Check the search filter is cleared and no rules are highlighted"); + is(view.element.children.length, 3, "Should have 3 rules."); + ok(!searchField.value, "Search filter is cleared."); + ok( + !doc.querySelectorAll(".ruleview-highlight").length, + "No rules are higlighted." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_strict-search-filter_02.js b/devtools/client/inspector/rules/test/browser_rules_strict-search-filter_02.js new file mode 100644 index 0000000000..a1c3824adf --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_strict-search-filter_02.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view strict search filter works properly for stylesheet +// source. + +const SEARCH = "`doc_urls_clickable.css:1`"; +const TEST_URI = URL_ROOT + "doc_urls_clickable.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + await selectNode(".relative1", inspector); + await testAddTextInFilter(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + await setSearchFilter(view, SEARCH); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + const rule = getRuleViewRuleEditor(view, 1).rule; + const source = rule.textProps[0].editor.ruleEditor.source; + + is(rule.selectorText, ".relative1", "Second rule is .relative1."); + ok( + source.classList.contains("ruleview-highlight"), + "stylesheet source is correctly highlighted." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_strict-search-filter_03.js b/devtools/client/inspector/rules/test/browser_rules_strict-search-filter_03.js new file mode 100644 index 0000000000..3bd34fd2c4 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_strict-search-filter_03.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the rule view strict search filter works properly for selector +// values. + +const SEARCH = "`.testclass`"; + +const TEST_URI = ` + <style type="text/css"> + .testclass1 { + background-color: #00F; + } + .testclass { + color: red; + } + </style> + <h1 id="testid" class="testclass testclass1">Styled Node</h1> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + await testAddTextInFilter(inspector, view); +}); + +async function testAddTextInFilter(inspector, view) { + await setSearchFilter(view, SEARCH); + + info("Check that the correct rules are visible"); + is(view.element.children.length, 2, "Should have 2 rules."); + is( + getRuleViewRuleEditor(view, 0).rule.selectorText, + "element", + "First rule is inline element." + ); + + const ruleEditor = getRuleViewRuleEditor(view, 1); + + is(ruleEditor.rule.selectorText, ".testclass", "Second rule is .testclass."); + ok( + ruleEditor.selectorText.children[0].classList.contains( + "ruleview-highlight" + ), + ".testclass selector is highlighted." + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_style-editor-link.js b/devtools/client/inspector/rules/test/browser_rules_style-editor-link.js new file mode 100644 index 0000000000..a3c59bfa79 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_style-editor-link.js @@ -0,0 +1,251 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the links from the rule-view to the styleeditor + +const STYLESHEET_DATA_URL_CONTENTS = `#first { +color: blue +}`; +const STYLESHEET_DATA_URL = `data:text/css,${encodeURIComponent( + STYLESHEET_DATA_URL_CONTENTS +)}`; + +const EXTERNAL_STYLESHEET_FILE_NAME = "doc_style_editor_link.css"; +const EXTERNAL_STYLESHEET_URL = URL_ROOT_SSL + EXTERNAL_STYLESHEET_FILE_NAME; + +const DOCUMENT_HTML = encodeURIComponent(` + <html> + <head> + <title>Rule view style editor link test</title> + <style type="text/css"> + html { color: #000000; } + div { font-variant: small-caps; color: #000000; } + .nomatches {color: #ff0000;} + </style> + <style> + div { font-weight: bold; } + </style> + <link rel="stylesheet" type="text/css" href="${STYLESHEET_DATA_URL}"> + <link rel="stylesheet" type="text/css" href="${EXTERNAL_STYLESHEET_URL}"> + </head> + <body> + <div id="first" style="margin: 10em;font-size: 14pt; font-family: helvetica, sans-serif; color: #AAA"> + <h1>Some header text</h1> + <p id="salutation" style="font-size: 12pt">hi.</p> + <p id="body" style="font-size: 12pt">I am a test-case. This text exists + solely to provide some things to + <span style="color: yellow" class="highlight"> + highlight</span> and <span style="font-weight: bold">count</span> + style list-items in the box at right. If you are reading this, + you should go do something else instead. Maybe read a book. Or better + yet, write some test-cases for another bit of code. + <span style="font-style: italic">some text</span></p> + <p id="closing">more text</p> + <p>even more text</p> + </div> + </body> + </html> +`); + +const DOCUMENT_DATA_URL = "data:text/html;charset=utf-8," + DOCUMENT_HTML; +const EXAMPLE_ORG_DOCUMENT_URL = + "https://example.org/document-builder.sjs?html=" + DOCUMENT_HTML; + +add_task(async function () { + await addTab(DOCUMENT_DATA_URL); + const { toolbox, inspector, view } = await openRuleView(); + + await testAllStylesheets(inspector, view, toolbox); + + info("Navigate to the example.org document"); + await navigateTo(EXAMPLE_ORG_DOCUMENT_URL); + await testAllStylesheets(inspector, view, toolbox); +}); + +add_task(async function () { + info("Check that link to the style editor works after tab reload"); + await addTab(EXAMPLE_ORG_DOCUMENT_URL); + const { toolbox, inspector, view } = await openRuleView(); + + info("Reload the example.org document"); + // Use navigateTo as it waits for the inspector to be ready. + await navigateTo(EXAMPLE_ORG_DOCUMENT_URL); + await testAllStylesheets(inspector, view, toolbox); +}); + +async function testAllStylesheets(inspector, view, toolbox) { + await selectNode("div", inspector); + await testRuleViewLinkLabel(view); + await testDisabledStyleEditor(view, toolbox); + await testFirstInlineStyleSheet(view, toolbox); + await testSecondInlineStyleSheet(view, toolbox); + await testExternalStyleSheet(view, toolbox); + + info("Switch back to the inspector panel"); + await toolbox.selectTool("inspector"); + await selectNode("body", inspector); +} + +async function testFirstInlineStyleSheet(view, toolbox) { + info("Testing inline stylesheet"); + + info("Listening for toolbox switch to the styleeditor"); + const onSwitch = waitForStyleEditor(toolbox); + + info("Clicking an inline stylesheet"); + clickLinkByIndex(view, 4); + const editor = await onSwitch; + + ok(true, "Switched to the style-editor panel in the toolbox"); + + await validateStyleEditorSheet(toolbox, editor, 0); +} + +async function testSecondInlineStyleSheet(view, toolbox) { + info("Testing second inline stylesheet"); + + const styleEditorPanel = toolbox.getCurrentPanel(); + const onEditorSelected = styleEditorPanel.UI.once("editor-selected"); + + info("Switching back to the inspector panel in the toolbox"); + await toolbox.selectTool("inspector"); + const onToolSelected = toolbox.once("styleeditor-selected"); + + info("Clicking on second inline stylesheet link"); + clickLinkByIndex(view, 3); + + info("Wait for the stylesheet editor to be selected"); + const editor = await onEditorSelected; + await onToolSelected; + + is( + toolbox.currentToolId, + "styleeditor", + "The style editor is selected again" + ); + await validateStyleEditorSheet(toolbox, editor, 1); +} + +async function testExternalStyleSheet(view, toolbox) { + info("Testing external stylesheet"); + const styleEditorPanel = toolbox.getCurrentPanel(); + const onEditorSelected = styleEditorPanel.UI.once("editor-selected"); + + info("Switching back to the inspector panel in the toolbox"); + await toolbox.selectTool("inspector"); + const onToolSelected = toolbox.once("styleeditor-selected"); + + info("Clicking on an external stylesheet link"); + clickLinkByIndex(view, 1); + + info("Wait for the stylesheet editor to be selected"); + const editor = await onEditorSelected; + await onToolSelected; + + is( + toolbox.currentToolId, + "styleeditor", + "The style editor is selected again" + ); + await validateStyleEditorSheet(toolbox, editor, 2); +} + +async function validateStyleEditorSheet(toolbox, editor, expectedSheetIndex) { + info("validating style editor stylesheet"); + is( + editor.styleSheet.styleSheetIndex, + expectedSheetIndex, + "loaded stylesheet index matches document stylesheet" + ); + + const href = editor.styleSheet.href || editor.styleSheet.nodeHref; + + const expectedHref = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [expectedSheetIndex], + _expectedSheetIndex => { + return ( + content.document.styleSheets[_expectedSheetIndex].href || + content.document.location.href + ); + } + ); + + is(href, expectedHref, "loaded stylesheet href matches document stylesheet"); +} + +async function testDisabledStyleEditor(view, toolbox) { + info("Testing with the style editor disabled"); + + info("Switching to the inspector panel in the toolbox"); + await toolbox.selectTool("inspector"); + + info("Disabling the style editor"); + Services.prefs.setBoolPref("devtools.styleeditor.enabled", false); + gDevTools.emit("tool-unregistered", "styleeditor"); + + info("Clicking on a link"); + testUnselectableRuleViewLink(view, 1); + clickLinkByIndex(view, 1); + // Wait for a bit just to make sure the click didn't had an impact + await wait(2000); + + is(toolbox.currentToolId, "inspector", "The click should have no effect"); + + info("Enabling the style editor"); + Services.prefs.setBoolPref("devtools.styleeditor.enabled", true); + gDevTools.emit("tool-registered", "styleeditor"); + + Services.prefs.clearUserPref("devtools.styleeditor.enabled"); +} + +async function testRuleViewLinkLabel(view) { + info("Checking the data URL link label"); + let link = getRuleViewLinkByIndex(view, 1); + let labelElem = link.querySelector(".ruleview-rule-source-label"); + let value = labelElem.textContent; + let tooltipText = labelElem.getAttribute("title"); + + is( + value, + encodeURIComponent(STYLESHEET_DATA_URL_CONTENTS) + ":1", + "Rule view data URL stylesheet display value matches contents" + ); + is( + tooltipText, + STYLESHEET_DATA_URL + ":1", + "Rule view data URL stylesheet tooltip text matches the full URI path" + ); + + info("Checking the external link label"); + link = getRuleViewLinkByIndex(view, 2); + labelElem = link.querySelector(".ruleview-rule-source-label"); + value = labelElem.textContent; + tooltipText = labelElem.getAttribute("title"); + + is( + value, + `${EXTERNAL_STYLESHEET_FILE_NAME}:1`, + "Rule view external stylesheet display value matches filename and line number" + ); + is( + tooltipText, + `${EXTERNAL_STYLESHEET_URL}:1`, + "Rule view external stylesheet tooltip text matches the full URI path" + ); +} + +function testUnselectableRuleViewLink(view, index) { + const link = getRuleViewLinkByIndex(view, index); + const unselectable = link.hasAttribute("unselectable"); + + ok(unselectable, "Rule view is unselectable"); +} + +function clickLinkByIndex(view, index) { + const link = getRuleViewLinkByIndex(view, index); + link.scrollIntoView(); + link.click(); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_update_mask_image_cors.js b/devtools/client/inspector/rules/test/browser_rules_update_mask_image_cors.js new file mode 100644 index 0000000000..bac63a9572 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_update_mask_image_cors.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/webconsole/test/browser/shared-head.js", + this +); + +// The mask image is served from example.com while the test page is served from +// example.org. +const MASK_SRC = URL_ROOT_COM_SSL + "square_svg.sjs"; +const STYLE_ATTRIBUTE = `mask-image: url("${MASK_SRC}"); width:10px; height: 10px; background: red;`; +const TEST_URL = `https://example.org/document-builder.sjs?html=<div style='${STYLE_ATTRIBUTE}'>`; + +// Used to assert screenshot colors. +const RED = { r: 255, g: 0, b: 0 }; + +add_task(async function () { + await addTab(TEST_URL); + const { inspector, toolbox, view } = await openRuleView(); + + info("Open the splitconsole to check for CORS messages"); + await toolbox.toggleSplitConsole(); + + await selectNode("div", inspector); + + info("Take a node screenshot, mask is applied, should be red"); + const beforeScreenshot = await takeNodeScreenshot(inspector); + await assertSingleColorScreenshotImage(beforeScreenshot, 10, 10, RED); + + info("Update a property from the rule view"); + const heightProperty = getTextProperty(view, 0, { height: "10px" }); + await setProperty(view, heightProperty, "11px"); + + info("Wait until the style has been applied in the content page"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector("div").style.height == "11px" + ); + }); + + // Wait for some time in case the image needs to be reloaded, and to allow + // error messages (if any) to be rendered. + await wait(1000); + + info("Take another screenshot, mask should still apply, should be red"); + const afterScreenshot = await takeNodeScreenshot(inspector); + await assertSingleColorScreenshotImage(afterScreenshot, 10, 11, RED); + + const hud = toolbox.getPanel("webconsole").hud; + ok( + !findMessageByType(hud, "Cross-Origin Request Blocked", ".error"), + "No message was logged about a CORS issue" + ); + + info("Close split console"); + await toolbox.toggleSplitConsole(); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_url-click-opens-new-tab.js b/devtools/client/inspector/rules/test/browser_rules_url-click-opens-new-tab.js new file mode 100644 index 0000000000..0fe12dc859 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_url-click-opens-new-tab.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Bug 1326626 - Tests that clicking a background url opens a new tab +// even when the devtools is opened in a separate window. + +const TEST_URL = + "data:text/html,<style>body{background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQImWNgYGD4DwABBAEAfbLI3wAAAABJRU5ErkJggg==) no-repeat}"; + +add_task(async function () { + const { inspector } = await openInspectorForURL(TEST_URL, "window"); + const view = selectRuleView(inspector); + + await selectNode("body", inspector); + + const anchor = view.styleDocument.querySelector( + ".ruleview-propertyvaluecontainer a" + ); + ok(anchor, "Link exists for style tag node"); + + const onTabOpened = waitForTab(); + anchor.click(); + + info("Wait for the image to open in a new tab"); + const tab = await onTabOpened; + ok(tab, "A new tab opened"); + + is( + tab.linkedBrowser.currentURI.spec, + anchor.href, + "The new tab has the expected URL" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_urls-clickable.js b/devtools/client/inspector/rules/test/browser_rules_urls-clickable.js new file mode 100644 index 0000000000..1d9574d73f --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_urls-clickable.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests to make sure that URLs are clickable in the rule view + +const TEST_URI = URL_ROOT_SSL + "doc_urls_clickable.html"; +const TEST_IMAGE = URL_ROOT_SSL + "doc_test_image.png"; +const BASE_64_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAA" + + "FCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAA" + + "BJRU5ErkJggg=="; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + await selectNodes(inspector, view); +}); + +async function selectNodes(inspector, ruleView) { + const relative1 = ".relative1"; + const relative2 = ".relative2"; + const absolute = ".absolute"; + const inline = ".inline"; + const base64 = ".base64"; + const noimage = ".noimage"; + const inlineresolved = ".inline-resolved"; + + await selectNode(relative1, inspector); + let relativeLink = ruleView.styleDocument.querySelector( + ".ruleview-propertyvaluecontainer a" + ); + ok(relativeLink, "Link exists for relative1 node"); + is(relativeLink.getAttribute("href"), TEST_IMAGE, "href matches"); + + await selectNode(relative2, inspector); + relativeLink = ruleView.styleDocument.querySelector( + ".ruleview-propertyvaluecontainer a" + ); + ok(relativeLink, "Link exists for relative2 node"); + is(relativeLink.getAttribute("href"), TEST_IMAGE, "href matches"); + + await selectNode(absolute, inspector); + const absoluteLink = ruleView.styleDocument.querySelector( + ".ruleview-propertyvaluecontainer a" + ); + ok(absoluteLink, "Link exists for absolute node"); + is(absoluteLink.getAttribute("href"), TEST_IMAGE, "href matches"); + + await selectNode(inline, inspector); + const inlineLink = ruleView.styleDocument.querySelector( + ".ruleview-propertyvaluecontainer a" + ); + ok(inlineLink, "Link exists for inline node"); + is(inlineLink.getAttribute("href"), TEST_IMAGE, "href matches"); + + await selectNode(base64, inspector); + const base64Link = ruleView.styleDocument.querySelector( + ".ruleview-propertyvaluecontainer a" + ); + ok(base64Link, "Link exists for base64 node"); + is(base64Link.getAttribute("href"), BASE_64_URL, "href matches"); + + await selectNode(inlineresolved, inspector); + const inlineResolvedLink = ruleView.styleDocument.querySelector( + ".ruleview-propertyvaluecontainer a" + ); + ok(inlineResolvedLink, "Link exists for style tag node"); + is(inlineResolvedLink.getAttribute("href"), TEST_IMAGE, "href matches"); + + await selectNode(noimage, inspector); + const noimageLink = ruleView.styleDocument.querySelector( + ".ruleview-propertyvaluecontainer a" + ); + ok(!noimageLink, "There is no link for the node with no background image"); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_user-agent-styles-uneditable.js b/devtools/client/inspector/rules/test/browser_rules_user-agent-styles-uneditable.js new file mode 100644 index 0000000000..07a2b6abb8 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_user-agent-styles-uneditable.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that user agent styles are never editable via +// the UI + +const TEST_URI = ` + <blockquote type=cite> + <pre _moz_quote=true> + inspect <a href='foo' style='color:orange'>user agent</a> styles + </pre> + </blockquote> +`; + +var PREF_UA_STYLES = "devtools.inspector.showUserAgentStyles"; + +add_task(async function () { + info("Starting the test with the pref set to true before toolbox is opened"); + Services.prefs.setBoolPref(PREF_UA_STYLES, true); + + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await userAgentStylesUneditable(inspector, view); + + info("Resetting " + PREF_UA_STYLES); + Services.prefs.clearUserPref(PREF_UA_STYLES); +}); + +async function userAgentStylesUneditable(inspector, view) { + info("Making sure that UI is not editable for user agent styles"); + + await selectNode("a", inspector); + const uaRules = view._elementStyle.rules.filter( + rule => !rule.editor.isEditable + ); + + for (const rule of uaRules) { + ok( + rule.editor.element.hasAttribute("uneditable"), + "UA rules have uneditable attribute" + ); + + const firstProp = rule.textProps.filter(p => !p.invisible)[0]; + + ok(!firstProp.editor.nameSpan._editable, "nameSpan is not editable"); + ok(!firstProp.editor.valueSpan._editable, "valueSpan is not editable"); + ok(!rule.editor.closeBrace._editable, "closeBrace is not editable"); + + const colorswatch = rule.editor.element.querySelector( + ".ruleview-colorswatch" + ); + if (colorswatch) { + ok( + !view.tooltips.getTooltip("colorPicker").swatches.has(colorswatch), + "The swatch is not editable" + ); + } + } +} diff --git a/devtools/client/inspector/rules/test/browser_rules_user-agent-styles.js b/devtools/client/inspector/rules/test/browser_rules_user-agent-styles.js new file mode 100644 index 0000000000..6692badeda --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_user-agent-styles.js @@ -0,0 +1,216 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that user agent styles are inspectable via rule view if +// it is preffed on. + +var PREF_UA_STYLES = "devtools.inspector.showUserAgentStyles"; +const { PrefObserver } = require("resource://devtools/client/shared/prefs.js"); + +const TEST_URI = URL_ROOT + "doc_author-sheet.html"; + +const TEST_DATA = [ + { + selector: "blockquote", + numUserRules: 1, + numUARules: 0, + }, + { + selector: "pre", + numUserRules: 1, + numUARules: 0, + }, + { + selector: "input[type=range]", + numUserRules: 1, + numUARules: 0, + }, + { + selector: "input[type=number]", + numUserRules: 1, + numUARules: 0, + }, + { + selector: "input[type=color]", + numUserRules: 1, + numUARules: 0, + }, + { + selector: "input[type=text]", + numUserRules: 1, + numUARules: 0, + }, + { + selector: "progress", + numUserRules: 1, + numUARules: 0, + }, + // Note that some tests below assume that the "a" selector is the + // last test in TEST_DATA. + { + selector: "a", + numUserRules: 3, + numUARules: 0, + }, +]; + +add_task(async function () { + // Bug 1517210: GC heuristics are broken for this test, so that the test ends up + // running out of memory if we don't force to reduce the GC side before/after the test. + Cu.forceShrinkingGC(); + + requestLongerTimeout(4); + + info("Starting the test with the pref set to true before toolbox is opened"); + await setUserAgentStylesPref(true); + + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + + info("Making sure that UA styles are visible on initial load"); + await userAgentStylesVisible(inspector, view); + + info("Making sure that setting the pref to false hides UA styles"); + await setUserAgentStylesPref(false); + await userAgentStylesNotVisible(inspector, view); + + info("Making sure that resetting the pref to true shows UA styles again"); + await setUserAgentStylesPref(true); + await userAgentStylesVisible(inspector, view); + + info("Resetting " + PREF_UA_STYLES); + Services.prefs.clearUserPref(PREF_UA_STYLES); + + // Bug 1517210: GC heuristics are broken for this test, so that the test ends up + // running out of memory if we don't force to reduce the GC side before/after the test. + Cu.forceShrinkingGC(); +}); + +async function setUserAgentStylesPref(val) { + info("Setting the pref " + PREF_UA_STYLES + " to: " + val); + + // Reset the pref and wait for PrefObserver to callback so UI + // has a chance to get updated. + const prefObserver = new PrefObserver("devtools."); + const oncePrefChanged = new Promise(resolve => { + prefObserver.on(PREF_UA_STYLES, onPrefChanged); + + function onPrefChanged() { + prefObserver.off(PREF_UA_STYLES, onPrefChanged); + resolve(); + } + }); + Services.prefs.setBoolPref(PREF_UA_STYLES, val); + await oncePrefChanged; +} + +async function userAgentStylesVisible(inspector, view) { + info("Making sure that user agent styles are currently visible"); + + let userRules; + let uaRules; + + for (const data of TEST_DATA) { + await selectNode(data.selector, inspector); + await compareAppliedStylesWithUI(inspector, view, "ua"); + + userRules = view._elementStyle.rules.filter(rule => rule.editor.isEditable); + uaRules = view._elementStyle.rules.filter(rule => !rule.editor.isEditable); + is(userRules.length, data.numUserRules, "Correct number of user rules"); + ok(uaRules.length > data.numUARules, "Has UA rules"); + } + + ok( + userRules.some(rule => rule.matchedSelectors.length === 1), + "There is an inline style for element in user styles" + ); + + // These tests rely on the "a" selector being the last test in + // TEST_DATA. + ok( + uaRules.some(rule => { + return rule.matchedSelectors.includes(":any-link"); + }), + "There is a rule for :any-link" + ); + ok( + uaRules.some(rule => { + return rule.matchedSelectors.includes(":link"); + }), + "There is a rule for :link" + ); + ok( + uaRules.some(rule => { + return rule.matchedSelectors.length === 1; + }), + "Inline styles for ua styles" + ); +} + +async function userAgentStylesNotVisible(inspector, view) { + info("Making sure that user agent styles are not currently visible"); + + let userRules; + let uaRules; + + for (const data of TEST_DATA) { + await selectNode(data.selector, inspector); + await compareAppliedStylesWithUI(inspector, view); + + userRules = view._elementStyle.rules.filter(rule => rule.editor.isEditable); + uaRules = view._elementStyle.rules.filter(rule => !rule.editor.isEditable); + is(userRules.length, data.numUserRules, "Correct number of user rules"); + is(uaRules.length, data.numUARules, "No UA rules"); + } +} + +async function compareAppliedStylesWithUI(inspector, view, filter) { + info("Making sure that UI is consistent with pageStyle.getApplied"); + + const pageStyle = inspector.selection.nodeFront.inspectorFront.pageStyle; + let entries = await pageStyle.getApplied(inspector.selection.nodeFront, { + inherited: true, + matchedSelectors: true, + filter, + }); + + // We may see multiple entries that map to a given rule; filter the + // duplicates here to match what the UI does. + const entryMap = new Map(); + for (const entry of entries) { + entryMap.set(entry.rule, entry); + } + entries = [...entryMap.values()]; + + const elementStyle = view._elementStyle; + is( + elementStyle.rules.length, + entries.length, + "Should have correct number of rules (" + entries.length + ")" + ); + + entries = entries.sort((a, b) => { + return (a.pseudoElement || "z") > (b.pseudoElement || "z"); + }); + + entries.forEach((entry, i) => { + const elementStyleRule = elementStyle.rules[i]; + is( + !!elementStyleRule.inherited, + !!entry.inherited, + "Same inherited (" + entry.inherited + ")" + ); + is( + elementStyleRule.isSystem, + entry.isSystem, + "Same isSystem (" + entry.isSystem + ")" + ); + is( + elementStyleRule.editor.isEditable, + !entry.isSystem, + "Editor isEditable opposite of UA (" + entry.isSystem + ")" + ); + }); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_user-property-reset.js b/devtools/client/inspector/rules/test/browser_rules_user-property-reset.js new file mode 100644 index 0000000000..649547e514 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_user-property-reset.js @@ -0,0 +1,117 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that user set style properties can be changed from the markup-view and +// don't survive page reload + +const TEST_URI = ` + <p id='id1' style='width:200px;'>element 1</p> + <p id='id2' style='width:100px;'>element 2</p> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + + await selectNode("#id1", inspector); + await modifyRuleViewWidth("300px", view, inspector); + await assertRuleAndMarkupViewWidth("id1", "300px", view, inspector); + + await selectNode("#id2", inspector); + await assertRuleAndMarkupViewWidth("id2", "100px", view, inspector); + await modifyRuleViewWidth("50px", view, inspector); + await assertRuleAndMarkupViewWidth("id2", "50px", view, inspector); + + is( + view.store.userProperties.map.size, + 2, + "The modifications are stored as expected" + ); + + await reloadBrowser(); + + is( + view.store.userProperties.map.size, + 0, + "Properties storing user modifications is cleared after a reload" + ); + + await selectNode("#id1", inspector); + await assertRuleAndMarkupViewWidth("id1", "200px", view, inspector); + await selectNode("#id2", inspector); + await assertRuleAndMarkupViewWidth("id2", "100px", view, inspector); +}); + +function getStyleRule(ruleView) { + return ruleView.styleDocument.querySelector(".ruleview-rule"); +} + +async function modifyRuleViewWidth(value, ruleView, inspector) { + info("Getting the property value element"); + const valueSpan = getStyleRule(ruleView).querySelector( + ".ruleview-propertyvalue" + ); + + info("Focusing the property value to set it to edit mode"); + const editor = await focusEditableField(ruleView, valueSpan.parentNode); + + ok(editor.input, "The inplace-editor field is ready"); + info("Setting the new value"); + editor.input.value = value; + + info( + "Pressing return and waiting for the field to blur and for the " + + "markup-view to show the mutation" + ); + const onBlur = once(editor.input, "blur", true); + const onStyleChanged = waitForStyleModification(inspector); + EventUtils.sendKey("return"); + await onBlur; + await onStyleChanged; + + info( + "Escaping out of the new property field that has been created after " + + "the value was edited" + ); + const onNewFieldBlur = once( + ruleView.styleDocument.activeElement, + "blur", + true + ); + EventUtils.sendKey("escape"); + await onNewFieldBlur; +} + +async function getContainerStyleAttrValue(id, { walker, markup }) { + const front = await walker.querySelector(walker.rootNode, "#" + id); + const container = markup.getContainer(front); + + let attrIndex = 0; + for (const attrName of container.elt.querySelectorAll(".attr-name")) { + if (attrName.textContent === "style") { + return container.elt.querySelectorAll(".attr-value")[attrIndex]; + } + attrIndex++; + } + return undefined; +} + +async function assertRuleAndMarkupViewWidth(id, value, ruleView, inspector) { + const valueSpan = getStyleRule(ruleView).querySelector( + ".ruleview-propertyvalue" + ); + is( + valueSpan.textContent, + value, + "Rule-view style width is " + value + " as expected" + ); + + const attr = await getContainerStyleAttrValue(id, inspector); + is( + attr.textContent.replace(/\s/g, ""), + "width:" + value + ";", + "Markup-view style attribute width is " + value + ); +} diff --git a/devtools/client/inspector/rules/test/browser_rules_variables-in-pseudo-element_01.js b/devtools/client/inspector/rules/test/browser_rules_variables-in-pseudo-element_01.js new file mode 100644 index 0000000000..9e9a2d1d50 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_variables-in-pseudo-element_01.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for pseudo element which defines CSS variable. + +const TEST_URI = ` + <style type='text/css'> + div::before { + color: var(--color); + --color: orange; + } + + div { + color: var(--color); + --color: lime; + } + </style> + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + info("Test the CSS variable which normal element is referring to"); + checkCSSVariableOutput( + view, + "div", + "color", + "ruleview-variable", + "--color = lime" + ); + + info("Test the CSS variable which pseudo element is referring to"); + checkCSSVariableOutput( + view, + "div::before", + "color", + "ruleview-variable", + "--color = orange" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_variables-in-pseudo-element_02.js b/devtools/client/inspector/rules/test/browser_rules_variables-in-pseudo-element_02.js new file mode 100644 index 0000000000..fba5c163c1 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_variables-in-pseudo-element_02.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for pseudo element which inherits CSS variable. + +const TEST_URI = ` + <style type='text/css'> + div::before { + color: var(--color); + } + + div { + color: var(--color); + --color: lime; + } + </style> + <div></div> +`; + +add_task(async function () { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + info("Test the CSS variable which normal element is referring to"); + checkCSSVariableOutput( + view, + "div", + "color", + "ruleview-variable", + "--color = lime" + ); + + info("Test the CSS variable which pseudo element is referring to"); + checkCSSVariableOutput( + view, + "div::before", + "color", + "ruleview-variable", + "--color = lime" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_variables_01.js b/devtools/client/inspector/rules/test/browser_rules_variables_01.js new file mode 100644 index 0000000000..255367fcbb --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_variables_01.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for variables in rule view. + +const TEST_URI = URL_ROOT + "doc_variables_1.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + await selectNode("#target", inspector); + + info( + "Tests basic support for CSS Variables for both single variable " + + "and double variable. Formats tested: var(x, constant), var(x, var(y))" + ); + + const unsetColor = getRuleViewProperty( + view, + "div", + "color" + ).valueSpan.querySelector(".ruleview-unmatched-variable"); + const setColor = unsetColor.previousElementSibling; + is(unsetColor.textContent, " red", "red is unmatched in color"); + is(setColor.textContent, "--color", "--color is not set correctly"); + is( + setColor.dataset.variable, + "--color = chartreuse", + "--color's dataset.variable is not set correctly" + ); + let previewTooltip = await assertShowPreviewTooltip(view, setColor); + await assertTooltipHiddenOnMouseOut(previewTooltip, setColor); + + ok( + previewTooltip.panel.textContent.includes("--color = chartreuse"), + "CSS variable preview tooltip shows the expected CSS variable" + ); + + const unsetVar = getRuleViewProperty( + view, + "div", + "background-color" + ).valueSpan.querySelector(".ruleview-unmatched-variable"); + const setVar = unsetVar.nextElementSibling; + const setVarName = setVar.querySelector(".ruleview-variable"); + is( + unsetVar.textContent, + "--not-set", + "--not-set is unmatched in background-color" + ); + is(setVar.textContent, " var(--bg)", "var(--bg) parsed incorrectly"); + is(setVarName.textContent, "--bg", "--bg is not set correctly"); + is( + setVarName.dataset.variable, + "--bg = seagreen", + "--bg's dataset.variable is not set correctly" + ); + previewTooltip = await assertShowPreviewTooltip(view, setVarName); + + ok( + !previewTooltip.panel.textContent.includes("--color = chartreuse"), + "CSS variable preview tooltip no longer shows the previous CSS variable" + ); + ok( + previewTooltip.panel.textContent.includes("--bg = seagreen"), + "CSS variable preview tooltip shows the new CSS variable" + ); + + await assertTooltipHiddenOnMouseOut(previewTooltip, setVarName); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_variables_02.js b/devtools/client/inspector/rules/test/browser_rules_variables_02.js new file mode 100644 index 0000000000..4100859fb9 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_variables_02.js @@ -0,0 +1,321 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for variables in rule view. + +const TEST_URI = URL_ROOT + "doc_variables_2.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + + await testBasic(inspector, view); + await testNestedCssFunctions(inspector, view); + await testBorderShorthandAndInheritance(inspector, view); + await testSingleLevelVariable(inspector, view); + await testDoubleLevelVariable(inspector, view); + await testTripleLevelVariable(inspector, view); +}); + +async function testBasic(inspector, view) { + info( + "Test support for basic variable functionality for var() with 2 variables." + + "Format: var(--var1, var(--var2))" + ); + + await selectNode("#a", inspector); + const unsetVar = getRuleViewProperty( + view, + "#a", + "font-size" + ).valueSpan.querySelector(".ruleview-unmatched-variable"); + const setVarParent = unsetVar.nextElementSibling; + const setVar = getVarFromParent(setVarParent); + is( + unsetVar.textContent, + "--var-not-defined", + "--var-not-defined is not set correctly" + ); + is( + unsetVar.dataset.variable, + "--var-not-defined is not set", + "--var-not-defined's dataset.variable is not set correctly" + ); + is( + setVarParent.textContent, + " var(--var-defined-font-size)", + "var(--var-defined-font-size) parsed incorrectly" + ); + is( + setVar.textContent, + "--var-defined-font-size", + "--var-defined-font-size is not set correctly" + ); + is( + setVar.dataset.variable, + "--var-defined-font-size = 60px", + "--bg's dataset.variable is not set correctly" + ); +} + +async function testNestedCssFunctions(inspector, view) { + info( + "Test support for variable functionality for a var() nested inside " + + "another CSS function. Format: rgb(0, 0, var(--var1, var(--var2)))" + ); + + await selectNode("#b", inspector); + const unsetVarParent = getRuleViewProperty( + view, + "#b", + "color" + ).valueSpan.querySelector(".ruleview-unmatched-variable"); + const unsetVar = getVarFromParent(unsetVarParent); + const setVar = unsetVarParent.previousElementSibling; + is( + unsetVarParent.textContent, + " var(--var-defined-r-2)", + "var(--var-defined-r-2) not parsed correctly" + ); + is( + unsetVar.textContent, + "--var-defined-r-2", + "--var-defined-r-2 is not set correctly" + ); + is( + unsetVar.dataset.variable, + "--var-defined-r-2 = 0", + "--var-defined-r-2's dataset.variable is not set correctly" + ); + is( + setVar.textContent, + "--var-defined-r-1", + "--var-defined-r-1 is not set correctly" + ); + is( + setVar.dataset.variable, + "--var-defined-r-1 = 255", + "--var-defined-r-1's dataset.variable is not set correctly" + ); +} + +async function testBorderShorthandAndInheritance(inspector, view) { + info( + "Test support for variable functionality for shorthands/CSS styles with spaces " + + 'like "margin: w x y z". Also tests functionality for inherticance of CSS' + + " variables. Format: var(l, var(m)) var(x) rgb(var(r) var(g) var(b))" + ); + + await selectNode("#c", inspector); + const unsetVarL = getRuleViewProperty( + view, + "#c", + "border" + ).valueSpan.querySelector(".ruleview-unmatched-variable"); + const setVarMParent = unsetVarL.nextElementSibling; + + // var(x) is the next sibling of the parent of M + const setVarXParent = setVarMParent.parentNode.nextElementSibling; + + // var(r) is the next sibling of var(x), and var(g) is the next sibling of var(r), etc. + const setVarRParent = setVarXParent.nextElementSibling; + const setVarGParent = setVarRParent.nextElementSibling; + const setVarBParent = setVarGParent.nextElementSibling; + + const setVarM = getVarFromParent(setVarMParent); + const setVarX = setVarXParent.firstElementChild; + const setVarR = setVarRParent.firstElementChild; + const setVarG = setVarGParent.firstElementChild; + const setVarB = setVarBParent.firstElementChild; + + is( + unsetVarL.textContent, + "--var-undefined", + "--var-undefined is not set correctly" + ); + is( + unsetVarL.dataset.variable, + "--var-undefined is not set", + "--var-undefined's dataset.variable is not set correctly" + ); + + is( + setVarM.textContent, + "--var-border-px", + "--var-border-px is not set correctly" + ); + is( + setVarM.dataset.variable, + "--var-border-px = 10px", + "--var-border-px's dataset.variable is not set correctly" + ); + + is( + setVarX.textContent, + "--var-border-style", + "--var-border-style is not set correctly" + ); + is( + setVarX.dataset.variable, + "--var-border-style = solid", + "var-border-style's dataset.variable is not set correctly" + ); + + is( + setVarR.textContent, + "--var-border-r", + "--var-defined-r is not set correctly" + ); + is( + setVarR.dataset.variable, + "--var-border-r = 255", + "--var-defined-r's dataset.variable is not set correctly" + ); + + is( + setVarG.textContent, + "--var-border-g", + "--var-defined-g is not set correctly" + ); + is( + setVarG.dataset.variable, + "--var-border-g = 0", + "--var-defined-g's dataset.variable is not set correctly" + ); + + is( + setVarB.textContent, + "--var-border-b", + "--var-defined-b is not set correctly" + ); + is( + setVarB.dataset.variable, + "--var-border-b = 0", + "--var-defined-b's dataset.variable is not set correctly" + ); +} + +async function testSingleLevelVariable(inspector, view) { + info( + "Test support for variable functionality of a single level of " + + "undefined variables. Format: var(x, constant)" + ); + + await selectNode("#d", inspector); + const unsetVar = getRuleViewProperty( + view, + "#d", + "font-size" + ).valueSpan.querySelector(".ruleview-unmatched-variable"); + + is( + unsetVar.textContent, + "--var-undefined", + "--var-undefined is not set correctly" + ); + is( + unsetVar.dataset.variable, + "--var-undefined is not set", + "--var-undefined's dataset.variable is not set correctly" + ); +} + +async function testDoubleLevelVariable(inspector, view) { + info( + "Test support for variable functionality of double level of " + + "undefined variables. Format: var(x, var(y, constant))" + ); + + await selectNode("#e", inspector); + const allUnsetVars = getRuleViewProperty( + view, + "#e", + "color" + ).valueSpan.querySelectorAll(".ruleview-unmatched-variable"); + + is(allUnsetVars.length, 2, "The number of unset variables is mismatched."); + + const unsetVar1 = allUnsetVars[0]; + const unsetVar2 = allUnsetVars[1]; + + is( + unsetVar1.textContent, + "--var-undefined", + "--var-undefined is not set correctly" + ); + is( + unsetVar1.dataset.variable, + "--var-undefined is not set", + "--var-undefined's dataset.variable is not set correctly" + ); + + is( + unsetVar2.textContent, + "--var-undefined-2", + "--var-undefined is not set correctly" + ); + is( + unsetVar2.dataset.variable, + "--var-undefined-2 is not set", + "--var-undefined-2's dataset.variable is not set correctly" + ); +} + +async function testTripleLevelVariable(inspector, view) { + info( + "Test support for variable functionality of triple level of " + + "undefined variables. Format: var(x, var(y, var(z, constant)))" + ); + + await selectNode("#f", inspector); + const allUnsetVars = getRuleViewProperty( + view, + "#f", + "border-style" + ).valueSpan.querySelectorAll(".ruleview-unmatched-variable"); + + is(allUnsetVars.length, 3, "The number of unset variables is mismatched."); + + const unsetVar1 = allUnsetVars[0]; + const unsetVar2 = allUnsetVars[1]; + const unsetVar3 = allUnsetVars[2]; + + is( + unsetVar1.textContent, + "--var-undefined", + "--var-undefined is not set correctly" + ); + is( + unsetVar1.dataset.variable, + "--var-undefined is not set", + "--var-undefined's dataset.variable is not set correctly" + ); + + is( + unsetVar2.textContent, + "--var-undefined-2", + "--var-undefined-2 is not set correctly" + ); + is( + unsetVar2.dataset.variable, + "--var-undefined-2 is not set", + "--var-defined-r-2's dataset.variable is not set correctly" + ); + + is( + unsetVar3.textContent, + "--var-undefined-3", + "--var-undefined-3 is not set correctly" + ); + is( + unsetVar3.dataset.variable, + "--var-undefined-3 is not set", + "--var-defined-r-3's dataset.variable is not set correctly" + ); +} + +function getVarFromParent(varParent) { + return varParent.firstElementChild.firstElementChild; +} diff --git a/devtools/client/inspector/rules/test/browser_rules_variables_03-case-sensitive.js b/devtools/client/inspector/rules/test/browser_rules_variables_03-case-sensitive.js new file mode 100644 index 0000000000..21fd00ec42 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_variables_03-case-sensitive.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that inherited CSS variables are case senstive. + +const TEST_URI = URL_ROOT + "doc_variables_3.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + await selectNode("#target", inspector); + + const upperCaseVarEl = getRuleViewProperty( + view, + "div", + "color" + ).valueSpan.querySelector(".ruleview-variable"); + const lowerCaseVarEl = getRuleViewProperty( + view, + "div", + "background" + ).valueSpan.querySelector(".ruleview-variable"); + + is(upperCaseVarEl.textContent, "--COLOR", "upper case variable is matched"); + is( + lowerCaseVarEl.textContent, + "--background", + "lower case variable is matched" + ); +}); diff --git a/devtools/client/inspector/rules/test/browser_rules_variables_04-valid-chars.js b/devtools/client/inspector/rules/test/browser_rules_variables_04-valid-chars.js new file mode 100644 index 0000000000..4e163b1fb7 --- /dev/null +++ b/devtools/client/inspector/rules/test/browser_rules_variables_04-valid-chars.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for variables in rule view. + +const TEST_URI = URL_ROOT + "doc_variables_4.html"; + +add_task(async function () { + await addTab(TEST_URI); + const { inspector, view } = await openRuleView(); + + await testNumber(inspector, view); + await testDash(inspector, view); +}); + +async function testNumber(inspector, view) { + info( + "Test support for allowing vars that begin with a number" + + "Format: --10: 10px;" + ); + + await selectNode("#a", inspector); + + const upperCaseVarEl = getRuleViewProperty( + view, + "#a", + "font-size" + ).valueSpan.querySelector(".ruleview-variable"); + + is( + upperCaseVarEl.dataset.variable, + "--10 = 10px", + "variable that starts with a number is valid" + ); +} + +async function testDash(inspector, view) { + info( + "Test support for allowing vars that begin with a dash" + + "Format: ---blue: blue;" + ); + + await selectNode("#b", inspector); + + const upperCaseVarEl = getRuleViewProperty( + view, + "#b", + "color" + ).valueSpan.querySelector(".ruleview-variable"); + + is( + upperCaseVarEl.dataset.variable, + "---blue = blue", + "variable that starts with a dash is valid" + ); +} diff --git a/devtools/client/inspector/rules/test/doc_author-sheet.html b/devtools/client/inspector/rules/test/doc_author-sheet.html new file mode 100644 index 0000000000..7595bc2e79 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_author-sheet.html @@ -0,0 +1,34 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> +<head> + <title>authored sheet test</title> + + <style> + pre a { + color: orange; + } + </style> + + <script> + "use strict"; + var style = "data:text/css,a { background-color: seagreen; }"; + var uri = SpecialPowers.Services.io.newURI(style); + var windowUtils = SpecialPowers.getDOMWindowUtils(window); + windowUtils.loadSheet(uri, windowUtils.AUTHOR_SHEET); + </script> + +</head> +<body> + <input type=text placeholder=test></input> + <input type=color></input> + <input type=range></input> + <input type=number></input> + <progress></progress> + <blockquote type=cite> + <pre _moz_quote=true> + inspect <a href="foo">user agent</a> styles + </pre> + </blockquote> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_blob_stylesheet.html b/devtools/client/inspector/rules/test/doc_blob_stylesheet.html new file mode 100644 index 0000000000..b408c2f6b0 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_blob_stylesheet.html @@ -0,0 +1,39 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> +</html> +<html> +<head> + <meta charset="utf-8"> + <title>Blob stylesheet sourcemap</title> +</head> +<body> +<h1>Test</h1> +<script> +"use strict"; + +var cssContent = `body { + background-color: black; +} +body > h1 { + color: white; +} +` + +"//# sourceMappingURL=data:application/json;base64,ewoidmVyc2lvbiI6IDMsCiJtYX" + +"BwaW5ncyI6ICJBQUFBLElBQUs7RUFDSCxnQkFBZ0IsRUFBRSxLQUFLOztBQUN2QixTQUFPO0VBQ0" + +"wsS0FBSyxFQUFFLEtBQUsiLAoic291cmNlcyI6IFsidGVzdC5zY3NzIl0sCiJzb3VyY2VzQ29udG" + +"VudCI6IFsiYm9keSB7XG4gIGJhY2tncm91bmQtY29sb3I6IGJsYWNrO1xuICAmID4gaDEge1xuIC" + +"AgIGNvbG9yOiB3aGl0ZTsgIFxuICB9XG59XG4iXSwKIm5hbWVzIjogW10sCiJmaWxlIjogInRlc3" + +"QuY3NzIgp9Cg=="; +var cssBlob = new Blob([cssContent], {type: "text/css"}); +var url = URL.createObjectURL(cssBlob); + +var head = document.querySelector("head"); +var link = document.createElement("link"); +link.rel = "stylesheet"; +link.type = "text/css"; +link.href = url; +head.appendChild(link); +</script> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_class_panel_autocomplete.html b/devtools/client/inspector/rules/test/doc_class_panel_autocomplete.html new file mode 100644 index 0000000000..9b5e3f2c01 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_class_panel_autocomplete.html @@ -0,0 +1,41 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html class="auto-html-class-1 auto-html-class-2 auto-bold"> +<head> + <title>Class panel autocomplete test</title> + + <link href="./doc_class_panel_autocomplete_stylesheet.css" rel="stylesheet" type="text/css"> + <style> + .auto-inline-class-1 { + padding: 1em; + } + .auto-inline-class-2 { + padding: 2em; + } + .auto-inline-class-3 { + padding: 3em; + } + + .auto-inline-class-1, + div.auto-inline-class-2, + p:first-of-type.auto-inline-class-3, + .auto-inline-class-4 { + background-color: blue; + } + + :root .auto-bold .auto-inline-class-5 { + font-size: bold; + } + </style> + <script defer> + "use strict"; + const x = document.styleSheets[0]; + x.insertRule(".auto-cssom-primary-color { color: tomato; }", 1); + </script> +</head> +<body class="auto-body-class-1 auto-body-class-2 auto-bold"> + <div id="auto-div-id-1" class="auto-div-class-1 auto-div-class-2 auto-bold"> the ocean </div> + <div id="auto-div-id-2" class="auto-div-class-1 auto-div-class-2 auto-bold"> roaring </div> + <div id="auto-div-id-3"> ahead </div> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_class_panel_autocomplete_stylesheet.css b/devtools/client/inspector/rules/test/doc_class_panel_autocomplete_stylesheet.css new file mode 100644 index 0000000000..c8b83ac6fc --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_class_panel_autocomplete_stylesheet.css @@ -0,0 +1,20 @@ +.auto-stylesheet-class-1 { + padding: 1em; +} +.auto-stylesheet-class-2 { + padding: 2em; +} +.auto-stylesheet-class-3 { + padding: 3em; +} + +.auto-stylesheet-class-1, +div.auto-stylesheet-class-2, +p:first-of-type.auto-stylesheet-class-3, +.auto-stylesheet-class-4 { + background-color: blue; +} + +:root .auto-bold .auto-stylesheet-class-5 { + font-size: bold; +} diff --git a/devtools/client/inspector/rules/test/doc_conditional_import.css b/devtools/client/inspector/rules/test/doc_conditional_import.css new file mode 100644 index 0000000000..32529fc608 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_conditional_import.css @@ -0,0 +1,3 @@ +h1, [test-hint=imported-conditional] { + color: rebeccapurple; +} diff --git a/devtools/client/inspector/rules/test/doc_content_stylesheet.html b/devtools/client/inspector/rules/test/doc_content_stylesheet.html new file mode 100644 index 0000000000..8af8ae950f --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_content_stylesheet.html @@ -0,0 +1,35 @@ +<html> +<head> + <title>test</title> + + <link href="./doc_content_stylesheet_linked.css" rel="stylesheet" type="text/css"> + + <script> + /* eslint no-unused-vars: [2, {"vars": "local"}] */ + "use strict"; + // Load script.css + function loadCSS() { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.type = "text/css"; + link.href = "./doc_content_stylesheet_script.css"; + document.getElementsByTagName("head")[0].appendChild(link); + } + </script> + + <style> + table { + border: 1px solid #000; + } + </style> +</head> +<body onload="loadCSS();"> + <table id="target"> + <tr> + <td> + <h3>Simple test</h3> + </td> + </tr> + </table> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_content_stylesheet_imported.css b/devtools/client/inspector/rules/test/doc_content_stylesheet_imported.css new file mode 100644 index 0000000000..ea1a3d986b --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_content_stylesheet_imported.css @@ -0,0 +1,5 @@ +@import url("./doc_content_stylesheet_imported2.css"); + +#target { + text-decoration: underline; +} diff --git a/devtools/client/inspector/rules/test/doc_content_stylesheet_imported2.css b/devtools/client/inspector/rules/test/doc_content_stylesheet_imported2.css new file mode 100644 index 0000000000..77c73299ea --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_content_stylesheet_imported2.css @@ -0,0 +1,3 @@ +#target { + text-decoration: underline; +} diff --git a/devtools/client/inspector/rules/test/doc_content_stylesheet_linked.css b/devtools/client/inspector/rules/test/doc_content_stylesheet_linked.css new file mode 100644 index 0000000000..712ba78fb6 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_content_stylesheet_linked.css @@ -0,0 +1,3 @@ +table { + border-collapse: collapse; +} diff --git a/devtools/client/inspector/rules/test/doc_content_stylesheet_script.css b/devtools/client/inspector/rules/test/doc_content_stylesheet_script.css new file mode 100644 index 0000000000..5aa5e2c6cb --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_content_stylesheet_script.css @@ -0,0 +1,5 @@ +@import url("./doc_content_stylesheet_imported.css"); + +table { + opacity: 1; +} diff --git a/devtools/client/inspector/rules/test/doc_copystyles.css b/devtools/client/inspector/rules/test/doc_copystyles.css new file mode 100644 index 0000000000..83f0c87b12 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_copystyles.css @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +html, body, #testid { + color: #F00; + background-color: #00F; + font-size: 12px; + border-color: #00F !important; + --var: "*/"; +} diff --git a/devtools/client/inspector/rules/test/doc_copystyles.html b/devtools/client/inspector/rules/test/doc_copystyles.html new file mode 100644 index 0000000000..da1b4c0b3f --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_copystyles.html @@ -0,0 +1,11 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> + <head> + <title>Test case for copying stylesheet in rule-view</title> + <link rel="stylesheet" type="text/css" href="doc_copystyles.css"/> + </head> + <body> + <div id='testid'>Styled Node</div> + </body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_cssom.html b/devtools/client/inspector/rules/test/doc_cssom.html new file mode 100644 index 0000000000..6c44cfcf98 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_cssom.html @@ -0,0 +1,25 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> +<head> + <title>CSSOM test</title> + + <script> + "use strict"; + window.onload = function() { + const x = document.styleSheets[0]; + x.insertRule("div { color: seagreen; }", 1); + + // Add a rule with a leading newline, to test that inspector can handle it. + x.insertRule("\ndiv { font-weight: bold; }", 1); + }; + </script> + + <style> + span { } + </style> +</head> +<body> + <div id="target"> the ocean </div> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_custom.html b/devtools/client/inspector/rules/test/doc_custom.html new file mode 100644 index 0000000000..09bf501d59 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_custom.html @@ -0,0 +1,33 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> + <head> + <style> + #testidSimple { + --background-color: blue; + } + .testclassSimple { + --background-color: green; + } + + .testclassImportant { + --background-color: green !important; + } + #testidImportant { + --background-color: blue; + } + + #testidDisable { + --background-color: blue; + } + .testclassDisable { + --background-color: green; + } + </style> + </head> + <body> + <div id="testidSimple" class="testclassSimple">Styled Node</div> + <div id="testidImportant" class="testclassImportant">Styled Node</div> + <div id="testidDisable" class="testclassDisable">Styled Node</div> + </body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_edit_imported_selector.html b/devtools/client/inspector/rules/test/doc_edit_imported_selector.html new file mode 100644 index 0000000000..5b2120850a --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_edit_imported_selector.html @@ -0,0 +1,13 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Edit a selector in an imported stylesheet</title> + <link rel="stylesheet" type="text/css" href="doc_content_stylesheet_script.css"> +</head> +<body> + <div id="target">Styled node</div> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_filter.html b/devtools/client/inspector/rules/test/doc_filter.html new file mode 100644 index 0000000000..cb2df9feb6 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_filter.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<html> +<head> + <title>Bug 1055181 - CSS Filter Editor Widget</title> + <style> + body { + filter: blur(2px) contrast(2); + } + </style> +</head> diff --git a/devtools/client/inspector/rules/test/doc_grid_area_gridline_names.html b/devtools/client/inspector/rules/test/doc_grid_area_gridline_names.html new file mode 100644 index 0000000000..0fe8527c01 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_grid_area_gridline_names.html @@ -0,0 +1,59 @@ +<!doctype html> +<style type='text/css'> + /* Implicit gridlines created from explicit grid areas. */ + .wrapperA { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-auto-rows: minmax(100px, auto); + grid-template-areas: + "header header header" + "main main main"; + } + + .header { + grid-column: header-start / header-end; + grid-row: header-start / header-end; + } + + .main { + grid-area: main; + } + + /* Implicit grid areas created from explicit gridlines */ + .wrapperB { + display: grid; + grid-template-columns: [main-start] 1fr [content-start] 1fr [content-end main-end]; + grid-template-rows: [main-start] 100px [content-start] 100px [content-end main-end]; + } + + .contentArea { + grid-column: content-start / content-end; + grid-row: content-start / content-end; + } + + .wrapperC { + display: grid; + grid-template-columns: [a-start b-end] 1fr [c]; + } + + .a { + grid-column: a-start / a-end; + } + + .b { + grid-column: b-start / b-end; + } +</style> +<div> + <div class="wrapperA"> + <div class="header">Header</div> + <div class="main">Content</div> + </div> + <div class="wrapperB"> + <div class="contentArea">Implicit area named "content".</div> + </div> + <div class="wrapperC"> + <div class="a">A.</div> + <div class="b">B.</div> + </div> +</div>
\ No newline at end of file diff --git a/devtools/client/inspector/rules/test/doc_grid_names.html b/devtools/client/inspector/rules/test/doc_grid_names.html new file mode 100644 index 0000000000..0aefb18585 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_grid_names.html @@ -0,0 +1,17 @@ +<!doctype html> +<style type='text/css'> + #grid { + display: grid; + grid-template-rows: [row1-start] auto [row2-start] auto [row2-end]; + grid-template-columns: [col1-start] 100px [col2-start] 100px [col3-start] 100px [col3-end]; + } + #cell3 { + grid-column: "col3-start"; + grid-row: "row2-start"; + } +</style> +<div id="grid"> + <div>cell1</div> + <div id="cell2">cell2</div> + <div id="cell3">cell3</div> +</div>
\ No newline at end of file diff --git a/devtools/client/inspector/rules/test/doc_imported_anonymous_layer.css b/devtools/client/inspector/rules/test/doc_imported_anonymous_layer.css new file mode 100644 index 0000000000..fb537b53ae --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_imported_anonymous_layer.css @@ -0,0 +1,4 @@ +h1, [test-hint=imported-anonymous-layer--no-rule-layer] { + color:cyan; + outline: 10px solid cyan; +} diff --git a/devtools/client/inspector/rules/test/doc_imported_named_layer.css b/devtools/client/inspector/rules/test/doc_imported_named_layer.css new file mode 100644 index 0000000000..0817f84e9f --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_imported_named_layer.css @@ -0,0 +1,12 @@ +@media screen { + h1, [test-hint=imported-named-layer--no-rule-layer] { + color:tomato; + border: 10px dotted currentColor; + } + + @layer in-imported-stylesheet { + h1, [test-hint=imported-named-layer--named-layer] { + color: purple; + } + } +} diff --git a/devtools/client/inspector/rules/test/doc_imported_no_layer.css b/devtools/client/inspector/rules/test/doc_imported_no_layer.css new file mode 100644 index 0000000000..9290eebc08 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_imported_no_layer.css @@ -0,0 +1,3 @@ +h1, [test-hint=imported-no-layer--no-rule-layer] { + color: gold; +} diff --git a/devtools/client/inspector/rules/test/doc_inactive_css_xul.xhtml b/devtools/client/inspector/rules/test/doc_inactive_css_xul.xhtml new file mode 100644 index 0000000000..ebe347997b --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_inactive_css_xul.xhtml @@ -0,0 +1,15 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://devtools/skin/light-theme.css"?> +<window class="theme-light" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + title="Inactive CSS for XUL documents"> + + <vbox> + <html:img + id="test-img-in-xul" + style="width:10px; height: 10px; grid-column-gap: 5px;"> + </html:img> + </vbox> +</window> diff --git a/devtools/client/inspector/rules/test/doc_inline_sourcemap.html b/devtools/client/inspector/rules/test/doc_inline_sourcemap.html new file mode 100644 index 0000000000..cb107d4244 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_inline_sourcemap.html @@ -0,0 +1,18 @@ +<!doctype html> +<html> +<head> + <title>CSS source maps in inline stylesheets</title> +</head> +<body> + <div>CSS source maps in inline stylesheets</div> + <style> +div { + color: #ff0066; } + +span { + background-color: #EEE; } + +/*# sourceMappingURL=doc_sourcemaps.css.map */ + </style> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_invalid_sourcemap.css b/devtools/client/inspector/rules/test/doc_invalid_sourcemap.css new file mode 100644 index 0000000000..ff96a6b542 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_invalid_sourcemap.css @@ -0,0 +1,3 @@ +div { color: gold; }
+
+/*# sourceMappingURL=this-source-map-does-not-exist.css.map */
\ No newline at end of file diff --git a/devtools/client/inspector/rules/test/doc_invalid_sourcemap.html b/devtools/client/inspector/rules/test/doc_invalid_sourcemap.html new file mode 100644 index 0000000000..cd3a74817f --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_invalid_sourcemap.html @@ -0,0 +1,11 @@ +<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Invalid source map</title>
+ <link rel="stylesheet" type="text/css" href="doc_invalid_sourcemap.css">
+</head>
+<body>
+ <div>invalid source map</div>
+</body>
+</html> diff --git a/devtools/client/inspector/rules/test/doc_keyframeLineNumbers.html b/devtools/client/inspector/rules/test/doc_keyframeLineNumbers.html new file mode 100644 index 0000000000..9964bf5069 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_keyframeLineNumbers.html @@ -0,0 +1,45 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>keyframe line numbers test</title> + <style type="text/css"> +div { + animation-duration: 1s; + animation-iteration-count: infinite; + animation-direction: alternate; + animation-name: CC; +} + +span { + animation-duration: 3s; + animation-iteration-count: infinite; + animation-direction: alternate; + animation-name: DD; +} + +@keyframes CC { + from { + background: #ffffff; + } + to { + background: #f06; + } +} + +@keyframes DD { + from { + background: seagreen; + } + to { + background: chartreuse; + } +} + </style> +</head> +<body> + <div id="outer"> + <span id="inner">lizards</div> + </div> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_keyframeanimation.css b/devtools/client/inspector/rules/test/doc_keyframeanimation.css new file mode 100644 index 0000000000..64582ed358 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_keyframeanimation.css @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.box { + height: 50px; + width: 50px; +} + +.circle { + width: 20px; + height: 20px; + border-radius: 10px; + background-color: #FFCB01; +} + +#pacman { + width: 0px; + height: 0px; + border-right: 60px solid transparent; + border-top: 60px solid #FFCB01; + border-left: 60px solid #FFCB01; + border-bottom: 60px solid #FFCB01; + border-top-left-radius: 60px; + border-bottom-left-radius: 60px; + border-top-right-radius: 60px; + border-bottom-right-radius: 60px; + top: 120px; + left: 150px; + position: absolute; + animation-name: pacman; + animation-fill-mode: forwards; + animation-timing-function: linear; + animation-duration: 15s; +} + +#boxy { + top: 170px; + left: 450px; + position: absolute; + animation: 4s linear 0s normal none infinite boxy; +} + + +#moxy { + animation-name: moxy, boxy; + animation-delay: 3.5s; + animation-duration: 2s; + top: 170px; + left: 650px; + position: absolute; +} + +@-moz-keyframes pacman { + 100% { + left: 750px; + } +} + +@keyframes pacman { + 100% { + left: 750px; + } +} + +@keyframes boxy { + 10% { + background-color: blue; + } + + 20% { + background-color: green; + } + + 100% { + opacity: 0; + } +} + +@keyframes moxy { + to { + opacity: 0; + } +} diff --git a/devtools/client/inspector/rules/test/doc_keyframeanimation.html b/devtools/client/inspector/rules/test/doc_keyframeanimation.html new file mode 100644 index 0000000000..4e02c32f05 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_keyframeanimation.html @@ -0,0 +1,13 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> + <head> + <title>test case for keyframes rule in rule-view</title> + <link rel="stylesheet" type="text/css" href="doc_keyframeanimation.css"/> + </head> + <body> + <div id="pacman"></div> + <div id="boxy" class="circle"></div> + <div id="moxy" class="circle"></div> + </body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_media_queries.html b/devtools/client/inspector/rules/test/doc_media_queries.html new file mode 100644 index 0000000000..f4706dad87 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_media_queries.html @@ -0,0 +1,42 @@ +<html> +<head> + <title>test</title> + <script type="application/javascript"> + + </script> + <style> + div { + width: 1000px; + height: 100px; + background-color: #f00; + } + + @media screen and (min-width: 1px) { + div { + width: 200px; + background-color: yellow; + } + } + + @media (prefers-color-scheme: dark) { + div { + background-color: darkblue; + } + } + </style> + <script> + "use strict"; + if (window.location.search == "?constructed") { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(`div { z-index: 0 }`); + document.adoptedStyleSheets.push(sheet); + } + </script> +</head> +<body> +<div></div> +<iframe + src='https://example.org/document-builder.sjs?html=<style>html { background: cyan;} @media (prefers-color-scheme: dark) {html {background: darkred;}}'> +</iframe> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_print_media_simulation.html b/devtools/client/inspector/rules/test/doc_print_media_simulation.html new file mode 100644 index 0000000000..e0caa25296 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_print_media_simulation.html @@ -0,0 +1,27 @@ +<html> +<head> + <title>test print media simulation</title> + <script type="application/javascript"> + + </script> + <style> + div { + width: 1000px; + height: 100px; + background-color: #f00; + } + + @media print { + div { + background-color: #00f; + } + } + </style> +</head> +<body> +<div></div> +<iframe + src='https://example.org/document-builder.sjs?html=<style>html { background-color: %23ff0;} @media print {html {background-color: %230ff;}}'> +</iframe> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_pseudoelement.html b/devtools/client/inspector/rules/test/doc_pseudoelement.html new file mode 100644 index 0000000000..26f94c6d3e --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_pseudoelement.html @@ -0,0 +1,149 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> + <head> + <style> + +body { + color: #333; +} + +.box { + float:left; + width: 128px; + height: 128px; + background: #ddd; + padding: 32px; + margin: 32px; + position:relative; +} + +.box:first-line { + color: orange; + background: red; +} + +.box:first-letter { + color: green; +} + +* { + cursor: default; +} + +nothing { + cursor: pointer; +} + +p::-moz-selection { + color: white; + background: black; +} +p::selection { + color: white; + background: black; +} + +p:first-line { + background: blue; +} +p:first-letter { + color: red; + font-size: 130%; +} + +.box:before { + background: green; + content: " "; + position: absolute; + height:32px; + width:32px; +} + +.box:after { + background: red; + content: " "; + position: absolute; + border-radius: 50%; + height:32px; + width:32px; + top: 50%; + left: 50%; + margin-top: -16px; + margin-left: -16px; +} + +.topleft:before { + top:0; + left:0; +} + +.topleft:first-line { + color: orange; +} +.topleft::selection { + color: orange; +} + +.topright:before { + top:0; + right:0; +} + +.bottomright:before { + bottom:10px; + right:10px; + color: red; +} + +.bottomright:before { + bottom:0; + right:0; +} + +.bottomleft:before { + bottom:0; + left:0; +} + +#list::marker { + color: purple; +} + +dialog::backdrop { + background-color: tomato; +} + + </style> + </head> + <body> + <h1>ruleview pseudoelement($("test"));</h1> + + <div id="topleft" class="box topleft"> + <p>Top Left<br />Position</p> + </div> + + <div id="topright" class="box topright"> + <p>Top Right<br />Position</p> + </div> + + <div id="bottomright" class="box bottomright"> + <p>Bottom Right<br />Position</p> + </div> + + <div id="bottomleft" class="box bottomleft"> + <p>Bottom Left<br />Position</p> + </div> + + <ol> + <li id="list" class="box">List element</li> + </ol> + + <dialog>In dialog</dialog> + <script> + "use strict"; + // This is the only way to have the ::backdrop style to be applied + document.querySelector("dialog").showModal() + </script> + </body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_ruleLineNumbers.html b/devtools/client/inspector/rules/test/doc_ruleLineNumbers.html new file mode 100644 index 0000000000..5a157f384c --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_ruleLineNumbers.html @@ -0,0 +1,19 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <title>simple testcase</title> + <style type="text/css"> + #testid { + background-color: seagreen; + } + + body { + color: chartreuse; + } + </style> +</head> +<body> + <div id="testid">simple testcase</div> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_rules_imported_stylesheet_edit.html b/devtools/client/inspector/rules/test/doc_rules_imported_stylesheet_edit.html new file mode 100644 index 0000000000..f1c87fa0ab --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_rules_imported_stylesheet_edit.html @@ -0,0 +1,4 @@ +<style type="text/css"> + @import url("./sjs_imported_stylesheet_edit.sjs"); +</style> +<body><div></div></body> diff --git a/devtools/client/inspector/rules/test/doc_sourcemaps.css b/devtools/client/inspector/rules/test/doc_sourcemaps.css new file mode 100644 index 0000000000..a9b437a408 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_sourcemaps.css @@ -0,0 +1,7 @@ +div { + color: #ff0066; } + +span { + background-color: #EEE; } + +/*# sourceMappingURL=doc_sourcemaps.css.map */
\ No newline at end of file diff --git a/devtools/client/inspector/rules/test/doc_sourcemaps.css.map b/devtools/client/inspector/rules/test/doc_sourcemaps.css.map new file mode 100644 index 0000000000..0f7486fd91 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_sourcemaps.css.map @@ -0,0 +1,7 @@ +{ +"version": 3, +"mappings": "AAGA,GAAI;EACF,KAAK,EAHU,OAAI;;AAMrB,IAAK;EACH,gBAAgB,EAAE,IAAI", +"sources": ["doc_sourcemaps.scss"], +"names": [], +"file": "doc_sourcemaps.css" +} diff --git a/devtools/client/inspector/rules/test/doc_sourcemaps.html b/devtools/client/inspector/rules/test/doc_sourcemaps.html new file mode 100644 index 0000000000..0014e55fe9 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_sourcemaps.html @@ -0,0 +1,11 @@ +<!doctype html> +<html> +<head> + <title>testcase for testing CSS source maps</title> + <link rel="stylesheet" type="text/css" href="simple.css"/> + <link rel="stylesheet" type="text/css" href="doc_sourcemaps.css"/> +</head> +<body> + <div>source maps <span>testcase</span></div> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_sourcemaps.scss b/devtools/client/inspector/rules/test/doc_sourcemaps.scss new file mode 100644 index 0000000000..0ff6c471bb --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_sourcemaps.scss @@ -0,0 +1,10 @@ + +$paulrougetpink: #f06; + +div { + color: $paulrougetpink; +} + +span { + background-color: #EEE; +}
\ No newline at end of file diff --git a/devtools/client/inspector/rules/test/doc_sourcemaps2.css b/devtools/client/inspector/rules/test/doc_sourcemaps2.css new file mode 100644 index 0000000000..c3e4ae7b40 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_sourcemaps2.css @@ -0,0 +1,5 @@ +div { + color: #ff0066; } + +span { + background-color: #EEE; } diff --git a/devtools/client/inspector/rules/test/doc_sourcemaps2.css^headers^ b/devtools/client/inspector/rules/test/doc_sourcemaps2.css^headers^ new file mode 100644 index 0000000000..a5e1f3c7a0 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_sourcemaps2.css^headers^ @@ -0,0 +1 @@ +X-SourceMap: doc_sourcemaps.css.map diff --git a/devtools/client/inspector/rules/test/doc_sourcemaps2.html b/devtools/client/inspector/rules/test/doc_sourcemaps2.html new file mode 100644 index 0000000000..e6990700e7 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_sourcemaps2.html @@ -0,0 +1,11 @@ +<!doctype html> +<html> +<head> + <title>testcase for testing CSS source maps</title> + <link rel="stylesheet" type="text/css" href="simple.css"/> + <link rel="stylesheet" type="text/css" href="doc_sourcemaps2.css"/> +</head> +<body> + <div>source maps <span>testcase</span></div> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_style_editor_link.css b/devtools/client/inspector/rules/test/doc_style_editor_link.css new file mode 100644 index 0000000000..e49e1f5871 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_style_editor_link.css @@ -0,0 +1,3 @@ +div { + opacity: 1; +}
\ No newline at end of file diff --git a/devtools/client/inspector/rules/test/doc_test_image.png b/devtools/client/inspector/rules/test/doc_test_image.png Binary files differnew file mode 100644 index 0000000000..769c636340 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_test_image.png diff --git a/devtools/client/inspector/rules/test/doc_urls_clickable.css b/devtools/client/inspector/rules/test/doc_urls_clickable.css new file mode 100644 index 0000000000..e2f77934e2 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_urls_clickable.css @@ -0,0 +1,9 @@ +.relative1 { + background-image: url(./doc_test_image.png); +} +.absolute { + background: url("https://example.com/browser/devtools/client/inspector/rules/test/doc_test_image.png"); +} +.base64 { + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=='); +} diff --git a/devtools/client/inspector/rules/test/doc_urls_clickable.html b/devtools/client/inspector/rules/test/doc_urls_clickable.html new file mode 100644 index 0000000000..b0265a703e --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_urls_clickable.html @@ -0,0 +1,30 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> + <head> + + <link href="./doc_urls_clickable.css" rel="stylesheet" type="text/css"> + + <style> + .relative2 { + background-image: url(doc_test_image.png); + } + </style> + </head> + <body> + + <div class="relative1">Background image #1 with relative path (loaded from external css)</div> + + <div class="relative2">Background image #2 with relative path (loaded from style tag)</div> + + <div class="absolute">Background image with absolute path (loaded from external css)</div> + + <div class="base64">Background image with base64 url (loaded from external css)</div> + + <div class="inline" style="background: url(doc_test_image.png);">Background image with relative path (loaded from style attribute)</div> + + <div class="inline-resolved" style="background-image: url(./doc_test_image.png)">Background image with resolved relative path (loaded from style attribute)</div> + + <div class="noimage">No background image :(</div> + </body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_variables_1.html b/devtools/client/inspector/rules/test/doc_variables_1.html new file mode 100644 index 0000000000..5b7905f47e --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_variables_1.html @@ -0,0 +1,23 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> +<head> + <title>variables test</title> + + <style> + * { + --color: tomato; + --bg: violet; + } + + div { + --color: chartreuse; + color: var(--color, red); + background-color: var(--not-set, var(--bg)); + } + </style> +</head> +<body> + <div id="target" style="--bg: seagreen;"> the ocean </div> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_variables_2.html b/devtools/client/inspector/rules/test/doc_variables_2.html new file mode 100644 index 0000000000..4215ea87c6 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_variables_2.html @@ -0,0 +1,45 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> +<head> + <title>variables test</title> + <style> + :root { + --var-border-px: 10px; + --var-border-style: solid; + --var-border-r: 255; + --var-border-g: 0; + --var-border-b: 0; + } + #a { + --var-defined-font-size: 60px; + font-size: var(--var-not-defined, var(--var-defined-font-size)); + } + #b { + --var-defined-r-1: 255; + --var-defined-r-2: 0; + color: rgb(var(--var-defined-r-1, var(--var-defined-r-2)), 0, 0); + } + #c { + border: var(--var-undefined, var(--var-border-px)) var(--var-border-style) rgb(var(--var-border-r), var(--var-border-g), var(--var-border-b)) + } + #d { + font-size: var(--var-undefined, 30px); + } + #e { + color: var(--var-undefined, var(--var-undefined-2, blue)); + } + #f { + border-style: var(--var-undefined, var(--var-undefined-2, var(--var-undefined-3, solid))); + } + </style> +</head> +<body> + <div id="a">A</div><br> + <div id="b">B</div><br> + <div id="c">C</div><br> + <div id="d">D</div><br> + <div id="e">E</div><br> + <div id="f">F</div> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_variables_3.html b/devtools/client/inspector/rules/test/doc_variables_3.html new file mode 100644 index 0000000000..61027c7b23 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_variables_3.html @@ -0,0 +1,16 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html style="--COLOR: green; --background: black"> +<head> + + <style> + div { + background: var(--background); + color: var(--COLOR); + } + </style> +</head> +<body> + <div id="target">test</div> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_variables_4.html b/devtools/client/inspector/rules/test/doc_variables_4.html new file mode 100644 index 0000000000..81441c67c2 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_variables_4.html @@ -0,0 +1,23 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> +<head> + <title>variables test</title> + <style> + :root { + --10: 10px; + ---blue: blue; + } + #a { + font-size: var(--10); + } + #b { + color: var(---blue); + } + </style> +</head> +<body> + <div id="a">A</div><br> + <div id="b">B</div> +</body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_visited.html b/devtools/client/inspector/rules/test/doc_visited.html new file mode 100644 index 0000000000..b18e8c3da1 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_visited.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <style type='text/css'> + a:visited, #visited-and-other-matched-selector { + background-color: transparent; + border-color: lime; + color: rgba(0, 255, 0, 0.8); + font-size: 100px; + margin-left: 50px; + text-decoration-color: lime; + text-emphasis-color: seagreen; + } + a:visited { color: lime; } + a:link { color: blue; } + a { color: pink; } + </style> + </head> + <body> + <a href="./doc_visited.html" id="visited">visited link</a> + <a href="#" id="unvisited">unvisited link</a> + <a href="./doc_visited.html" id="visited-and-other-matched-selector"> + visited and other matched selector + </a> + </body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_visited_in_media_query.html b/devtools/client/inspector/rules/test/doc_visited_in_media_query.html new file mode 100644 index 0000000000..ff95cfbc73 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_visited_in_media_query.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <style type='text/css'> + @media (min-width:1px) + { + a { + color: lime; + margin-left: 1px; + } + } + </style> + </head> + <body> + <a href="./doc_visited_in_media_query.html" id="visited">visited link</a> + </body> +</html> diff --git a/devtools/client/inspector/rules/test/doc_visited_with_style_attribute.html b/devtools/client/inspector/rules/test/doc_visited_with_style_attribute.html new file mode 100644 index 0000000000..0f07fb9d48 --- /dev/null +++ b/devtools/client/inspector/rules/test/doc_visited_with_style_attribute.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + </head> + <body> + <a href="./doc_visited_with_style_attribute.html" style="margin: 0;" id="visited">visited link</a> + </body> +</html> 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; +} diff --git a/devtools/client/inspector/rules/test/sjs_imported_stylesheet_edit.sjs b/devtools/client/inspector/rules/test/sjs_imported_stylesheet_edit.sjs new file mode 100644 index 0000000000..5a68c5571c --- /dev/null +++ b/devtools/client/inspector/rules/test/sjs_imported_stylesheet_edit.sjs @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; +const INITIAL_CONTENT = ` +div { + color: red +} +`; + +const UPDATED_CONTENT = ` +span { + color: green; +} + +a { + color: blue; +} + +div { + color: gold; +} +`; + +/** + * This sjs file supports three endpoint: + * - "sjs_imported_stylesheet_edit.sjs" -> will return a text/css content which + * will be either INITIAL_CONTENT or UPDATED_CONTENT. Initially will return + * INITIAL_CONTENT. + * - "sjs_imported_stylesheet_edit.sjs?update-stylesheet" -> will update an + * internal flag. After calling this URL, the regular endpoint will return + * UPDATED_CONTENT instead of INITIAL_CONTENT + * - "sjs_imported_stylesheet_edit.sjs?setup" -> set the internal flag to its + * default value. Should be called at the beginning of every test to avoid + * side effects. + */ +function handleRequest(request, response) { + const { queryString } = request; + if (queryString === "setup") { + setState("serve-updated-content", "false"); + response.setHeader("Content-Type", "text/html"); + response.write("OK"); + } else if (queryString === "update-stylesheet") { + setState("serve-updated-content", "true"); + response.setHeader("Content-Type", "text/html"); + response.write("OK"); + } else { + response.setHeader("Content-Type", "text/css"); + const shouldServeUpdatedCSS = getState("serve-updated-content") == "true"; + response.write(shouldServeUpdatedCSS ? UPDATED_CONTENT : INITIAL_CONTENT); + } +} diff --git a/devtools/client/inspector/rules/test/square_svg.sjs b/devtools/client/inspector/rules/test/square_svg.sjs new file mode 100644 index 0000000000..7f42dfdae1 --- /dev/null +++ b/devtools/client/inspector/rules/test/square_svg.sjs @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +function handleRequest(request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Access-Control-Allow-Origin", "*", false); + response.setHeader("Access-Control-Allow-Headers", "content-type", false); + response.setHeader("Content-Type", "image/svg+xml", false); + response.write( + `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><rect width="16" height="16" /></svg>` + ); +} diff --git a/devtools/client/inspector/rules/types.js b/devtools/client/inspector/rules/types.js new file mode 100644 index 0000000000..62c0db7fd4 --- /dev/null +++ b/devtools/client/inspector/rules/types.js @@ -0,0 +1,165 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +/** + * A CSS class. + */ +exports.classes = { + // The CSS class name. + name: PropTypes.string, + + // Whether or not the CSS class is applied. + isApplied: PropTypes.bool, +}; + +/** + * A CSS declaration. + */ +const declaration = (exports.declaration = { + // Array of the computed properties for a CSS declaration. + computedProperties: PropTypes.arrayOf( + PropTypes.shape({ + // Whether or not the computed property is overridden. + isOverridden: PropTypes.bool, + // The computed property name. + name: PropTypes.string, + // The computed priority (either "important" or an empty string). + priority: PropTypes.string, + // The computed property value. + value: PropTypes.string, + }) + ), + + // An unique CSS declaration id. + id: PropTypes.string, + + // Whether or not the declaration is valid. (Does it make sense for this value + // to be assigned to this property name?) + isDeclarationValid: PropTypes.bool, + + // Whether or not the declaration is enabled. + isEnabled: PropTypes.bool, + + // Whether or not the declaration is invisible. In an inherited rule, only the + // inherited declarations are shown and the rest are considered invisible. + isInvisible: PropTypes.bool, + + // Whether or not the declaration's property name is known. + isKnownProperty: PropTypes.bool, + + // Whether or not the property name is valid. + isNameValid: PropTypes.bool, + + // Whether or not the the declaration is overridden. + isOverridden: PropTypes.bool, + + // Whether or not the declaration is changed by the user. + isPropertyChanged: PropTypes.bool, + + // The declaration's property name. + name: PropTypes.string, + + // The declaration's priority (either "important" or an empty string). + priority: PropTypes.string, + + // The declaration's property value. + value: PropTypes.string, +}); + +/** + * The pseudo classes redux structure. + */ +exports.pseudoClasses = { + // An object containing the :active pseudo class toggle state. + ":active": PropTypes.shape({ + // Whether or not the :active pseudo class is checked. + isChecked: PropTypes.bool, + // Whether or not the :active pseudo class is disabled. + isDisabled: PropTypes.bool, + }), + + // An object containing the :focus pseudo class toggle state. + ":focus": PropTypes.shape({ + // Whether or not the :focus pseudo class is checked + isChecked: PropTypes.bool, + // Whether or not the :focus pseudo class is disabled. + isDisabled: PropTypes.bool, + }), + + // An object containing the :focus-within pseudo class toggle state. + ":focus-within": PropTypes.shape({ + // Whether or not the :focus-within pseudo class is checked + isChecked: PropTypes.bool, + // Whether or not the :focus-within pseudo class is disabled. + isDisabled: PropTypes.bool, + }), + + // An object containing the :hover pseudo class toggle state. + ":hover": PropTypes.shape({ + // Whether or not the :hover pseudo class is checked. + isChecked: PropTypes.bool, + // Whether or not the :hover pseudo class is disabled. + isDisabled: PropTypes.bool, + }), +}; + +/** + * A CSS selector. + */ +const selector = (exports.selector = { + // Function that returns a Promise containing an unique CSS selector. + getUniqueSelector: PropTypes.func, + // Array of the selectors that match the selected element. + matchedSelectors: PropTypes.arrayOf(PropTypes.string), + // The CSS rule's selector text content. + selectorText: PropTypes.string, + // Array of the CSS rule's selectors. + selectors: PropTypes.arrayOf(PropTypes.string), +}); + +/** + * A CSS Rule. + */ +exports.rule = { + // Array of CSS declarations. + declarations: PropTypes.arrayOf(PropTypes.shape(declaration)), + + // An unique CSS rule id. + id: PropTypes.string, + + // An object containing information about the CSS rule's inheritance. + inheritance: PropTypes.shape({ + // The NodeFront of the element this rule was inherited from. + inherited: PropTypes.object, + // A header label for where the element this rule was inherited from. + inheritedSource: PropTypes.string, + }), + + // Whether or not the rule does not match the current selected element. + isUnmatched: PropTypes.bool, + + // Whether or not the rule is an user agent style. + isUserAgentStyle: PropTypes.bool, + + // An object containing information about the CSS keyframes rules. + keyframesRule: PropTypes.shape({ + // The actor ID of the keyframes rule. + id: PropTypes.string, + // The keyframes rule name. + keyframesName: PropTypes.string, + }), + + // The pseudo-element keyword used in the rule. + pseudoElement: PropTypes.string, + + // An object containing information about the CSS rule's selector. + selector: PropTypes.shape(selector), + + // The CSS rule type. + type: PropTypes.number, +}; diff --git a/devtools/client/inspector/rules/utils/l10n.js b/devtools/client/inspector/rules/utils/l10n.js new file mode 100644 index 0000000000..b90748ae22 --- /dev/null +++ b/devtools/client/inspector/rules/utils/l10n.js @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/shared/locales/styleinspector.properties" +); + +module.exports = { + getStr: (...args) => L10N.getStr(...args), + getFormatStr: (...args) => L10N.getFormatStr(...args), +}; diff --git a/devtools/client/inspector/rules/utils/moz.build b/devtools/client/inspector/rules/utils/moz.build new file mode 100644 index 0000000000..cc3fa4dfbd --- /dev/null +++ b/devtools/client/inspector/rules/utils/moz.build @@ -0,0 +1,10 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "l10n.js", + "utils.js", +) diff --git a/devtools/client/inspector/rules/utils/utils.js b/devtools/client/inspector/rules/utils/utils.js new file mode 100644 index 0000000000..c6a23ac59b --- /dev/null +++ b/devtools/client/inspector/rules/utils/utils.js @@ -0,0 +1,357 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + VIEW_NODE_CSS_QUERY_CONTAINER, + VIEW_NODE_FONT_TYPE, + VIEW_NODE_IMAGE_URL_TYPE, + VIEW_NODE_INACTIVE_CSS, + VIEW_NODE_LOCATION_TYPE, + VIEW_NODE_PROPERTY_TYPE, + VIEW_NODE_SELECTOR_TYPE, + VIEW_NODE_SHAPE_POINT_TYPE, + VIEW_NODE_SHAPE_SWATCH, + VIEW_NODE_VALUE_TYPE, + VIEW_NODE_VARIABLE_TYPE, +} = require("resource://devtools/client/inspector/shared/node-types.js"); +const INSET_POINT_TYPES = ["top", "right", "bottom", "left"]; + +/** + * Returns the [Rule] object associated with the given node. + * + * @param {DOMNode} node + * The node which we want to find the [Rule] object for + * @param {ElementStyle} elementStyle + * The [ElementStyle] associated with the selected element + * @return {Rule|null} associated with the given node + */ +function getRuleFromNode(node, elementStyle) { + const ruleEl = node.closest(".ruleview-rule[data-rule-id]"); + const ruleId = ruleEl ? ruleEl.dataset.ruleId : null; + return ruleId ? elementStyle.getRule(ruleId) : null; +} + +/** + * Returns the [TextProperty] object associated with the given node. + * + * @param {DOMNode} node + * The node which we want to find the [TextProperty] object for + * @param {Rule|null} rule + * The [Rule] associated with the given node + * @return {TextProperty|null} associated with the given node + */ +function getDeclarationFromNode(node, rule) { + if (!rule) { + return null; + } + + const declarationEl = node.closest(".ruleview-property[data-declaration-id]"); + const declarationId = declarationEl + ? declarationEl.dataset.declarationId + : null; + return rule ? rule.getDeclaration(declarationId) : null; +} + +/** + * Get the type of a given node in the Rules view. + * + * @param {DOMNode} node + * The node which we want information about + * @param {ElementStyle} elementStyle + * The ElementStyle to which this rule belongs + * @return {Object|null} containing the following props: + * - rule {Rule} The Rule object. + * - type {String} One of the VIEW_NODE_XXX_TYPE const in + * client/inspector/shared/node-types. + * - value {Object} Depends on the type of the node. + * - view {String} Always "rule" to indicate the rule view. + * Otherwise, returns null if the node isn't anything we care about. + */ +// eslint-disable-next-line complexity +function getNodeInfo(node, elementStyle) { + if (!node) { + return null; + } + + const rule = getRuleFromNode(node, elementStyle); + const declaration = getDeclarationFromNode(node, rule); + const classList = node.classList; + + let type, value; + + if (declaration && classList.contains("ruleview-propertyname")) { + type = VIEW_NODE_PROPERTY_TYPE; + value = { + property: node.textContent, + value: getPropertyNameAndValue(node).value, + enabled: declaration.enabled, + overridden: declaration.overridden, + pseudoElement: rule.pseudoElement, + sheetHref: rule.domRule.href, + textProperty: declaration, + }; + } else if (declaration && classList.contains("ruleview-propertyvalue")) { + type = VIEW_NODE_VALUE_TYPE; + value = { + property: getPropertyNameAndValue(node).name, + value: node.textContent, + enabled: declaration.enabled, + overridden: declaration.overridden, + pseudoElement: rule.pseudoElement, + sheetHref: rule.domRule.href, + textProperty: declaration, + }; + } else if (declaration && classList.contains("ruleview-font-family")) { + const { name: propertyName, value: propertyValue } = + getPropertyNameAndValue(node); + type = VIEW_NODE_FONT_TYPE; + value = { + property: propertyName, + value: propertyValue, + enabled: declaration.enabled, + overridden: declaration.overridden, + pseudoElement: rule.pseudoElement, + sheetHref: rule.domRule.href, + textProperty: declaration, + }; + } else if (declaration && classList.contains("ruleview-shape-point")) { + type = VIEW_NODE_SHAPE_POINT_TYPE; + value = { + property: getPropertyNameAndValue(node).name, + value: node.textContent, + enabled: declaration.enabled, + overridden: declaration.overridden, + pseudoElement: rule.pseudoElement, + sheetHref: rule.domRule.href, + textProperty: declaration, + toggleActive: getShapeToggleActive(node), + point: getShapePoint(node), + }; + } else if (declaration && classList.contains("ruleview-unused-warning")) { + type = VIEW_NODE_INACTIVE_CSS; + value = declaration.isUsed(); + } else if (node.closest(".container-query-declaration")) { + type = VIEW_NODE_CSS_QUERY_CONTAINER; + const li = node.closest("li.container-query"); + value = { + ancestorIndex: li.getAttribute("data-ancestor-index"), + rule, + }; + } else if (declaration && classList.contains("ruleview-shapeswatch")) { + type = VIEW_NODE_SHAPE_SWATCH; + value = { + enabled: declaration.enabled, + overridden: declaration.overridden, + textProperty: declaration, + }; + } else if ( + declaration && + (classList.contains("ruleview-variable") || + classList.contains("ruleview-unmatched-variable")) + ) { + type = VIEW_NODE_VARIABLE_TYPE; + value = { + property: getPropertyNameAndValue(node).name, + value: node.textContent.trim(), + enabled: declaration.enabled, + overridden: declaration.overridden, + pseudoElement: rule.pseudoElement, + sheetHref: rule.domRule.href, + textProperty: declaration, + variable: node.dataset.variable, + }; + } else if ( + declaration && + classList.contains("theme-link") && + !classList.contains("ruleview-rule-source") + ) { + type = VIEW_NODE_IMAGE_URL_TYPE; + value = { + property: getPropertyNameAndValue(node).name, + value: node.parentNode.textContent, + url: node.href, + enabled: declaration.enabled, + overridden: declaration.overridden, + pseudoElement: rule.pseudoElement, + sheetHref: rule.domRule.href, + textProperty: declaration, + }; + } else if ( + classList.contains("ruleview-selector-unmatched") || + classList.contains("ruleview-selector-matched") || + classList.contains("ruleview-selectorcontainer") || + classList.contains("ruleview-selector") || + classList.contains("ruleview-selector-attribute") || + classList.contains("ruleview-selector-pseudo-class") || + classList.contains("ruleview-selector-pseudo-class-lock") + ) { + type = VIEW_NODE_SELECTOR_TYPE; + value = rule.selectorText; + } else if ( + classList.contains("ruleview-rule-source") || + classList.contains("ruleview-rule-source-label") + ) { + type = VIEW_NODE_LOCATION_TYPE; + value = rule.sheet?.href ? rule.sheet.href : rule.title; + } else { + return null; + } + + return { + rule, + type, + value, + view: "rule", + }; +} + +/** + * Walk up the DOM from a given node until a parent property holder is found, + * and return the textContent for the name and value nodes. + * Stops at the first property found, so if node is inside the computed property + * list, the computed property will be returned + * + * @param {DOMNode} node + * The node to start from + * @return {Object} {name, value} + */ +function getPropertyNameAndValue(node) { + while (node?.classList) { + // Check first for ruleview-computed since it's the deepest + if ( + node.classList.contains("ruleview-computed") || + node.classList.contains("ruleview-property") + ) { + return { + name: node.querySelector(".ruleview-propertyname").textContent, + value: node.querySelector(".ruleview-propertyvalue").textContent, + }; + } + + node = node.parentNode; + } + + return null; +} + +/** + * Walk up the DOM from a given node until a parent property holder is found, + * and return an active shape toggle if one exists. + * + * @param {DOMNode} node + * The node to start from + * @returns {DOMNode} The active shape toggle node, if one exists. + */ +function getShapeToggleActive(node) { + while (node?.classList) { + // Check first for ruleview-computed since it's the deepest + if ( + node.classList.contains("ruleview-computed") || + node.classList.contains("ruleview-property") + ) { + return node.querySelector(".ruleview-shapeswatch.active"); + } + + node = node.parentNode; + } + + return null; +} + +/** + * Get the point associated with a shape point node. + * + * @param {DOMNode} node + * A shape point node + * @returns {String} The point associated with the given node. + */ +function getShapePoint(node) { + const classList = node.classList; + let point = node.dataset.point; + // Inset points use classes instead of data because a single span can represent + // multiple points. + const insetClasses = []; + classList.forEach(className => { + if (INSET_POINT_TYPES.includes(className)) { + insetClasses.push(className); + } + }); + if (insetClasses.length) { + point = insetClasses.join(","); + } + return point; +} + +/** + * Returns an array of CSS variables used in a CSS property value. + * If no CSS variables are used, returns an empty array. + * + * @param {String} propertyValue + * CSS property value (e.g. "1px solid var(--color, blue)") + * @return {Array} + * List of variable names (e.g. ["--color"]) + * + */ +function getCSSVariables(propertyValue = "") { + const variables = []; + const parts = propertyValue.split(/var\(\s*--/); + + if (parts.length) { + // Skip first part. It is the substring before the first occurence of "var(--" + for (let i = 1; i < parts.length; i++) { + // Split the part by any of the following characters expected after a variable name: + // comma, closing parenthesis or whitespace. + // Take just the first match. Anything else is either: + // - the fallback value, ex: ", blue" from "var(--color, blue)" + // - the closing parenthesis, ex: ")" from "var(--color)" + const variable = parts[i].split(/[,)\s+]/).shift(); + + if (variable) { + // Add back the double-dash. The initial string was split by "var(--" + variables.push(`--${variable}`); + } + } + } + + return variables; +} + +/** + * Get the CSS compatibility issue information for a given node. + * + * @param {DOMNode} node + * The node which we want compatibility information about + * @param {ElementStyle} elementStyle + * The ElementStyle to which this rule belongs + */ +async function getNodeCompatibilityInfo(node, elementStyle) { + const rule = getRuleFromNode(node, elementStyle); + const declaration = getDeclarationFromNode(node, rule); + const issue = await declaration.isCompatible(); + + return issue; +} + +/** + * Returns true if the given CSS property value contains the given variable name. + * + * @param {String} propertyValue + * CSS property value (e.g. "var(--color)") + * @param {String} variableName + * CSS variable name (e.g. "--color") + * @return {Boolean} + */ +function hasCSSVariable(propertyValue, variableName) { + return getCSSVariables(propertyValue).includes(variableName); +} + +module.exports = { + getCSSVariables, + getNodeInfo, + getRuleFromNode, + hasCSSVariable, + getNodeCompatibilityInfo, +}; diff --git a/devtools/client/inspector/rules/views/class-list-previewer.js b/devtools/client/inspector/rules/views/class-list-previewer.js new file mode 100644 index 0000000000..e4e99bedde --- /dev/null +++ b/devtools/client/inspector/rules/views/class-list-previewer.js @@ -0,0 +1,310 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ClassList = require("resource://devtools/client/inspector/rules/models/class-list.js"); + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/inspector.properties" +); +const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); +const { debounce } = require("resource://devtools/shared/debounce.js"); + +/** + * This UI widget shows a textfield and a series of checkboxes in the rule-view. It is + * used to toggle classes on the current node selection, and add new classes. + */ +class ClassListPreviewer { + /* + * @param {Inspector} inspector + * The current inspector instance. + * @param {DomNode} containerEl + * The element in the rule-view where the widget should go. + */ + constructor(inspector, containerEl) { + this.inspector = inspector; + this.containerEl = containerEl; + this.model = new ClassList(inspector); + + this.onNewSelection = this.onNewSelection.bind(this); + this.onCheckBoxChanged = this.onCheckBoxChanged.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onAddElementInputModified = debounce( + this.onAddElementInputModified, + 75, + this + ); + this.onCurrentNodeClassChanged = this.onCurrentNodeClassChanged.bind(this); + this.onNodeFrontWillUnset = this.onNodeFrontWillUnset.bind(this); + this.onAutocompleteClassHovered = debounce( + this.onAutocompleteClassHovered, + 75, + this + ); + this.onAutocompleteClosed = this.onAutocompleteClosed.bind(this); + + // Create the add class text field. + this.addEl = this.doc.createElement("input"); + this.addEl.classList.add("devtools-textinput"); + this.addEl.classList.add("add-class"); + this.addEl.setAttribute( + "placeholder", + L10N.getStr("inspector.classPanel.newClass.placeholder") + ); + this.addEl.addEventListener("keydown", this.onKeyDown); + this.addEl.addEventListener("input", this.onAddElementInputModified); + this.containerEl.appendChild(this.addEl); + + // Create the class checkboxes container. + this.classesEl = this.doc.createElement("div"); + this.classesEl.classList.add("classes"); + this.containerEl.appendChild(this.classesEl); + + // Create the autocomplete popup + this.autocompletePopup = new AutocompletePopup(this.inspector.toolbox.doc, { + listId: "inspector_classListPreviewer_autocompletePopupListBox", + position: "bottom", + autoSelect: true, + useXulWrapper: true, + input: this.addEl, + onClick: (e, item) => { + if (item) { + this.addEl.value = item.label; + this.autocompletePopup.hidePopup(); + this.autocompletePopup.clearItems(); + this.model.previewClass(item.label); + } + }, + onSelect: item => { + if (item) { + this.onAutocompleteClassHovered(item?.label); + } + }, + }); + + // Start listening for interesting events. + this.inspector.selection.on("new-node-front", this.onNewSelection); + this.inspector.selection.on( + "node-front-will-unset", + this.onNodeFrontWillUnset + ); + this.containerEl.addEventListener("input", this.onCheckBoxChanged); + this.model.on("current-node-class-changed", this.onCurrentNodeClassChanged); + this.autocompletePopup.on("popup-closed", this.onAutocompleteClosed); + + this.onNewSelection(); + } + + destroy() { + this.inspector.selection.off("new-node-front", this.onNewSelection); + this.inspector.selection.off( + "node-front-will-unset", + this.onNodeFrontWillUnset + ); + this.autocompletePopup.off("popup-closed", this.onAutocompleteClosed); + this.addEl.removeEventListener("keydown", this.onKeyDown); + this.addEl.removeEventListener("input", this.onAddElementInputModified); + this.containerEl.removeEventListener("input", this.onCheckBoxChanged); + + this.autocompletePopup.destroy(); + + this.containerEl.innerHTML = ""; + + this.model.destroy(); + this.containerEl = null; + this.inspector = null; + this.addEl = null; + this.classesEl = null; + } + + get doc() { + return this.containerEl.ownerDocument; + } + + /** + * Render the content of the panel. You typically don't need to call this as the panel + * renders itself on inspector selection changes. + */ + render() { + this.classesEl.innerHTML = ""; + + for (const { name, isApplied } of this.model.currentClasses) { + const checkBox = this.renderCheckBox(name, isApplied); + this.classesEl.appendChild(checkBox); + } + + if (!this.model.currentClasses.length) { + this.classesEl.appendChild(this.renderNoClassesMessage()); + } + } + + /** + * Render a single checkbox for a given classname. + * + * @param {String} name + * The name of this class. + * @param {Boolean} isApplied + * Is this class currently applied on the DOM node. + * @return {DOMNode} The DOM element for this checkbox. + */ + renderCheckBox(name, isApplied) { + const box = this.doc.createElement("input"); + box.setAttribute("type", "checkbox"); + if (isApplied) { + box.setAttribute("checked", "checked"); + } + box.dataset.name = name; + + const labelWrapper = this.doc.createElement("label"); + labelWrapper.setAttribute("title", name); + labelWrapper.appendChild(box); + + // A child element is required to do the ellipsis. + const label = this.doc.createElement("span"); + label.textContent = name; + labelWrapper.appendChild(label); + + return labelWrapper; + } + + /** + * Render the message displayed in the panel when the current element has no classes. + * + * @return {DOMNode} The DOM element for the message. + */ + renderNoClassesMessage() { + const msg = this.doc.createElement("p"); + msg.classList.add("no-classes"); + msg.textContent = L10N.getStr("inspector.classPanel.noClasses"); + return msg; + } + + /** + * Focus the add-class text field. + */ + focusAddClassField() { + if (this.addEl) { + this.addEl.focus(); + } + } + + onCheckBoxChanged({ target }) { + if (!target.dataset.name) { + return; + } + + this.model.setClassState(target.dataset.name, target.checked).catch(e => { + // Only log the error if the panel wasn't destroyed in the meantime. + if (this.containerEl) { + console.error(e); + } + }); + } + + onKeyDown(event) { + // If the popup is already open, all the keyboard interaction are handled + // directly by the popup component. + if (this.autocompletePopup.isOpen) { + return; + } + + // Open the autocomplete popup on Ctrl+Space / ArrowDown (when the input isn't empty) + if ( + (this.addEl.value && event.key === " " && event.ctrlKey) || + event.key === "ArrowDown" + ) { + this.onAddElementInputModified(); + return; + } + + if (this.addEl.value !== "" && event.key === "Enter") { + this.addClassName(this.addEl.value); + } + } + + async onAddElementInputModified() { + const newValue = this.addEl.value; + + // if the input is empty, let's close the popup, if it was open. + if (newValue === "") { + if (this.autocompletePopup.isOpen) { + this.autocompletePopup.hidePopup(); + this.autocompletePopup.clearItems(); + } else { + this.model.previewClass(""); + } + return; + } + + // Otherwise, we need to update the popup items to match the new input. + let items = []; + try { + const classNames = await this.model.getClassNames(newValue); + if (!this.autocompletePopup.isOpen) { + this._previewClassesBeforeAutocompletion = + this.model.previewClasses.map(previewClass => previewClass.className); + } + items = classNames.map(className => { + return { + preLabel: className.substring(0, newValue.length), + label: className, + }; + }); + } catch (e) { + // If there was an error while retrieving the classNames, we'll simply NOT show the + // popup, which is okay. + console.warn("Error when calling getClassNames", e); + } + + if (!items.length || (items.length == 1 && items[0].label === newValue)) { + this.autocompletePopup.clearItems(); + await this.autocompletePopup.hidePopup(); + this.model.previewClass(newValue); + } else { + this.autocompletePopup.setItems(items); + this.autocompletePopup.openPopup(); + } + } + + async addClassName(className) { + try { + await this.model.addClassName(className); + this.render(); + this.addEl.value = ""; + } catch (e) { + // Only log the error if the panel wasn't destroyed in the meantime. + if (this.containerEl) { + console.error(e); + } + } + } + + onNewSelection() { + this.render(); + } + + onCurrentNodeClassChanged() { + this.render(); + } + + onNodeFrontWillUnset() { + this.model.eraseClassPreview(); + this.addEl.value = ""; + } + + onAutocompleteClassHovered(autocompleteItemLabel = "") { + if (this.autocompletePopup.isOpen) { + this.model.previewClass(autocompleteItemLabel); + } + } + + onAutocompleteClosed() { + const inputValue = this.addEl.value; + this.model.previewClass(inputValue); + } +} + +module.exports = ClassListPreviewer; diff --git a/devtools/client/inspector/rules/views/moz.build b/devtools/client/inspector/rules/views/moz.build new file mode 100644 index 0000000000..d5058bdbc2 --- /dev/null +++ b/devtools/client/inspector/rules/views/moz.build @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "class-list-previewer.js", + "rule-editor.js", + "text-property-editor.js", +) diff --git a/devtools/client/inspector/rules/views/rule-editor.js b/devtools/client/inspector/rules/views/rule-editor.js new file mode 100644 index 0000000000..35c4b41e37 --- /dev/null +++ b/devtools/client/inspector/rules/views/rule-editor.js @@ -0,0 +1,842 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { l10n } = require("resource://devtools/shared/inspector/css-logic.js"); +const { + PSEUDO_CLASSES, +} = require("resource://devtools/shared/css/constants.js"); +const { + style: { ELEMENT_STYLE }, +} = require("resource://devtools/shared/constants.js"); +const Rule = require("resource://devtools/client/inspector/rules/models/rule.js"); +const { + InplaceEditor, + editableField, + editableItem, +} = require("resource://devtools/client/shared/inplace-editor.js"); +const TextPropertyEditor = require("resource://devtools/client/inspector/rules/views/text-property-editor.js"); +const { + createChild, + blurOnMultipleProperties, + promiseWarn, +} = require("resource://devtools/client/inspector/shared/utils.js"); +const { + parseNamedDeclarations, + parsePseudoClassesAndAttributes, + SELECTOR_ATTRIBUTE, + SELECTOR_ELEMENT, + SELECTOR_PSEUDO_CLASS, +} = require("resource://devtools/shared/css/parsing-utils.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const CssLogic = require("resource://devtools/shared/inspector/css-logic.js"); + +loader.lazyRequireGetter( + this, + "Tools", + "resource://devtools/client/definitions.js", + true +); + +const STYLE_INSPECTOR_PROPERTIES = + "devtools/shared/locales/styleinspector.properties"; +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES); + +/** + * RuleEditor is responsible for the following: + * Owns a Rule object and creates a list of TextPropertyEditors + * for its TextProperties. + * Manages creation of new text properties. + * + * @param {CssRuleView} ruleView + * The CssRuleView containg the document holding this rule editor. + * @param {Rule} rule + * The Rule object we're editing. + */ +function RuleEditor(ruleView, rule) { + EventEmitter.decorate(this); + + this.ruleView = ruleView; + this.doc = this.ruleView.styleDocument; + this.toolbox = this.ruleView.inspector.toolbox; + this.telemetry = this.toolbox.telemetry; + this.rule = rule; + + this.isEditable = !rule.isSystem; + // Flag that blocks updates of the selector and properties when it is + // being edited + this.isEditing = false; + + this._onNewProperty = this._onNewProperty.bind(this); + this._newPropertyDestroy = this._newPropertyDestroy.bind(this); + this._onSelectorDone = this._onSelectorDone.bind(this); + this._locationChanged = this._locationChanged.bind(this); + this.updateSourceLink = this.updateSourceLink.bind(this); + this._onToolChanged = this._onToolChanged.bind(this); + this._updateLocation = this._updateLocation.bind(this); + this._onSourceClick = this._onSourceClick.bind(this); + + this.rule.domRule.on("location-changed", this._locationChanged); + this.toolbox.on("tool-registered", this._onToolChanged); + this.toolbox.on("tool-unregistered", this._onToolChanged); + + this._create(); +} + +RuleEditor.prototype = { + destroy() { + this.rule.domRule.off("location-changed"); + this.toolbox.off("tool-registered", this._onToolChanged); + this.toolbox.off("tool-unregistered", this._onToolChanged); + + if (this._unsubscribeSourceMap) { + this._unsubscribeSourceMap(); + } + }, + + get sourceMapURLService() { + if (!this._sourceMapURLService) { + // sourceMapURLService is a lazy getter in the toolbox. + this._sourceMapURLService = this.toolbox.sourceMapURLService; + } + + return this._sourceMapURLService; + }, + + get isSelectorEditable() { + const trait = + this.isEditable && + this.rule.domRule.type !== ELEMENT_STYLE && + this.rule.domRule.type !== CSSRule.KEYFRAME_RULE; + + // Do not allow editing anonymousselectors until we can + // detect mutations on pseudo elements in Bug 1034110. + return trait && !this.rule.elementStyle.element.isAnonymous; + }, + + _create() { + this.element = this.doc.createElement("div"); + this.element.className = "ruleview-rule devtools-monospace"; + this.element.dataset.ruleId = this.rule.domRule.actorID; + this.element.setAttribute("uneditable", !this.isEditable); + this.element.setAttribute("unmatched", this.rule.isUnmatched); + this.element._ruleEditor = this; + + // Give a relative position for the inplace editor's measurement + // span to be placed absolutely against. + this.element.style.position = "relative"; + + // Add the source link. + this.source = createChild(this.element, "div", { + class: "ruleview-rule-source theme-link", + }); + this.source.addEventListener("click", this._onSourceClick); + + const sourceLabel = this.doc.createElement("span"); + sourceLabel.classList.add("ruleview-rule-source-label"); + this.source.appendChild(sourceLabel); + + this.updateSourceLink(); + + if (this.rule.domRule.ancestorData.length) { + const parts = this.rule.domRule.ancestorData.map( + (ancestorData, index) => { + if (ancestorData.type == "container") { + const container = this.doc.createElement("li"); + container.classList.add("container-query"); + container.setAttribute("data-ancestor-index", index); + + createChild(container, "span", { + class: "container-query-declaration", + textContent: `@container${ + ancestorData.containerName + ? " " + ancestorData.containerName + : "" + }`, + }); + + container.classList.add("has-tooltip"); + + const jumpToNodeButton = createChild(container, "button", { + class: "open-inspector", + title: l10n("rule.containerQuery.selectContainerButton.tooltip"), + }); + + let containerNodeFront; + const getNodeFront = async () => { + if (!containerNodeFront) { + const res = await this.rule.domRule.getQueryContainerForNode( + index, + this.rule.inherited || + this.ruleView.inspector.selection.nodeFront + ); + containerNodeFront = res.node; + } + return containerNodeFront; + }; + + jumpToNodeButton.addEventListener("click", async () => { + const front = await getNodeFront(); + if (!front) { + return; + } + this.ruleView.inspector.selection.setNodeFront(front); + await this.ruleView.inspector.highlighters.hideHighlighterType( + this.ruleView.inspector.highlighters.TYPES.BOXMODEL + ); + }); + container.append(jumpToNodeButton); + + container.addEventListener("mouseenter", async () => { + const front = await getNodeFront(); + if (!front) { + return; + } + + await this.ruleView.inspector.highlighters.showHighlighterTypeForNode( + this.ruleView.inspector.highlighters.TYPES.BOXMODEL, + front + ); + }); + container.addEventListener("mouseleave", async () => { + await this.ruleView.inspector.highlighters.hideHighlighterType( + this.ruleView.inspector.highlighters.TYPES.BOXMODEL + ); + }); + + createChild(container, "span", { + // Add a space between the container name (or @container if there's no name) + // and the query so the title, which is computed from the DOM, displays correctly. + textContent: " " + ancestorData.containerQuery, + }); + return container; + } + if (ancestorData.type == "layer") { + return `@layer${ + ancestorData.value ? " " + ancestorData.value : "" + }`; + } + if (ancestorData.type == "media") { + return `@media ${ancestorData.value}`; + } + + if (ancestorData.type == "supports") { + return `@supports ${ancestorData.conditionText}`; + } + + if (ancestorData.type == "import") { + return `@import ${ancestorData.value}`; + } + + // We shouldn't get here as `type` should only match to what can be set in + // the StyleRuleActor form, but just in case, let's return an empty string. + console.warn("Unknown ancestor data type:", ancestorData.type); + return ``; + } + ); + + this.ancestorDataEl = createChild(this.element, "ul", { + class: "ruleview-rule-ancestor-data theme-link", + }); + + for (const part of parts) { + if (typeof part == "string") { + createChild(this.ancestorDataEl, "li", { + textContent: part, + }); + } else { + this.ancestorDataEl.append(part); + } + } + } + + const code = createChild(this.element, "div", { + class: "ruleview-code", + }); + + const header = createChild(code, "div", {}); + + this.selectorText = createChild(header, "span", { + class: "ruleview-selectorcontainer", + tabindex: this.isSelectorEditable ? "0" : "-1", + }); + + if (this.isSelectorEditable) { + this.selectorText.addEventListener("click", event => { + // Clicks within the selector shouldn't propagate any further. + event.stopPropagation(); + }); + + editableField({ + element: this.selectorText, + done: this._onSelectorDone, + cssProperties: this.rule.cssProperties, + }); + } + + if (this.rule.domRule.type !== CSSRule.KEYFRAME_RULE) { + let selector = ""; + if (this.rule.domRule.selectors) { + // This is a "normal" rule with a selector. + selector = this.rule.domRule.selectors.join(", "); + // Otherwise, the rule is either inherited or inline, and selectors will + // be computed on demand when the highlighter is requested. + } + + const isHighlighted = this.ruleView.isSelectorHighlighted(selector); + // Handling of click events is delegated to CssRuleView.handleEvent() + createChild(header, "span", { + class: + "ruleview-selectorhighlighter js-toggle-selector-highlighter" + + (isHighlighted ? " highlighted" : ""), + "data-selector": selector, + title: l10n("rule.selectorHighlighter.tooltip"), + }); + } + + this.openBrace = createChild(header, "span", { + class: "ruleview-ruleopen", + textContent: " {", + }); + + this.propertyList = createChild(code, "ul", { + class: "ruleview-propertylist", + }); + + this.populate(); + + this.closeBrace = createChild(code, "div", { + class: "ruleview-ruleclose", + tabindex: this.isEditable ? "0" : "-1", + textContent: "}", + }); + + if (this.isEditable) { + // A newProperty editor should only be created when no editor was + // previously displayed. Since the editors are cleared on blur, + // check this.ruleview.isEditing on mousedown + this._ruleViewIsEditing = false; + + code.addEventListener("mousedown", () => { + this._ruleViewIsEditing = this.ruleView.isEditing; + }); + + code.addEventListener("click", event => { + const selection = this.doc.defaultView.getSelection(); + if (selection.isCollapsed && !this._ruleViewIsEditing) { + this.newProperty(); + } + // Cleanup the _ruleViewIsEditing flag + this._ruleViewIsEditing = false; + }); + + this.element.addEventListener("mousedown", () => { + this.doc.defaultView.focus(); + }); + + // Create a property editor when the close brace is clicked. + editableItem({ element: this.closeBrace }, () => { + this.newProperty(); + }); + } + }, + + /** + * Called when a tool is registered or unregistered. + */ + _onToolChanged() { + // When the source editor is registered, update the source links + // to be clickable; and if it is unregistered, update the links to + // be unclickable. However, some links are never clickable, so + // filter those out first. + if (this.source.getAttribute("unselectable") === "permanent") { + // Nothing. + } else if (this.toolbox.isToolRegistered("styleeditor")) { + this.source.removeAttribute("unselectable"); + } else { + this.source.setAttribute("unselectable", "true"); + } + }, + + /** + * Event handler called when a property changes on the + * StyleRuleActor. + */ + _locationChanged() { + this.updateSourceLink(); + }, + + _onSourceClick() { + if (this.source.hasAttribute("unselectable")) { + return; + } + + const { inspector } = this.ruleView; + if (Tools.styleEditor.isToolSupported(inspector.toolbox)) { + inspector.toolbox.viewSourceInStyleEditorByResource( + this.rule.sheet, + this.rule.ruleLine, + this.rule.ruleColumn + ); + } + }, + + /** + * Update the text of the source link to reflect whether we're showing + * original sources or not. This is a callback for + * SourceMapURLService.subscribeByID, which see. + * + * @param {Object | null} originalLocation + * The original position object (url/line/column) or null. + */ + _updateLocation(originalLocation) { + let displayURL = this.rule.sheet?.href; + const constructed = this.rule.sheet?.constructed; + let line = this.rule.ruleLine; + if (originalLocation) { + displayURL = originalLocation.url; + line = originalLocation.line; + } + + let sourceTextContent = CssLogic.shortSource({ + constructed, + href: displayURL, + }); + let title = displayURL ? displayURL : sourceTextContent; + if (line > 0) { + sourceTextContent += ":" + line; + title += ":" + line; + } + + const sourceLabel = this.element.querySelector( + ".ruleview-rule-source-label" + ); + sourceLabel.setAttribute("title", title); + sourceLabel.textContent = sourceTextContent; + }, + + updateSourceLink() { + if (this.rule.isSystem) { + const sourceLabel = this.element.querySelector( + ".ruleview-rule-source-label" + ); + const title = this.rule.title; + const sourceHref = this.rule.sheet?.href || title; + + const uaLabel = STYLE_INSPECTOR_L10N.getStr("rule.userAgentStyles"); + sourceLabel.textContent = uaLabel + " " + title; + + // Special case about:PreferenceStyleSheet, as it is generated on the + // fly and the URI is not registered with the about: handler. + // https://bugzilla.mozilla.org/show_bug.cgi?id=935803#c37 + if (sourceHref === "about:PreferenceStyleSheet") { + this.source.setAttribute("unselectable", "permanent"); + sourceLabel.textContent = uaLabel; + sourceLabel.removeAttribute("title"); + } + } else { + this._updateLocation(null); + } + + if ( + this.rule.sheet && + !this.rule.isSystem && + this.rule.domRule.type !== ELEMENT_STYLE + ) { + // Only get the original source link if the rule isn't a system + // rule and if it isn't an inline rule. + if (this._unsubscribeSourceMap) { + this._unsubscribeSourceMap(); + } + this._unsubscribeSourceMap = this.sourceMapURLService.subscribeByID( + this.rule.sheet.resourceId, + this.rule.ruleLine, + this.rule.ruleColumn, + this._updateLocation + ); + // Set "unselectable" appropriately. + this._onToolChanged(); + } else if (this.rule.domRule.type === ELEMENT_STYLE) { + this.source.setAttribute("unselectable", "permanent"); + } else { + // Set "unselectable" appropriately. + this._onToolChanged(); + } + + Promise.resolve().then(() => { + this.emit("source-link-updated"); + }); + }, + + /** + * Update the rule editor with the contents of the rule. + * + * @param {Boolean} reset + * True to completely reset the rule editor before populating. + */ + populate(reset) { + // Clear out existing viewers. + while (this.selectorText.hasChildNodes()) { + this.selectorText.removeChild(this.selectorText.lastChild); + } + + // If selector text comes from a css rule, highlight selectors that + // actually match. For custom selector text (such as for the 'element' + // style, just show the text directly. + if (this.rule.domRule.type === ELEMENT_STYLE) { + this.selectorText.textContent = this.rule.selectorText; + } else if (this.rule.domRule.type === CSSRule.KEYFRAME_RULE) { + this.selectorText.textContent = this.rule.domRule.keyText; + } else { + this.rule.domRule.selectors.forEach((selector, i) => { + if (i !== 0) { + createChild(this.selectorText, "span", { + class: "ruleview-selector-separator", + textContent: ", ", + }); + } + + const containerClass = + this.rule.matchedSelectors.indexOf(selector) > -1 + ? "ruleview-selector-matched" + : "ruleview-selector-unmatched"; + const selectorContainer = createChild(this.selectorText, "span", { + class: containerClass, + }); + + const parsedSelector = parsePseudoClassesAndAttributes(selector); + + for (const selectorText of parsedSelector) { + let selectorClass = ""; + + switch (selectorText.type) { + case SELECTOR_ATTRIBUTE: + selectorClass = "ruleview-selector-attribute"; + break; + case SELECTOR_ELEMENT: + selectorClass = "ruleview-selector"; + break; + case SELECTOR_PSEUDO_CLASS: + selectorClass = PSEUDO_CLASSES.some( + pseudo => selectorText.value === pseudo + ) + ? "ruleview-selector-pseudo-class-lock" + : "ruleview-selector-pseudo-class"; + break; + default: + break; + } + + createChild(selectorContainer, "span", { + textContent: selectorText.value, + class: selectorClass, + }); + } + }); + } + + if (reset) { + while (this.propertyList.hasChildNodes()) { + this.propertyList.removeChild(this.propertyList.lastChild); + } + } + + for (const prop of this.rule.textProps) { + if (!prop.editor && !prop.invisible) { + const editor = new TextPropertyEditor(this, prop); + this.propertyList.appendChild(editor.element); + } else if (prop.editor) { + // If an editor already existed, append it to the bottom now to make sure the + // order of editors in the DOM follow the order of the rule's properties. + this.propertyList.appendChild(prop.editor.element); + } + } + }, + + /** + * Programatically add a new property to the rule. + * + * @param {String} name + * Property name. + * @param {String} value + * Property value. + * @param {String} priority + * Property priority. + * @param {Boolean} enabled + * True if the property should be enabled. + * @param {TextProperty} siblingProp + * Optional, property next to which the new property will be added. + * @return {TextProperty} + * The new property + */ + addProperty(name, value, priority, enabled, siblingProp) { + const prop = this.rule.createProperty( + name, + value, + priority, + enabled, + siblingProp + ); + const index = this.rule.textProps.indexOf(prop); + const editor = new TextPropertyEditor(this, prop); + + // Insert this node before the DOM node that is currently at its new index + // in the property list. There is currently one less node in the DOM than + // in the property list, so this causes it to appear after siblingProp. + // If there is no node at its index, as is the case where this is the last + // node being inserted, then this behaves as appendChild. + this.propertyList.insertBefore( + editor.element, + this.propertyList.children[index] + ); + + return prop; + }, + + /** + * Programatically add a list of new properties to the rule. Focus the UI + * to the proper location after adding (either focus the value on the + * last property if it is empty, or create a new property and focus it). + * + * @param {Array} properties + * Array of properties, which are objects with this signature: + * { + * name: {string}, + * value: {string}, + * priority: {string} + * } + * @param {TextProperty} siblingProp + * Optional, the property next to which all new props should be added. + */ + addProperties(properties, siblingProp) { + if (!properties || !properties.length) { + return; + } + + let lastProp = siblingProp; + for (const p of properties) { + const isCommented = Boolean(p.commentOffsets); + const enabled = !isCommented; + lastProp = this.addProperty( + p.name, + p.value, + p.priority, + enabled, + lastProp + ); + } + + // Either focus on the last value if incomplete, or start a new one. + if (lastProp && lastProp.value.trim() === "") { + lastProp.editor.valueSpan.click(); + } else { + this.newProperty(); + } + }, + + /** + * Create a text input for a property name. If a non-empty property + * name is given, we'll create a real TextProperty and add it to the + * rule. + */ + newProperty() { + // If we're already creating a new property, ignore this. + if (!this.closeBrace.hasAttribute("tabindex")) { + return; + } + + // While we're editing a new property, it doesn't make sense to + // start a second new property editor, so disable focusing the + // close brace for now. + this.closeBrace.removeAttribute("tabindex"); + + this.newPropItem = createChild(this.propertyList, "li", { + class: "ruleview-property ruleview-newproperty", + }); + + this.newPropSpan = createChild(this.newPropItem, "span", { + class: "ruleview-propertyname", + tabindex: "0", + }); + + this.multipleAddedProperties = null; + + this.editor = new InplaceEditor({ + element: this.newPropSpan, + done: this._onNewProperty, + destroy: this._newPropertyDestroy, + advanceChars: ":", + contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY, + popup: this.ruleView.popup, + cssProperties: this.rule.cssProperties, + }); + + // Auto-close the input if multiple rules get pasted into new property. + this.editor.input.addEventListener( + "paste", + blurOnMultipleProperties(this.rule.cssProperties) + ); + }, + + /** + * Called when the new property input has been dismissed. + * + * @param {String} value + * The value in the editor. + * @param {Boolean} commit + * True if the value should be committed. + */ + _onNewProperty(value, commit) { + if (!value || !commit) { + return; + } + + // parseDeclarations allows for name-less declarations, but in the present + // case, we're creating a new declaration, it doesn't make sense to accept + // these entries + this.multipleAddedProperties = parseNamedDeclarations( + this.rule.cssProperties.isKnown, + value, + true + ); + + // Blur the editor field now and deal with adding declarations later when + // the field gets destroyed (see _newPropertyDestroy) + this.editor.input.blur(); + + this.telemetry.recordEvent("edit_rule", "ruleview"); + }, + + /** + * Called when the new property editor is destroyed. + * This is where the properties (type TextProperty) are actually being + * added, since we want to wait until after the inplace editor `destroy` + * event has been fired to keep consistent UI state. + */ + _newPropertyDestroy() { + // We're done, make the close brace focusable again. + this.closeBrace.setAttribute("tabindex", "0"); + + this.propertyList.removeChild(this.newPropItem); + delete this.newPropItem; + delete this.newPropSpan; + + // If properties were added, we want to focus the proper element. + // If the last new property has no value, focus the value on it. + // Otherwise, start a new property and focus that field. + if (this.multipleAddedProperties && this.multipleAddedProperties.length) { + this.addProperties(this.multipleAddedProperties); + } + }, + + /** + * Called when the selector's inplace editor is closed. + * Ignores the change if the user pressed escape, otherwise + * commits it. + * + * @param {String} value + * The value contained in the editor. + * @param {Boolean} commit + * True if the change should be applied. + * @param {Number} direction + * The move focus direction number. + */ + async _onSelectorDone(value, commit, direction) { + if ( + !commit || + this.isEditing || + value === "" || + value === this.rule.selectorText + ) { + return; + } + + const ruleView = this.ruleView; + const elementStyle = ruleView._elementStyle; + const element = elementStyle.element; + + this.isEditing = true; + + // Remove highlighter for the previous selector. + if (this.ruleView.isSelectorHighlighted(this.rule.selectorText)) { + await this.ruleView.toggleSelectorHighlighter(this.rule.selectorText); + } + + try { + const response = await this.rule.domRule.modifySelector(element, value); + + // We recompute the list of applied styles, because editing a + // selector might cause this rule's position to change. + const applied = await elementStyle.pageStyle.getApplied(element, { + inherited: true, + matchedSelectors: true, + filter: elementStyle.showUserAgentStyles ? "ua" : undefined, + }); + + this.isEditing = false; + + const { ruleProps, isMatching } = response; + if (!ruleProps) { + // Notify for changes, even when nothing changes, + // just to allow tests being able to track end of this request. + ruleView.emit("ruleview-invalid-selector"); + return; + } + + ruleProps.isUnmatched = !isMatching; + const newRule = new Rule(elementStyle, ruleProps); + const editor = new RuleEditor(ruleView, newRule); + const rules = elementStyle.rules; + + let newRuleIndex = applied.findIndex(r => r.rule == ruleProps.rule); + const oldIndex = rules.indexOf(this.rule); + + // If the selector no longer matches, then we leave the rule in + // the same relative position. + if (newRuleIndex === -1) { + newRuleIndex = oldIndex; + } + + // Remove the old rule and insert the new rule. + rules.splice(oldIndex, 1); + rules.splice(newRuleIndex, 0, newRule); + elementStyle._changed(); + elementStyle.onRuleUpdated(); + + // We install the new editor in place of the old -- you might + // think we would replicate the list-modification logic above, + // but that is complicated due to the way the UI installs + // pseudo-element rules and the like. + this.element.parentNode.replaceChild(editor.element, this.element); + + editor._moveSelectorFocus(direction); + } catch (err) { + this.isEditing = false; + promiseWarn(err); + } + }, + + /** + * Handle moving the focus change after a tab or return keypress in the + * selector inplace editor. + * + * @param {Number} direction + * The move focus direction number. + */ + _moveSelectorFocus(direction) { + if (!direction || direction === Services.focus.MOVEFOCUS_BACKWARD) { + return; + } + + if (this.rule.textProps.length) { + this.rule.textProps[0].editor.nameSpan.click(); + } else { + this.propertyList.click(); + } + }, +}; + +module.exports = RuleEditor; diff --git a/devtools/client/inspector/rules/views/text-property-editor.js b/devtools/client/inspector/rules/views/text-property-editor.js new file mode 100644 index 0000000000..28340c8a14 --- /dev/null +++ b/devtools/client/inspector/rules/views/text-property-editor.js @@ -0,0 +1,1598 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { l10n } = require("resource://devtools/shared/inspector/css-logic.js"); +const { + InplaceEditor, + editableField, +} = require("resource://devtools/client/shared/inplace-editor.js"); +const { + createChild, + appendText, + advanceValidate, + blurOnMultipleProperties, +} = require("resource://devtools/client/inspector/shared/utils.js"); +const { throttle } = require("resource://devtools/shared/throttle.js"); +const { + style: { ELEMENT_STYLE }, +} = require("resource://devtools/shared/constants.js"); + +loader.lazyRequireGetter( + this, + "openContentLink", + "resource://devtools/client/shared/link.js", + true +); +loader.lazyRequireGetter( + this, + ["parseDeclarations", "parseSingleValue"], + "resource://devtools/shared/css/parsing-utils.js", + true +); +loader.lazyRequireGetter( + this, + "findCssSelector", + "resource://devtools/shared/inspector/css-logic.js", + true +); +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AppConstants: "resource://gre/modules/AppConstants.sys.mjs", +}); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const inlineCompatibilityWarningEnabled = Services.prefs.getBoolPref( + "devtools.inspector.ruleview.inline-compatibility-warning.enabled" +); + +const SHARED_SWATCH_CLASS = "ruleview-swatch"; +const COLOR_SWATCH_CLASS = "ruleview-colorswatch"; +const BEZIER_SWATCH_CLASS = "ruleview-bezierswatch"; +const LINEAR_EASING_SWATCH_CLASS = "ruleview-lineareasingswatch"; +const FILTER_SWATCH_CLASS = "ruleview-filterswatch"; +const ANGLE_SWATCH_CLASS = "ruleview-angleswatch"; +const FONT_FAMILY_CLASS = "ruleview-font-family"; +const SHAPE_SWATCH_CLASS = "ruleview-shapeswatch"; + +/* + * An actionable element is an element which on click triggers a specific action + * (e.g. shows a color tooltip, opens a link, …). + */ +const ACTIONABLE_ELEMENTS_SELECTORS = [ + `.${COLOR_SWATCH_CLASS}`, + `.${BEZIER_SWATCH_CLASS}`, + `.${LINEAR_EASING_SWATCH_CLASS}`, + `.${FILTER_SWATCH_CLASS}`, + `.${ANGLE_SWATCH_CLASS}`, + "a", +]; + +/* + * Speeds at which we update the value when the user is dragging its mouse + * over a value. + */ +const SLOW_DRAGGING_SPEED = 0.1; +const DEFAULT_DRAGGING_SPEED = 1; +const FAST_DRAGGING_SPEED = 10; + +// Deadzone in pixels where dragging should not update the value. +const DRAGGING_DEADZONE_DISTANCE = 5; + +const DRAGGABLE_VALUE_CLASSNAME = "ruleview-propertyvalue-draggable"; +const IS_DRAGGING_CLASSNAME = "ruleview-propertyvalue-dragging"; + +// In order to highlight the used fonts in font-family properties, we +// retrieve the list of used fonts from the server. That always +// returns the actually used font family name(s). If the property's +// authored value is sans-serif for instance, the used font might be +// arial instead. So we need the list of all generic font family +// names to underline those when we find them. +const GENERIC_FONT_FAMILIES = [ + "serif", + "sans-serif", + "cursive", + "fantasy", + "monospace", + "system-ui", +]; + +/** + * TextPropertyEditor is responsible for the following: + * Owns a TextProperty object. + * Manages changes to the TextProperty. + * Can be expanded to display computed properties. + * Can mark a property disabled or enabled. + * + * @param {RuleEditor} ruleEditor + * The rule editor that owns this TextPropertyEditor. + * @param {TextProperty} property + * The text property to edit. + */ +function TextPropertyEditor(ruleEditor, property) { + this.ruleEditor = ruleEditor; + this.ruleView = this.ruleEditor.ruleView; + this.cssProperties = this.ruleView.cssProperties; + this.doc = this.ruleEditor.doc; + this.popup = this.ruleView.popup; + this.prop = property; + this.prop.editor = this; + this.browserWindow = this.doc.defaultView.top; + + this._populatedComputed = false; + this._hasPendingClick = false; + this._clickedElementOptions = null; + + this.toolbox = this.ruleView.inspector.toolbox; + this.telemetry = this.toolbox.telemetry; + + this._isDragging = false; + this._hasDragged = false; + this._draggingController = null; + this._draggingValueCache = null; + + this.getGridlineNames = this.getGridlineNames.bind(this); + this.update = this.update.bind(this); + this.updatePropertyState = this.updatePropertyState.bind(this); + this._onDraggablePreferenceChanged = + this._onDraggablePreferenceChanged.bind(this); + this._onEnableChanged = this._onEnableChanged.bind(this); + this._onEnableClicked = this._onEnableClicked.bind(this); + this._onExpandClicked = this._onExpandClicked.bind(this); + this._onNameDone = this._onNameDone.bind(this); + this._onStartEditing = this._onStartEditing.bind(this); + this._onSwatchCommit = this._onSwatchCommit.bind(this); + this._onSwatchPreview = this._onSwatchPreview.bind(this); + this._onSwatchRevert = this._onSwatchRevert.bind(this); + this._onValidate = this.ruleView.debounce(this._previewValue, 10, this); + this._onValueDone = this._onValueDone.bind(this); + + this._draggingOnMouseDown = this._draggingOnMouseDown.bind(this); + this._draggingOnMouseMove = throttle(this._draggingOnMouseMove, 30, this); + this._draggingOnMouseUp = this._draggingOnMouseUp.bind(this); + this._draggingOnKeydown = this._draggingOnKeydown.bind(this); + + this._create(); + this.update(); +} + +TextPropertyEditor.prototype = { + /** + * Boolean indicating if the name or value is being currently edited. + */ + get editing() { + return ( + !!( + this.nameSpan.inplaceEditor || + this.valueSpan.inplaceEditor || + this.ruleView.tooltips.isEditing + ) || this.popup.isOpen + ); + }, + + /** + * Get the rule to the current text property + */ + get rule() { + return this.prop.rule; + }, + + // Exposed for tests. + get _DRAGGING_DEADZONE_DISTANCE() { + return DRAGGING_DEADZONE_DISTANCE; + }, + + /** + * Create the property editor's DOM. + */ + _create() { + this.element = this.doc.createElementNS(HTML_NS, "li"); + this.element.classList.add("ruleview-property"); + this.element.dataset.declarationId = this.prop.id; + this.element._textPropertyEditor = this; + + this.container = createChild(this.element, "div", { + class: "ruleview-propertycontainer", + }); + + // The enable checkbox will disable or enable the rule. + this.enable = createChild(this.container, "input", { + type: "checkbox", + class: "ruleview-enableproperty", + "aria-labelledby": this.prop.id, + tabindex: "-1", + }); + + this.nameContainer = createChild(this.container, "span", { + class: "ruleview-namecontainer", + }); + + // Property name, editable when focused. Property name + // is committed when the editor is unfocused. + this.nameSpan = createChild(this.nameContainer, "span", { + class: "ruleview-propertyname theme-fg-color3", + tabindex: this.ruleEditor.isEditable ? "0" : "-1", + id: this.prop.id, + }); + + appendText(this.nameContainer, ": "); + + // Click to expand the computed properties of the text property. + this.expander = createChild(this.container, "span", { + class: "ruleview-expander theme-twisty", + }); + this.expander.addEventListener("click", this._onExpandClicked, true); + + // Create a span that will hold the property and semicolon. + // Use this span to create a slightly larger click target + // for the value. + this.valueContainer = createChild(this.container, "span", { + class: "ruleview-propertyvaluecontainer", + }); + + // Property value, editable when focused. Changes to the + // property value are applied as they are typed, and reverted + // if the user presses escape. + this.valueSpan = createChild(this.valueContainer, "span", { + class: "ruleview-propertyvalue theme-fg-color1", + tabindex: this.ruleEditor.isEditable ? "0" : "-1", + }); + + // Storing the TextProperty on the elements for easy access + // (for instance by the tooltip) + this.valueSpan.textProperty = this.prop; + this.nameSpan.textProperty = this.prop; + + appendText(this.valueContainer, ";"); + + this.warning = createChild(this.container, "div", { + class: "ruleview-warning", + hidden: "", + title: l10n("rule.warning.title"), + }); + + this.unusedState = createChild(this.container, "div", { + class: "ruleview-unused-warning", + hidden: "", + }); + + if (inlineCompatibilityWarningEnabled) { + this.compatibilityState = createChild(this.container, "div", { + class: "ruleview-compatibility-warning", + hidden: "", + }); + } + + // Filter button that filters for the current property name and is + // displayed when the property is overridden by another rule. + this.filterProperty = createChild(this.container, "div", { + class: "ruleview-overridden-rule-filter", + hidden: "", + title: l10n("rule.filterProperty.title"), + }); + + this.filterProperty.addEventListener("click", event => { + this.ruleEditor.ruleView.setFilterStyles("`" + this.prop.name + "`"); + event.stopPropagation(); + }); + + // Holds the viewers for the computed properties. + // will be populated in |_updateComputed|. + this.computed = createChild(this.element, "ul", { + class: "ruleview-computedlist", + }); + + // Holds the viewers for the overridden shorthand properties. + // will be populated in |_updateShorthandOverridden|. + this.shorthandOverridden = createChild(this.element, "ul", { + class: "ruleview-overridden-items", + }); + + // Only bind event handlers if the rule is editable. + if (this.ruleEditor.isEditable) { + this.enable.addEventListener("click", this._onEnableClicked, true); + this.enable.addEventListener("change", this._onEnableChanged, true); + + this.nameContainer.addEventListener("click", event => { + // Clicks within the name shouldn't propagate any further. + event.stopPropagation(); + + // Forward clicks on nameContainer to the editable nameSpan + if (event.target === this.nameContainer) { + this.nameSpan.click(); + } + }); + + editableField({ + start: this._onStartEditing, + element: this.nameSpan, + done: this._onNameDone, + destroy: this.updatePropertyState, + advanceChars: ":", + contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY, + popup: this.popup, + cssProperties: this.cssProperties, + }); + + // Auto blur name field on multiple CSS rules get pasted in. + this.nameContainer.addEventListener( + "paste", + blurOnMultipleProperties(this.cssProperties) + ); + + this.valueContainer.addEventListener("click", event => { + // Clicks within the value shouldn't propagate any further. + event.stopPropagation(); + + // Forward clicks on valueContainer to the editable valueSpan + if (event.target === this.valueContainer) { + this.valueSpan.click(); + } + }); + + // The mousedown event could trigger a blur event on nameContainer, which + // will trigger a call to the update function. The update function clears + // valueSpan's markup. Thus the regular click event does not bubble up, and + // listener's callbacks are not called. + // So we need to remember where the user clicks in order to re-trigger the click + // after the valueSpan's markup is re-populated. We only need to track this for + // valueSpan's child elements, because direct click on valueSpan will always + // trigger a click event. + this.valueSpan.addEventListener("mousedown", event => { + const clickedEl = event.target; + if (clickedEl === this.valueSpan) { + return; + } + this._hasPendingClick = true; + + const matchedSelector = ACTIONABLE_ELEMENTS_SELECTORS.find(selector => + clickedEl.matches(selector) + ); + if (matchedSelector) { + const similarElements = [ + ...this.valueSpan.querySelectorAll(matchedSelector), + ]; + this._clickedElementOptions = { + selector: matchedSelector, + index: similarElements.indexOf(clickedEl), + }; + } + }); + + this.valueSpan.addEventListener("mouseup", event => { + // if we have dragged, we will handle the pending click in _draggingOnMouseUp instead + if (this._hasDragged) { + return; + } + this._clickedElementOptions = null; + this._hasPendingClick = false; + }); + + this.valueSpan.addEventListener("click", event => { + const target = event.target; + + if (target.nodeName === "a") { + event.stopPropagation(); + event.preventDefault(); + openContentLink(target.href); + } + }); + + this.ruleView.on( + "draggable-preference-updated", + this._onDraggablePreferenceChanged + ); + if (this._isDraggableProperty(this.prop)) { + this._addDraggingCapability(); + } + + editableField({ + start: this._onStartEditing, + element: this.valueSpan, + done: this._onValueDone, + destroy: this.update, + validate: this._onValidate, + advanceChars: advanceValidate, + contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE, + property: this.prop, + defaultIncrement: this.prop.name === "opacity" ? 0.1 : 1, + popup: this.popup, + multiline: true, + maxWidth: () => this.container.getBoundingClientRect().width, + cssProperties: this.cssProperties, + cssVariables: + this.rule.elementStyle.variablesMap.get(this.rule.pseudoElement) || + [], + getGridLineNames: this.getGridlineNames, + showSuggestCompletionOnEmpty: true, + }); + } + }, + + /** + * Get the grid line names of the grid that the currently selected element is + * contained in. + * + * @return {Object} Contains the names of the cols and rows as arrays + * {cols: [], rows: []}. + */ + async getGridlineNames() { + const gridLineNames = { cols: [], rows: [] }; + const layoutInspector = + await this.ruleView.inspector.walker.getLayoutInspector(); + const gridFront = await layoutInspector.getCurrentGrid( + this.ruleView.inspector.selection.nodeFront + ); + + if (gridFront) { + const gridFragments = gridFront.gridFragments; + + for (const gridFragment of gridFragments) { + for (const rowLine of gridFragment.rows.lines) { + // We specifically ignore implicit line names created from implicitly named + // areas. This is because showing implicit line names can be confusing for + // designers who may have used a line name with "-start" or "-end" and created + // an implicitly named grid area without meaning to. + let gridArea; + + for (const name of rowLine.names) { + const rowLineName = + name.substring(0, name.lastIndexOf("-start")) || + name.substring(0, name.lastIndexOf("-end")); + gridArea = gridFragment.areas.find( + area => area.name === rowLineName + ); + + if ( + rowLine.type === "implicit" && + gridArea && + gridArea.type === "implicit" + ) { + continue; + } + gridLineNames.rows.push(name); + } + } + + for (const colLine of gridFragment.cols.lines) { + let gridArea; + + for (const name of colLine.names) { + const colLineName = + name.substring(0, name.lastIndexOf("-start")) || + name.substring(0, name.lastIndexOf("-end")); + gridArea = gridFragment.areas.find( + area => area.name === colLineName + ); + + if ( + colLine.type === "implicit" && + gridArea && + gridArea.type === "implicit" + ) { + continue; + } + gridLineNames.cols.push(name); + } + } + } + } + + // Emit message for test files + this.ruleView.inspector.emit("grid-line-names-updated"); + return gridLineNames; + }, + + /** + * Get the path from which to resolve requests for this + * rule's stylesheet. + * + * @return {String} the stylesheet's href. + */ + get sheetHref() { + const domRule = this.rule.domRule; + if (domRule) { + return domRule.href || domRule.nodeHref; + } + return undefined; + }, + + /** + * Populate the span based on changes to the TextProperty. + */ + // eslint-disable-next-line complexity + update() { + if (this.ruleView.isDestroyed) { + return; + } + + this.updatePropertyState(); + + const name = this.prop.name; + this.nameSpan.textContent = name; + + // Combine the property's value and priority into one string for + // the value. + const store = this.rule.elementStyle.store; + let val = store.userProperties.getProperty( + this.rule.domRule, + name, + this.prop.value + ); + if (this.prop.priority) { + val += " !" + this.prop.priority; + } + + const propDirty = store.userProperties.contains(this.rule.domRule, name); + + if (propDirty) { + this.element.setAttribute("dirty", ""); + } else { + this.element.removeAttribute("dirty"); + } + + const outputParser = this.ruleView._outputParser; + const parserOptions = { + angleClass: "ruleview-angle", + angleSwatchClass: SHARED_SWATCH_CLASS + " " + ANGLE_SWATCH_CLASS, + bezierClass: "ruleview-bezier", + bezierSwatchClass: SHARED_SWATCH_CLASS + " " + BEZIER_SWATCH_CLASS, + colorClass: "ruleview-color", + colorSwatchClass: SHARED_SWATCH_CLASS + " " + COLOR_SWATCH_CLASS, + filterClass: "ruleview-filter", + filterSwatchClass: SHARED_SWATCH_CLASS + " " + FILTER_SWATCH_CLASS, + flexClass: "ruleview-flex js-toggle-flexbox-highlighter", + gridClass: "ruleview-grid js-toggle-grid-highlighter", + linearEasingClass: "ruleview-lineareasing", + linearEasingSwatchClass: + SHARED_SWATCH_CLASS + " " + LINEAR_EASING_SWATCH_CLASS, + shapeClass: "ruleview-shape", + shapeSwatchClass: SHAPE_SWATCH_CLASS, + // Only ask the parser to convert colors to the default color type specified by the + // user if the property hasn't been changed yet. + defaultColorType: !propDirty, + urlClass: "theme-link", + fontFamilyClass: FONT_FAMILY_CLASS, + baseURI: this.sheetHref, + unmatchedVariableClass: "ruleview-unmatched-variable", + matchedVariableClass: "ruleview-variable", + getVariableValue: varName => + this.rule.elementStyle.getVariable(varName, this.rule.pseudoElement), + }; + const frag = outputParser.parseCssProperty(name, val, parserOptions); + + // Save the initial value as the last committed value, + // for restoring after pressing escape. + if (!this.committed) { + this.committed = { + name, + value: frag.textContent, + priority: this.prop.priority, + }; + } + + // Save focused element inside value span if one exists before wiping the innerHTML + let focusedElSelector = null; + if (this.valueSpan.contains(this.doc.activeElement)) { + focusedElSelector = findCssSelector(this.doc.activeElement); + } + + this.valueSpan.innerHTML = ""; + this.valueSpan.appendChild(frag); + if ( + this.valueSpan.textProperty?.name === "grid-template-areas" && + this.isValid() && + (this.valueSpan.innerText.includes(`"`) || + this.valueSpan.innerText.includes(`'`)) + ) { + this._formatGridTemplateAreasValue(); + } + + this.ruleView.emit("property-value-updated", { + rule: this.prop.rule, + property: name, + value: val, + }); + + // Highlight the currently used font in font-family properties. + // If we cannot find a match, highlight the first generic family instead. + const fontFamilySpans = this.valueSpan.querySelectorAll( + "." + FONT_FAMILY_CLASS + ); + if (fontFamilySpans.length && this.prop.enabled && !this.prop.overridden) { + this.rule.elementStyle + .getUsedFontFamilies() + .then(families => { + const usedFontFamilies = families.map(font => font.toLowerCase()); + let foundMatchingFamily = false; + let firstGenericSpan = null; + + for (const span of fontFamilySpans) { + const authoredFont = span.textContent.toLowerCase(); + + if ( + !firstGenericSpan && + GENERIC_FONT_FAMILIES.includes(authoredFont) + ) { + firstGenericSpan = span; + } + + if (usedFontFamilies.includes(authoredFont)) { + span.classList.add("used-font"); + foundMatchingFamily = true; + } + } + + if (!foundMatchingFamily && firstGenericSpan) { + firstGenericSpan.classList.add("used-font"); + } + + this.ruleView.emit("font-highlighted", this.valueSpan); + }) + .catch(e => + console.error("Could not get the list of font families", e) + ); + } + + // Attach the color picker tooltip to the color swatches + this._colorSwatchSpans = this.valueSpan.querySelectorAll( + "." + COLOR_SWATCH_CLASS + ); + if (this.ruleEditor.isEditable) { + for (const span of this._colorSwatchSpans) { + // Adding this swatch to the list of swatches our colorpicker + // knows about + this.ruleView.tooltips.getTooltip("colorPicker").addSwatch(span, { + onShow: this._onStartEditing, + onPreview: this._onSwatchPreview, + onCommit: this._onSwatchCommit, + onRevert: this._onSwatchRevert, + }); + const title = l10n("rule.colorSwatch.tooltip"); + span.setAttribute("title", title); + span.dataset.propertyName = this.nameSpan.textContent; + } + } + + // Attach the cubic-bezier tooltip to the bezier swatches + this._bezierSwatchSpans = this.valueSpan.querySelectorAll( + "." + BEZIER_SWATCH_CLASS + ); + if (this.ruleEditor.isEditable) { + for (const span of this._bezierSwatchSpans) { + // Adding this swatch to the list of swatches our colorpicker + // knows about + this.ruleView.tooltips.getTooltip("cubicBezier").addSwatch(span, { + onShow: this._onStartEditing, + onPreview: this._onSwatchPreview, + onCommit: this._onSwatchCommit, + onRevert: this._onSwatchRevert, + }); + const title = l10n("rule.bezierSwatch.tooltip"); + span.setAttribute("title", title); + } + } + + // Attach the linear easing tooltip to the linear easing swatches + this._linearEasingSwatchSpans = this.valueSpan.querySelectorAll( + "." + LINEAR_EASING_SWATCH_CLASS + ); + if (this.ruleEditor.isEditable) { + for (const span of this._linearEasingSwatchSpans) { + // Adding this swatch to the list of swatches our colorpicker + // knows about + this.ruleView.tooltips + .getTooltip("linearEaseFunction") + .addSwatch(span, { + onShow: this._onStartEditing, + onPreview: this._onSwatchPreview, + onCommit: this._onSwatchCommit, + onRevert: this._onSwatchRevert, + }); + span.setAttribute("title", l10n("rule.bezierSwatch.tooltip")); + } + } + + // Attach the filter editor tooltip to the filter swatch + const span = this.valueSpan.querySelector("." + FILTER_SWATCH_CLASS); + if (this.ruleEditor.isEditable) { + if (span) { + parserOptions.filterSwatch = true; + + this.ruleView.tooltips.getTooltip("filterEditor").addSwatch( + span, + { + onShow: this._onStartEditing, + onPreview: this._onSwatchPreview, + onCommit: this._onSwatchCommit, + onRevert: this._onSwatchRevert, + }, + outputParser, + parserOptions + ); + const title = l10n("rule.filterSwatch.tooltip"); + span.setAttribute("title", title); + } + } + + this.angleSwatchSpans = this.valueSpan.querySelectorAll( + "." + ANGLE_SWATCH_CLASS + ); + if (this.ruleEditor.isEditable) { + for (const angleSpan of this.angleSwatchSpans) { + angleSpan.on("unit-change", this._onSwatchCommit); + const title = l10n("rule.angleSwatch.tooltip"); + angleSpan.setAttribute("title", title); + } + } + + const nodeFront = this.ruleView.inspector.selection.nodeFront; + + const flexToggle = this.valueSpan.querySelector(".ruleview-flex"); + if (flexToggle) { + flexToggle.setAttribute("title", l10n("rule.flexToggle.tooltip")); + flexToggle.classList.toggle( + "active", + this.ruleView.inspector.highlighters.getNodeForActiveHighlighter( + this.ruleView.inspector.highlighters.TYPES.FLEXBOX + ) === nodeFront + ); + } + + const gridToggle = this.valueSpan.querySelector(".ruleview-grid"); + if (gridToggle) { + gridToggle.setAttribute("title", l10n("rule.gridToggle.tooltip")); + gridToggle.classList.toggle( + "active", + this.ruleView.highlighters.gridHighlighters.has(nodeFront) + ); + gridToggle.toggleAttribute( + "disabled", + !this.ruleView.highlighters.canGridHighlighterToggle(nodeFront) + ); + } + + const shapeToggle = this.valueSpan.querySelector(".ruleview-shapeswatch"); + if (shapeToggle) { + const mode = + "css" + + name + .split("-") + .map(s => { + return s[0].toUpperCase() + s.slice(1); + }) + .join(""); + shapeToggle.setAttribute("data-mode", mode); + } + + // Now that we have updated the property's value, we might have a pending + // click on the value container. If we do, we have to trigger a click event + // on the right element. + // If we are dragging, we don't need to handle the pending click + if (this._hasPendingClick && !this._isDragging) { + this._hasPendingClick = false; + let elToClick; + + if (this._clickedElementOptions !== null) { + const { selector, index } = this._clickedElementOptions; + elToClick = this.valueSpan.querySelectorAll(selector)[index]; + + this._clickedElementOptions = null; + } + + if (!elToClick) { + elToClick = this.valueSpan; + } + elToClick.click(); + } + + // Populate the computed styles and shorthand overridden styles. + this._updateComputed(); + this._updateShorthandOverridden(); + + // Update the rule property highlight. + this.ruleView._updatePropertyHighlight(this); + + // Restore focus back to the element whose markup was recreated above. + if (focusedElSelector) { + const elementToFocus = this.doc.querySelector(focusedElSelector); + if (elementToFocus) { + elementToFocus.focus(); + } + } + }, + + _onStartEditing() { + this.element.classList.remove("ruleview-overridden"); + this.filterProperty.hidden = true; + this.enable.style.visibility = "hidden"; + this.expander.style.display = "none"; + }, + + get shouldShowComputedExpander() { + // Only show the expander to reveal computed properties if: + // - the computed properties are actually different from the current property (i.e + // these are longhands while the current property is the shorthand) + // - all of the computed properties have defined values. In case the current property + // value contains CSS variables, then the computed properties will be missing and we + // want to avoid showing them. + return ( + this.prop.computed.some(c => c.name !== this.prop.name) && + !this.prop.computed.every(c => !c.value) + ); + }, + + /** + * Update the visibility of the enable checkbox, the warning indicator, the used + * indicator and the filter property, as well as the overridden state of the property. + */ + updatePropertyState() { + if (this.prop.enabled) { + this.enable.style.removeProperty("visibility"); + } else { + this.enable.style.visibility = "visible"; + } + + this.enable.checked = this.prop.enabled; + + this.warning.title = !this.isNameValid() + ? l10n("rule.warningName.title") + : l10n("rule.warning.title"); + + this.warning.hidden = this.editing || this.isValid(); + this.filterProperty.hidden = + this.editing || + !this.isValid() || + !this.prop.overridden || + this.ruleEditor.rule.isUnmatched; + + this.expander.style.display = this.shouldShowComputedExpander + ? "inline-block" + : "none"; + + if ( + !this.editing && + (this.prop.overridden || !this.prop.enabled || !this.prop.isKnownProperty) + ) { + this.element.classList.add("ruleview-overridden"); + } else { + this.element.classList.remove("ruleview-overridden"); + } + + this.updatePropertyUsedIndicator(); + + if (inlineCompatibilityWarningEnabled) { + this.updatePropertyCompatibilityIndicator(); + } + }, + + updatePropertyUsedIndicator() { + const { used } = this.prop.isUsed(); + + if (this.editing || this.prop.overridden || !this.prop.enabled || used) { + this.element.classList.remove("unused"); + this.unusedState.hidden = true; + } else { + this.element.classList.add("unused"); + this.unusedState.hidden = false; + } + }, + + async updatePropertyCompatibilityIndicator() { + const { isCompatible } = await this.prop.isCompatible(); + + if (this.editing || isCompatible) { + this.compatibilityState.hidden = true; + } else { + this.compatibilityState.hidden = false; + } + }, + + /** + * Update the indicator for computed styles. The computed styles themselves + * are populated on demand, when they become visible. + */ + _updateComputed() { + this.computed.innerHTML = ""; + + this.expander.style.display = + !this.editing && this.shouldShowComputedExpander + ? "inline-block" + : "none"; + + this._populatedComputed = false; + if (this.expander.hasAttribute("open")) { + this._populateComputed(); + } + }, + + /** + * Populate the list of computed styles. + */ + _populateComputed() { + if (this._populatedComputed) { + return; + } + this._populatedComputed = true; + + for (const computed of this.prop.computed) { + // Don't bother to duplicate information already + // shown in the text property. + if (computed.name === this.prop.name) { + continue; + } + + // Store the computed style element for easy access when highlighting + // styles + computed.element = this._createComputedListItem( + this.computed, + computed, + "ruleview-computed" + ); + } + }, + + /** + * Update the indicator for overridden shorthand styles. The shorthand + * overridden styles themselves are populated on demand, when they + * become visible. + */ + _updateShorthandOverridden() { + this.shorthandOverridden.innerHTML = ""; + + this._populatedShorthandOverridden = false; + this._populateShorthandOverridden(); + }, + + /** + * Populate the list of overridden shorthand styles. + */ + _populateShorthandOverridden() { + if ( + this._populatedShorthandOverridden || + this.prop.overridden || + !this.shouldShowComputedExpander + ) { + return; + } + this._populatedShorthandOverridden = true; + + for (const computed of this.prop.computed) { + // Don't display duplicate information or show properties + // that are completely overridden. + if (computed.name === this.prop.name || !computed.overridden) { + continue; + } + + this._createComputedListItem( + this.shorthandOverridden, + computed, + "ruleview-overridden-item" + ); + } + }, + + /** + * Creates and populates a list item with the computed CSS property. + */ + _createComputedListItem(parentEl, computed, className) { + const li = createChild(parentEl, "li", { + class: className, + }); + + if (computed.overridden) { + li.classList.add("ruleview-overridden"); + } + + const nameContainer = createChild(li, "span", { + class: "ruleview-namecontainer", + }); + + createChild(nameContainer, "span", { + class: "ruleview-propertyname theme-fg-color3", + textContent: computed.name, + }); + appendText(nameContainer, ": "); + + const outputParser = this.ruleView._outputParser; + const frag = outputParser.parseCssProperty(computed.name, computed.value, { + colorSwatchClass: "ruleview-swatch ruleview-colorswatch", + urlClass: "theme-link", + baseURI: this.sheetHref, + fontFamilyClass: "ruleview-font-family", + }); + + // Store the computed property value that was parsed for output + computed.parsedValue = frag.textContent; + + const propertyContainer = createChild(li, "span", { + class: "ruleview-propertyvaluecontainer", + }); + + createChild(propertyContainer, "span", { + class: "ruleview-propertyvalue theme-fg-color1", + child: frag, + }); + appendText(propertyContainer, ";"); + + return li; + }, + + /** + * Handle updates to the preference which disables/enables the feature to + * edit size properties on drag. + */ + _onDraggablePreferenceChanged() { + if (this._isDraggableProperty(this.prop)) { + this._addDraggingCapability(); + } else { + this._removeDraggingCapacity(); + } + }, + + /** + * Stop clicks propogating down the tree from the enable / disable checkbox. + */ + _onEnableClicked(event) { + event.stopPropagation(); + }, + + /** + * Handles clicks on the disabled property. + */ + _onEnableChanged(event) { + this.prop.setEnabled(this.enable.checked); + event.stopPropagation(); + this.telemetry.recordEvent("edit_rule", "ruleview"); + }, + + /** + * Handles clicks on the computed property expander. If the computed list is + * open due to user expanding or style filtering, collapse the computed list + * and close the expander. Otherwise, add user-open attribute which is used to + * expand the computed list and tracks whether or not the computed list is + * expanded by manually by the user. + */ + _onExpandClicked(event) { + if ( + this.computed.hasAttribute("filter-open") || + this.computed.hasAttribute("user-open") + ) { + this.expander.removeAttribute("open"); + this.computed.removeAttribute("filter-open"); + this.computed.removeAttribute("user-open"); + this.shorthandOverridden.hidden = false; + this._populateShorthandOverridden(); + } else { + this.expander.setAttribute("open", "true"); + this.computed.setAttribute("user-open", ""); + this.shorthandOverridden.hidden = true; + this._populateComputed(); + } + + event.stopPropagation(); + }, + + /** + * Expands the computed list when a computed property is matched by the style + * filtering. The filter-open attribute is used to track whether or not the + * computed list was toggled opened by the filter. + */ + expandForFilter() { + if (!this.computed.hasAttribute("user-open")) { + this.expander.setAttribute("open", "true"); + this.computed.setAttribute("filter-open", ""); + this._populateComputed(); + } + }, + + /** + * Collapses the computed list that was expanded by style filtering. + */ + collapseForFilter() { + this.computed.removeAttribute("filter-open"); + + if (!this.computed.hasAttribute("user-open")) { + this.expander.removeAttribute("open"); + } + }, + + /** + * Called when the property name's inplace editor is closed. + * Ignores the change if the user pressed escape, otherwise + * commits it. + * + * @param {String} value + * The value contained in the editor. + * @param {Boolean} commit + * True if the change should be applied. + * @param {Number} direction + * The move focus direction number. + */ + _onNameDone(value, commit, direction) { + const isNameUnchanged = + (!commit && !this.ruleEditor.isEditing) || this.committed.name === value; + if (this.prop.value && isNameUnchanged) { + return; + } + + this.telemetry.recordEvent("edit_rule", "ruleview"); + + // Remove a property if the name is empty + if (!value.trim()) { + this.remove(direction); + return; + } + + // Remove a property if the property value is empty and the property + // value is not about to be focused + if (!this.prop.value && direction !== Services.focus.MOVEFOCUS_FORWARD) { + this.remove(direction); + return; + } + + // Adding multiple rules inside of name field overwrites the current + // property with the first, then adds any more onto the property list. + const properties = parseDeclarations(this.cssProperties.isKnown, value); + + if (properties.length) { + this.prop.setName(properties[0].name); + this.committed.name = this.prop.name; + + if (!this.prop.enabled) { + this.prop.setEnabled(true); + } + + if (properties.length > 1) { + this.prop.setValue(properties[0].value, properties[0].priority); + this.ruleEditor.addProperties(properties.slice(1), this.prop); + } + } + }, + + /** + * Remove property from style and the editors from DOM. + * Begin editing next or previous available property given the focus + * direction. + * + * @param {Number} direction + * The move focus direction number. + */ + remove(direction) { + if (this._colorSwatchSpans && this._colorSwatchSpans.length) { + for (const span of this._colorSwatchSpans) { + this.ruleView.tooltips.getTooltip("colorPicker").removeSwatch(span); + span.off("unit-change", this._onSwatchCommit); + } + } + + if (this.angleSwatchSpans && this.angleSwatchSpans.length) { + for (const span of this.angleSwatchSpans) { + span.off("unit-change", this._onSwatchCommit); + } + } + + this.ruleView.off( + "draggable-preference-updated", + this._onDraggablePreferenceChanged + ); + + this.element.remove(); + this.ruleEditor.rule.editClosestTextProperty(this.prop, direction); + this.nameSpan.textProperty = null; + this.valueSpan.textProperty = null; + this.prop.remove(); + }, + + /** + * Called when a value editor closes. If the user pressed escape, + * revert to the value this property had before editing. + * + * @param {String} value + * The value contained in the editor. + * @param {Boolean} commit + * True if the change should be applied. + * @param {Number} direction + * The move focus direction number. + */ + _onValueDone(value = "", commit, direction) { + const parsedProperties = this._getValueAndExtraProperties(value); + const val = parseSingleValue( + this.cssProperties.isKnown, + parsedProperties.firstValue + ); + const isValueUnchanged = + (!commit && !this.ruleEditor.isEditing) || + (!parsedProperties.propertiesToAdd.length && + this.committed.value === val.value && + this.committed.priority === val.priority); + + // If the value is not empty and unchanged, revert the property back to + // its original value and enabled or disabled state + if (value.trim() && isValueUnchanged) { + this.ruleEditor.rule.previewPropertyValue( + this.prop, + val.value, + val.priority + ); + this.rule.setPropertyEnabled(this.prop, this.prop.enabled); + return; + } + + // Check if unit of value changed to add dragging feature + if (this._isDraggableProperty(val)) { + this._addDraggingCapability(); + } else { + this._removeDraggingCapacity(); + } + + this.telemetry.recordEvent("edit_rule", "ruleview"); + + // First, set this property value (common case, only modified a property) + this.prop.setValue(val.value, val.priority); + + if (!this.prop.enabled) { + this.prop.setEnabled(true); + } + + this.committed.value = this.prop.value; + this.committed.priority = this.prop.priority; + + // If needed, add any new properties after this.prop. + this.ruleEditor.addProperties(parsedProperties.propertiesToAdd, this.prop); + + // If the input value is empty and the focus is moving forward to the next + // editable field, then remove the whole property. + // A timeout is used here to accurately check the state, since the inplace + // editor `done` and `destroy` events fire before the next editor + // is focused. + if (!value.trim() && direction !== Services.focus.MOVEFOCUS_BACKWARD) { + setTimeout(() => { + if (!this.editing) { + this.remove(direction); + } + }, 0); + } + }, + + /** + * Called when the swatch editor wants to commit a value change. + */ + _onSwatchCommit() { + this._onValueDone(this.valueSpan.textContent, true); + this.update(); + }, + + /** + * Called when the swatch editor wants to preview a value change. + */ + _onSwatchPreview() { + this._previewValue(this.valueSpan.textContent); + }, + + /** + * Called when the swatch editor closes from an ESC. Revert to the original + * value of this property before editing. + */ + _onSwatchRevert() { + this._previewValue(this.prop.value, true); + this.update(); + }, + + /** + * Parse a value string and break it into pieces, starting with the + * first value, and into an array of additional properties (if any). + * + * Example: Calling with "red; width: 100px" would return + * { firstValue: "red", propertiesToAdd: [{ name: "width", value: "100px" }] } + * + * @param {String} value + * The string to parse + * @return {Object} An object with the following properties: + * firstValue: A string containing a simple value, like + * "red" or "100px!important" + * propertiesToAdd: An array with additional properties, following the + * parseDeclarations format of {name,value,priority} + */ + _getValueAndExtraProperties(value) { + // The inplace editor will prevent manual typing of multiple properties, + // but we need to deal with the case during a paste event. + // Adding multiple properties inside of value editor sets value with the + // first, then adds any more onto the property list (below this property). + let firstValue = value; + let propertiesToAdd = []; + + const properties = parseDeclarations(this.cssProperties.isKnown, value); + + // Check to see if the input string can be parsed as multiple properties + if (properties.length) { + // Get the first property value (if any), and any remaining + // properties (if any) + if (!properties[0].name && properties[0].value) { + firstValue = properties[0].value; + propertiesToAdd = properties.slice(1); + } else if (properties[0].name && properties[0].value) { + // In some cases, the value could be a property:value pair + // itself. Join them as one value string and append + // potentially following properties + firstValue = properties[0].name + ": " + properties[0].value; + propertiesToAdd = properties.slice(1); + } + } + + return { + propertiesToAdd, + firstValue, + }; + }, + + /** + * Live preview this property, without committing changes. + * + * @param {String} value + * The value to set the current property to. + * @param {Boolean} reverting + * True if we're reverting the previously previewed value + */ + _previewValue(value, reverting = false) { + // Since function call is debounced, we need to make sure we are still + // editing, and any selector modifications have been completed + if (!reverting && (!this.editing || this.ruleEditor.isEditing)) { + return; + } + + const val = parseSingleValue(this.cssProperties.isKnown, value); + this.ruleEditor.rule.previewPropertyValue( + this.prop, + val.value, + val.priority + ); + }, + + /** + * Check if the event passed has a "small increment" modifier + * Alt on macosx and ctrl on other OSs + * + * @param {KeyboardEvent} event + * @returns {Boolean} + */ + _hasSmallIncrementModifier(event) { + const modifier = + lazy.AppConstants.platform === "macosx" ? "altKey" : "ctrlKey"; + return event[modifier] === true; + }, + + /** + * Parses the value to check if it is a dimension + * e.g. if the input is "128px" it will return an object like + * { groups: { value: "128", unit: "px"}} + * + * @param {String} value + * @returns {Object|null} + */ + _parseDimension(value) { + // The regex handles values like +1, -1, 1e4, .4, 1.3e-4, 1.567 + const cssDimensionRegex = + /^(?<value>[+-]?(\d*\.)?\d+(e[+-]?\d+)?)(?<unit>(%|[a-zA-Z]+))$/; + return value.match(cssDimensionRegex); + }, + + /** + * Check if a textProperty value is supported to add the dragging feature + * + * @param {TextProperty} textProperty + * @returns {Boolean} + */ + _isDraggableProperty(textProperty) { + // Check if the feature is explicitly disabled. + if (!this.ruleView.draggablePropertiesEnabled) { + return false; + } + // temporary way of fixing the bug when editing inline styles + // otherwise the textPropertyEditor object is destroyed on each value edit + // See Bug 1755024 + if (this.rule.domRule.type == ELEMENT_STYLE) { + return false; + } + + const nbValues = textProperty.value.split(" ").length; + if (nbValues > 1) { + // we do not support values like "1px solid red" yet + // See 1755025 + return false; + } + + const dimensionMatchObj = this._parseDimension(textProperty.value); + return !!dimensionMatchObj; + }, + + _draggingOnMouseDown(event) { + this._isDragging = true; + this.valueSpan.setPointerCapture(event.pointerId); + this._draggingController = new AbortController(); + const { signal } = this._draggingController; + + // turn off user-select in CSS when we drag + this.valueSpan.classList.add(IS_DRAGGING_CLASSNAME); + + const dimensionObj = this._parseDimension(this.prop.value); + const { value, unit } = dimensionObj.groups; + this._draggingValueCache = { + isInDeadzone: true, + previousScreenX: event.screenX, + value: parseFloat(value), + unit, + }; + + this.valueSpan.addEventListener("mousemove", this._draggingOnMouseMove, { + signal, + }); + this.valueSpan.addEventListener("mouseup", this._draggingOnMouseUp, { + signal, + }); + this.valueSpan.addEventListener("keydown", this._draggingOnKeydown, { + signal, + }); + }, + + _draggingOnMouseMove(event) { + if (!this._isDragging) { + return; + } + + const { isInDeadzone, previousScreenX } = this._draggingValueCache; + let deltaX = event.screenX - previousScreenX; + + // If `isInDeadzone` is still true, the user has not previously left the deadzone. + if (isInDeadzone) { + // If the mouse is still in the deadzone, bail out immediately. + if (Math.abs(deltaX) < DRAGGING_DEADZONE_DISTANCE) { + return; + } + + // Otherwise, remove the DRAGGING_DEADZONE_DISTANCE from the current deltaX, so that + // the value does not update too abruptly. + deltaX = + Math.sign(deltaX) * (Math.abs(deltaX) - DRAGGING_DEADZONE_DISTANCE); + + // Update the state to remember the user is out of the deadzone. + this._draggingValueCache.isInDeadzone = false; + } + + let draggingSpeed = DEFAULT_DRAGGING_SPEED; + if (event.shiftKey) { + draggingSpeed = FAST_DRAGGING_SPEED; + } else if (this._hasSmallIncrementModifier(event)) { + draggingSpeed = SLOW_DRAGGING_SPEED; + } + + const delta = deltaX * draggingSpeed; + this._draggingValueCache.previousScreenX = event.screenX; + this._draggingValueCache.value += delta; + + if (delta == 0) { + return; + } + + const { value, unit } = this._draggingValueCache; + // We use toFixed to avoid the case where value is too long, 9.00001px for example + const roundedValue = Number.isInteger(value) ? value : value.toFixed(1); + this.prop.setValue(roundedValue + unit, this.prop.priority); + this.ruleView.emitForTests("property-updated-by-dragging"); + this._hasDragged = true; + }, + + _draggingOnMouseUp(event) { + if (!this._isDragging) { + return; + } + if (this._hasDragged) { + this.committed.value = this.prop.value; + this.prop.setEnabled(true); + } + this._onStopDragging(event); + }, + + _draggingOnKeydown(event) { + if (event.key == "Escape") { + this.prop.setValue(this.committed.value, this.committed.priority); + this._onStopDragging(event); + event.preventDefault(); + } + }, + + _onStopDragging(event) { + // childHasDragged is used to stop the propagation of a click event when we + // release the mouse in the ruleview. + // The click event is not emitted when we have a pending click on the text property. + if (this._hasDragged && !this._hasPendingClick) { + this.ruleView.childHasDragged = true; + } + this._isDragging = false; + this._hasDragged = false; + this._draggingValueCache = null; + this.valueSpan.releasePointerCapture(event.pointerId); + this.valueSpan.classList.remove(IS_DRAGGING_CLASSNAME); + this._draggingController.abort(); + }, + + /** + * add event listeners to add the ability to modify any size value + * by dragging the mouse horizontally + */ + _addDraggingCapability() { + if (this.valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME)) { + return; + } + this.valueSpan.classList.add(DRAGGABLE_VALUE_CLASSNAME); + this.valueSpan.addEventListener("mousedown", this._draggingOnMouseDown); + }, + + _removeDraggingCapacity() { + if (!this.valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME)) { + return; + } + this._draggingController = null; + this.valueSpan.classList.remove(DRAGGABLE_VALUE_CLASSNAME); + this.valueSpan.removeEventListener("mousedown", this._draggingOnMouseDown); + }, + + /** + * Validate this property. Does it make sense for this value to be assigned + * to this property name? This does not apply the property value + * + * @return {Boolean} true if the property name + value pair is valid, false otherwise. + */ + isValid() { + return this.prop.isValid(); + }, + + /** + * Validate the name of this property. + * @return {Boolean} true if the property name is valid, false otherwise. + */ + isNameValid() { + return this.prop.isNameValid(); + }, + + /** + * Display grid-template-area value strings each on their own line + * to display it in an ascii-art style matrix + */ + _formatGridTemplateAreasValue() { + this.valueSpan.classList.add("ruleview-propertyvalue-break-spaces"); + + let quoteSymbolsUsed = []; + + const getQuoteSymbolsUsed = cssValue => { + const regex = /\"|\'/g; + const found = cssValue.match(regex); + quoteSymbolsUsed = found.filter((_, i) => i % 2 === 0); + }; + + getQuoteSymbolsUsed(this.valueSpan.innerText); + + this.valueSpan.innerText = this.valueSpan.innerText + .split('"') + .filter(s => s !== "") + .map(s => s.split("'")) + .flat() + .map(s => s.trim().replace(/\s+/g, " ")) + .filter(s => s.length) + .map(line => line.split(" ")) + .map((line, i, lines) => + line.map((col, j) => + col.padEnd(Math.max(...lines.map(l => l[j].length)), " ") + ) + ) + .map( + (line, i) => + `\n${quoteSymbolsUsed[i]}` + line.join(" ") + quoteSymbolsUsed[i] + ) + .join(" "); + }, +}; + +module.exports = TextPropertyEditor; |