[go: nahoru, domu]

Added validation of CSS rule and info button with basic hint

This CL adds mechanism that validates whether CSS rule has effect on UI or not and shows hint message when invalid.

Screenshot:
https://imgur.com/a/WCJyC8U

Bug: 1178508
Change-Id: I4d6308e6c8d061153ac8e9b88fdfaa5cf47fff4b
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3698093
Reviewed-by: Alex Rudenko <alexrudenko@chromium.org>
Commit-Queue: Saba Khukhunashvili <khukhunashvili@google.com>
Reviewed-by: Changhao Han <changhaohan@chromium.org>
diff --git a/config/gni/devtools_grd_files.gni b/config/gni/devtools_grd_files.gni
index 2885b41..78796f8 100644
--- a/config/gni/devtools_grd_files.gni
+++ b/config/gni/devtools_grd_files.gni
@@ -933,6 +933,7 @@
   "front_end/panels/developer_resources/developerResourcesView.css.js",
   "front_end/panels/elements/AccessibilityTreeUtils.js",
   "front_end/panels/elements/AccessibilityTreeView.js",
+  "front_end/panels/elements/CSSRuleValidator.js",
   "front_end/panels/elements/ClassesPaneWidget.js",
   "front_end/panels/elements/ColorSwatchPopoverIcon.js",
   "front_end/panels/elements/ComputedStyleModel.js",
diff --git a/front_end/core/i18n/locales/en-US.json b/front_end/core/i18n/locales/en-US.json
index ab7660c..f95b93f 100644
--- a/front_end/core/i18n/locales/en-US.json
+++ b/front_end/core/i18n/locales/en-US.json
@@ -4688,6 +4688,12 @@
   "panels/elements/ComputedStyleWidget.ts | showAll": {
     "message": "Show all"
   },
+  "panels/elements/CSSRuleValidator.ts | alignContentRuleOnNoWrapFlex": {
+    "message": "This element has flex-wrap: nowrap rule, therefore 'align-content' has no effect."
+  },
+  "panels/elements/CSSRuleValidator.ts | notFlexItemHint": {
+    "message": "Parent of this element is not flex container, therefore {PH1} property has no effect."
+  },
   "panels/elements/DOMLinkifier.ts | node": {
     "message": "<node>"
   },
diff --git a/front_end/core/i18n/locales/en-XL.json b/front_end/core/i18n/locales/en-XL.json
index 2d7ceac..961e047 100644
--- a/front_end/core/i18n/locales/en-XL.json
+++ b/front_end/core/i18n/locales/en-XL.json
@@ -4688,6 +4688,12 @@
   "panels/elements/ComputedStyleWidget.ts | showAll": {
     "message": "Ŝh́ôẃ âĺl̂"
   },
+  "panels/elements/CSSRuleValidator.ts | alignContentRuleOnNoWrapFlex": {
+    "message": "T̂h́îś êĺêḿêńt̂ h́âś f̂ĺêx́-ŵŕâṕ: n̂óŵŕâṕ r̂úl̂é, t̂h́êŕêf́ôŕê 'ál̂íĝń-ĉón̂t́êńt̂' h́âś n̂ó êf́f̂éĉt́."
+  },
+  "panels/elements/CSSRuleValidator.ts | notFlexItemHint": {
+    "message": "P̂ár̂én̂t́ ôf́ t̂h́îś êĺêḿêńt̂ íŝ ńôt́ f̂ĺêx́ ĉón̂t́âín̂ér̂, t́ĥér̂éf̂ór̂é {PH1} p̂ŕôṕêŕt̂ý ĥáŝ ńô éf̂f́êćt̂."
+  },
   "panels/elements/DOMLinkifier.ts | node": {
     "message": "<n̂ód̂é>"
   },
diff --git a/front_end/panels/elements/BUILD.gn b/front_end/panels/elements/BUILD.gn
index d8251a7..df1d19b 100644
--- a/front_end/panels/elements/BUILD.gn
+++ b/front_end/panels/elements/BUILD.gn
@@ -31,6 +31,7 @@
   sources = [
     "AccessibilityTreeUtils.ts",
     "AccessibilityTreeView.ts",
+    "CSSRuleValidator.ts",
     "ClassesPaneWidget.ts",
     "ColorSwatchPopoverIcon.ts",
     "ComputedStyleModel.ts",
diff --git a/front_end/panels/elements/CSSRuleValidator.ts b/front_end/panels/elements/CSSRuleValidator.ts
new file mode 100644
index 0000000..29a9eb5
--- /dev/null
+++ b/front_end/panels/elements/CSSRuleValidator.ts
@@ -0,0 +1,100 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as i18n from '../../core/i18n/i18n.js';
+
+const UIStrings = {
+  /**
+    *@description Hint for Align-content rule where element also has flex-wrap nowrap rule.
+    */
+  alignContentRuleOnNoWrapFlex: 'This element has flex-wrap: nowrap rule, therefore \'align-content\' has no effect.',
+  /**
+    *@description Hint for element that does not have effect if parent container is not flex.
+    *@example {flex} PH1
+    */
+  notFlexItemHint: 'Parent of this element is not flex container, therefore {PH1} property has no effect.',
+};
+const str_ = i18n.i18n.registerUIStrings('panels/elements/CSSRuleValidator.ts', UIStrings);
+const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
+
+export abstract class CSSRuleValidator {
+  readonly #affectedProperties: string[];
+
+  constructor(affectedProperties: string[]) {
+    this.#affectedProperties = affectedProperties;
+  }
+
+  abstract isRuleValid(computedStyles: Map<String, String>|null, parentsComputedStyles?: Map<String, String>|null):
+      boolean;
+
+  getAffectedProperties(): string[] {
+    return this.#affectedProperties;
+  }
+
+  abstract getHintMessage(propertyName: string): string;
+}
+
+export class AlignContentValidator extends CSSRuleValidator {
+  constructor() {
+    super(['align-content']);
+  }
+
+  isRuleValid(computedStyles: Map<String, String>|null): boolean {
+    if (computedStyles === null || computedStyles === undefined) {
+      return true;
+    }
+    const display = computedStyles.get('display');
+    if (display !== 'flex' && display !== 'inline-flex') {
+      return true;
+    }
+    return computedStyles.get('flex-wrap') !== 'nowrap';
+  }
+
+  getHintMessage(): string {
+    return i18nString(UIStrings.alignContentRuleOnNoWrapFlex);
+  }
+}
+
+export class FlexItemValidator extends CSSRuleValidator {
+  constructor() {
+    super(['flex', 'flex-basis', 'flex-grow', 'flex-shrink']);
+  }
+
+  isRuleValid(computedStyles: Map<String, String>|null, parentsComputedStyles: Map<String, String>|null): boolean {
+    if (computedStyles === null || computedStyles === undefined || parentsComputedStyles === null ||
+        parentsComputedStyles === undefined) {
+      return true;
+    }
+    const parentDisplay = parentsComputedStyles.get('display');
+    return parentDisplay === 'flex' || parentDisplay === 'inline-flex';
+  }
+
+  getHintMessage(property: string): string {
+    return i18nString(UIStrings.notFlexItemHint, {
+      'PH1': property,
+    });
+  }
+}
+
+const setupCSSRulesValidators = (): Map<String, CSSRuleValidator[]> => {
+  const validators = [new AlignContentValidator(), new FlexItemValidator()];
+
+  const validatorsMap = new Map<String, CSSRuleValidator[]>();
+  for (const validator of validators) {
+    const affectedProperties = validator.getAffectedProperties();
+
+    for (const affectedProperty of affectedProperties) {
+      let propertyValidators = validatorsMap.get(affectedProperty);
+      if (propertyValidators === undefined) {
+        propertyValidators = [];
+      }
+      propertyValidators.push(validator);
+
+      validatorsMap.set(affectedProperty, propertyValidators);
+    }
+  }
+  return validatorsMap;
+};
+
+export const cssRuleValidatorsMap: Map<String, CSSRuleValidator[]> = setupCSSRulesValidators();
diff --git a/front_end/panels/elements/StylePropertiesSection.ts b/front_end/panels/elements/StylePropertiesSection.ts
index 39ad0e6..a52e2db 100644
--- a/front_end/panels/elements/StylePropertiesSection.ts
+++ b/front_end/panels/elements/StylePropertiesSection.ts
@@ -121,6 +121,8 @@
   protected parentPane: StylesSidebarPane;
   styleInternal: SDK.CSSStyleDeclaration.CSSStyleDeclaration;
   readonly matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles;
+  private computedStyles: Map<string, string>|null;
+  private parentsComputedStyles: Map<string, string>|null;
   editable: boolean;
   private hoverTimer: number|null;
   private willCauseCancelEditing: boolean;
@@ -152,11 +154,14 @@
 
   constructor(
       parentPane: StylesSidebarPane, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
-      style: SDK.CSSStyleDeclaration.CSSStyleDeclaration, sectionIdx: number) {
+      style: SDK.CSSStyleDeclaration.CSSStyleDeclaration, sectionIdx: number, computedStyles: Map<string, string>|null,
+      parentsComputedStyles: Map<string, string>|null) {
     this.parentPane = parentPane;
     this.sectionIdx = sectionIdx;
     this.styleInternal = style;
     this.matchedStyles = matchedStyles;
+    this.computedStyles = computedStyles;
+    this.parentsComputedStyles = parentsComputedStyles;
     this.editable = Boolean(style.styleSheetId && style.range);
     this.hoverTimer = null;
     this.willCauseCancelEditing = false;
@@ -932,6 +937,8 @@
       }
       const item = new StylePropertyTreeElement(
           this.parentPane, this.matchedStyles, property, isShorthand, inherited, overloaded, false);
+      item.setComputedStyles(this.computedStyles);
+      item.setParentsComputedStyles(this.parentsComputedStyles);
       this.propertiesTreeOutline.appendChild(item);
     }
 
@@ -1449,7 +1456,7 @@
       insertAfterStyle: SDK.CSSStyleDeclaration.CSSStyleDeclaration, sectionIdx: number) {
     const cssModel = (stylesPane.cssModel() as SDK.CSSModel.CSSModel);
     const rule = SDK.CSSRule.CSSStyleRule.createDummyRule(cssModel, defaultSelectorText);
-    super(stylesPane, matchedStyles, rule.style, sectionIdx);
+    super(stylesPane, matchedStyles, rule.style, sectionIdx, null, null);
     this.normal = false;
     this.ruleLocation = ruleLocation;
     this.styleSheetId = styleSheetId;
@@ -1553,7 +1560,7 @@
   constructor(
       stylesPane: StylesSidebarPane, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
       style: SDK.CSSStyleDeclaration.CSSStyleDeclaration, sectionIdx: number) {
-    super(stylesPane, matchedStyles, style, sectionIdx);
+    super(stylesPane, matchedStyles, style, sectionIdx, null, null);
     this.selectorElement.className = 'keyframe-key';
   }
 
diff --git a/front_end/panels/elements/StylePropertyTreeElement.ts b/front_end/panels/elements/StylePropertyTreeElement.ts
index 29f8e57..0f0cc70 100644
--- a/front_end/panels/elements/StylePropertyTreeElement.ts
+++ b/front_end/panels/elements/StylePropertyTreeElement.ts
@@ -21,6 +21,7 @@
 import type {StylePropertiesSection} from './StylePropertiesSection.js';
 import {CSSPropertyPrompt, StylesSidebarPane, StylesSidebarPropertyRenderer} from './StylesSidebarPane.js';
 import {getCssDeclarationAsJavascriptProperty} from './StylePropertyUtils.js';
+import {cssRuleValidatorsMap} from './CSSRuleValidator.js';
 
 const FlexboxEditor = ElementsComponents.StylePropertyEditor.FlexboxEditor;
 const GridEditor = ElementsComponents.StylePropertyEditor.GridEditor;
@@ -120,6 +121,8 @@
   private hasBeenEditedIncrementally: boolean;
   private prompt: CSSPropertyPrompt|null;
   private lastComputedValue: string|null;
+  private computedStyles: Map<string, string>|null = null;
+  private parentsComputedStyles: Map<string, string>|null = null;
   private contextForTest!: Context|undefined;
   #propertyTextFromSource: string;
 
@@ -179,6 +182,14 @@
     this.updateState();
   }
 
+  setComputedStyles(computedStyles: Map<string, string>|null): void {
+    this.computedStyles = computedStyles;
+  }
+
+  setParentsComputedStyles(parentsComputedStyles: Map<string, string>|null): void {
+    this.parentsComputedStyles = parentsComputedStyles;
+  }
+
   get name(): string {
     return this.property.name;
   }
@@ -568,6 +579,8 @@
       const item = new StylePropertyTreeElement(
           this.parentPaneInternal, this.matchedStylesInternal, longhandProperties[i], false, inherited, overloaded,
           false);
+      item.setComputedStyles(this.computedStyles);
+      item.setParentsComputedStyles(this.parentsComputedStyles);
       this.appendChild(item);
     }
   }
@@ -702,6 +715,17 @@
       }
     }
 
+    const hintMessage = this.getHintMessage(this.computedStyles, this.parentsComputedStyles);
+    if (hintMessage !== null) {
+      const hintIcon = UI.Icon.Icon.create('mediumicon-info', 'hint');
+      const hintPopover =
+          new UI.PopoverHelper.PopoverHelper(hintIcon, event => this.handleHintPopoverRequest(hintMessage, event));
+      hintPopover.setHasPadding(true);
+      hintPopover.setTimeout(0, 100);
+
+      this.listItemElement.append(hintIcon);
+    }
+
     if (!this.property.parsedOk) {
       // Avoid having longhands under an invalid shorthand.
       this.listItemElement.classList.add('not-parsed-ok');
@@ -793,6 +817,41 @@
         StylesSidebarPane.createExclamationMark(this.property, warnings.join(' ')), this.listItemElement.firstChild);
   }
 
+  private getHintMessage(computedStyles: Map<string, string>|null, parentComputedStyles: Map<string, string>|null):
+      string|null {
+    const propertyName = this.property.name;
+
+    if (!Root.Runtime.experiments.isEnabled(Root.Runtime.ExperimentName.CSS_AUTHORING_HINTS) ||
+        !cssRuleValidatorsMap.has(propertyName)) {
+      return null;
+    }
+
+    for (const validator of cssRuleValidatorsMap.get(propertyName) || []) {
+      if (!validator.isRuleValid(computedStyles, parentComputedStyles)) {
+        return validator.getHintMessage(propertyName);
+      }
+    }
+
+    return null;
+  }
+
+  private handleHintPopoverRequest(hintMessageContent: string, event: Event): UI.PopoverHelper.PopoverRequest|null {
+    const link = event.composedPath()[0];
+    Platform.DCHECK(() => link instanceof Element, 'Link is not an instance of Element');
+
+    return {
+      box: (link as Element).boxInWindow(),
+      show: async(popover: UI.GlassPane.GlassPane): Promise<boolean> => {
+        const node = this.node();
+        if (!node) {
+          return false;
+        }
+        popover.contentElement.insertAdjacentHTML('beforeend', hintMessageContent);
+        return true;
+      },
+    };
+  }
+
   private mouseUp(event: MouseEvent): void {
     const activeTreeElement = parentMap.get(this.parentPaneInternal);
     parentMap.delete(this.parentPaneInternal);
diff --git a/front_end/panels/elements/StylesSidebarPane.ts b/front_end/panels/elements/StylesSidebarPane.ts
index 59c6219..4e92f9a 100644
--- a/front_end/panels/elements/StylesSidebarPane.ts
+++ b/front_end/panels/elements/StylesSidebarPane.ts
@@ -583,8 +583,16 @@
       }, 200 /* only spin for loading time > 200ms to avoid unpleasant render flashes */);
     }
 
+    const node = this.node();
+    // TODO: Fetch the parent node id using CDP command
+    const parentNode = node ? node.parentNode : null;
+
+    const [computedStyles, parentsComputedStyles] =
+        await Promise.all([this.fetchComputedStylesFor(node), this.fetchComputedStylesFor(parentNode)]);
+
     const matchedStyles = await this.fetchMatchedCascade();
-    await this.innerRebuildUpdate(matchedStyles);
+
+    await this.innerRebuildUpdate(matchedStyles, computedStyles, parentsComputedStyles);
     if (!this.initialUpdateCompleted) {
       this.initialUpdateCompleted = true;
       this.appendToolbarItem(this.createRenderingShortcuts());
@@ -599,6 +607,13 @@
     this.dispatchEventToListeners(Events.StylesUpdateCompleted, {hasMatchedStyles: this.hasMatchedStyles});
   }
 
+  private async fetchComputedStylesFor(node: SDK.DOMModel.DOMNode|null): Promise<Map<string, string>|null> {
+    if (!node) {
+      return null;
+    }
+    return await node.domModel().cssModel().getComputedStyle(node.id);
+  }
+
   onResize(): void {
     void this.resizeThrottler.schedule(this.innerResize.bind(this));
   }
@@ -718,7 +733,9 @@
     }
   }
 
-  private async innerRebuildUpdate(matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles|null): Promise<void> {
+  private async innerRebuildUpdate(
+      matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles|null, computedStyles: Map<string, string>|null,
+      parentsComputedStyles: Map<string, string>|null): Promise<void> {
     // ElementsSidebarPane's throttler schedules this method. Usually,
     // rebuild is suppressed while editing (see onCSSModelChanged()), but we need a
     // 'force' flag since the currently running throttler process cannot be canceled.
@@ -742,8 +759,8 @@
       return;
     }
 
-    this.sectionBlocks =
-        await this.rebuildSectionsForMatchedStyleRules((matchedStyles as SDK.CSSMatchedStyles.CSSMatchedStyles));
+    this.sectionBlocks = await this.rebuildSectionsForMatchedStyleRules(
+        (matchedStyles as SDK.CSSMatchedStyles.CSSMatchedStyles), computedStyles, parentsComputedStyles);
 
     // Style sections maybe re-created when flexbox editor is activated.
     // With the following code we re-bind the flexbox editor to the new
@@ -814,8 +831,9 @@
     // For sniffing in tests.
   }
 
-  private async rebuildSectionsForMatchedStyleRules(matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles):
-      Promise<SectionBlock[]> {
+  private async rebuildSectionsForMatchedStyleRules(
+      matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, computedStyles: Map<string, string>|null,
+      parentsComputedStyles: Map<string, string>|null): Promise<SectionBlock[]> {
     if (this.idleCallbackManager) {
       this.idleCallbackManager.discard();
     }
@@ -872,7 +890,8 @@
       const lastBlock = blocks[blocks.length - 1];
       if (lastBlock) {
         this.idleCallbackManager.schedule(() => {
-          const section = new StylePropertiesSection(this, matchedStyles, style, sectionIdx);
+          const section =
+              new StylePropertiesSection(this, matchedStyles, style, sectionIdx, computedStyles, parentsComputedStyles);
           sectionIdx++;
           lastBlock.sections.push(section);
         });
@@ -941,7 +960,8 @@
         addLayerSeparator(style);
         const lastBlock = blocks[blocks.length - 1];
         this.idleCallbackManager.schedule(() => {
-          const section = new HighlightPseudoStylePropertiesSection(this, matchedStyles, style, sectionIdx);
+          const section = new HighlightPseudoStylePropertiesSection(
+              this, matchedStyles, style, sectionIdx, computedStyles, parentsComputedStyles);
           sectionIdx++;
           lastBlock.sections.push(section);
         });
diff --git a/front_end/panels/elements/elements.ts b/front_end/panels/elements/elements.ts
index 9b22a88..2841a15 100644
--- a/front_end/panels/elements/elements.ts
+++ b/front_end/panels/elements/elements.ts
@@ -22,6 +22,7 @@
 import './StylesSidebarPane.js';
 import './StylePropertyTreeElement.js';
 import './ComputedStyleWidget.js';
+import './CSSRuleValidator.js';
 import './ElementsPanel.js';
 import './ClassesPaneWidget.js';
 import './ElementStatePaneWidget.js';
@@ -32,6 +33,7 @@
 import * as ColorSwatchPopoverIcon from './ColorSwatchPopoverIcon.js';
 import * as ComputedStyleModel from './ComputedStyleModel.js';
 import * as ComputedStyleWidget from './ComputedStyleWidget.js';
+import * as CSSRuleValidator from './CSSRuleValidator.js';
 import * as DOMLinkifier from './DOMLinkifier.js';
 import * as DOMPath from './DOMPath.js';
 import * as ElementsPanel from './ElementsPanel.js';
@@ -62,6 +64,7 @@
   ColorSwatchPopoverIcon,
   ComputedStyleModel,
   ComputedStyleWidget,
+  CSSRuleValidator,
   DOMLinkifier,
   DOMPath,
   ElementsPanel,
diff --git a/front_end/panels/elements/stylesSectionTree.css b/front_end/panels/elements/stylesSectionTree.css
index 9d2ec74..de99c59 100644
--- a/front_end/panels/elements/stylesSectionTree.css
+++ b/front_end/panels/elements/stylesSectionTree.css
@@ -155,6 +155,16 @@
   transform: scale(0.9);
 }
 
+.hint {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  margin: auto;
+  display: inline-block;
+  cursor: pointer;
+  transform: scale(0.9);
+}
+
 .has-ignorable-error {
   color: var(--color-text-disabled);
 }
diff --git a/test/e2e/elements/style-pane-properties_test.ts b/test/e2e/elements/style-pane-properties_test.ts
index 742f5ad..e95a3a9 100644
--- a/test/e2e/elements/style-pane-properties_test.ts
+++ b/test/e2e/elements/style-pane-properties_test.ts
@@ -13,6 +13,7 @@
   goToResource,
   waitFor,
   waitForFunction,
+  enableExperiment,
 } from '../../shared/helper.js';
 import {describe, it} from '../../shared/mocha-extensions.js';
 import {
@@ -30,6 +31,7 @@
   waitForStyleRule,
   expandSelectedNodeRecursively,
   waitForAndClickTreeElementWithPartialText,
+  getPropertiesWithHints,
 } from '../helpers/elements-helpers.js';
 
 const PROPERTIES_TO_DELETE_SELECTOR = '#properties-to-delete';
@@ -1039,4 +1041,16 @@
     ];
     assert.deepEqual(inspectedRules, expectedInspectedRules);
   });
+
+  it('can detect inactive CSS', async () => {
+    await enableExperiment('cssAuthoringHints');
+
+    await goToResourceAndWaitForStyleSection('elements/inactive-css-page.html');
+    await waitForStyleRule('body');
+    await waitForAndClickTreeElementWithPartialText('wrapper');
+    await waitForStyleRule('#wrapper');
+
+    const propertiesWithHints = await getPropertiesWithHints();
+    assert.deepEqual(propertiesWithHints, ['align-content']);
+  });
 });
diff --git a/test/e2e/helpers/elements-helpers.ts b/test/e2e/helpers/elements-helpers.ts
index 664520a..0e75ccb 100644
--- a/test/e2e/helpers/elements-helpers.ts
+++ b/test/e2e/helpers/elements-helpers.ts
@@ -43,6 +43,7 @@
 const ELEMENT_CHECKBOX_IN_LAYOUT_PANE_SELECTOR = '.elements input[type=checkbox]';
 const ELEMENT_STYLE_SECTION_SELECTOR = '[aria-label="element.style, css selector"]';
 const STYLE_QUERY_RULE_TEXT_SELECTOR = '.query-text';
+const CSS_AUTHORING_HINTS_ICON_SELECTOR = '.hint';
 
 export const openLayoutPane = async () => {
   await step('Open Layout pane', async () => {
@@ -738,3 +739,27 @@
   const treeToggleButton = await waitForAria('Switch to Accessibility Tree view');
   await click(treeToggleButton);
 };
+
+export const getPropertiesWithHints = async () => {
+  const allRuleSelectors = await $$(CSS_STYLE_RULE_SELECTOR);
+
+  const propertiesWithHints = [];
+  for (const propertiesSection of allRuleSelectors) {
+    const cssRuleNodes = await $$('li ', propertiesSection);
+
+    for (const cssRuleNode of cssRuleNodes) {
+      const propertyNode = await $(CSS_PROPERTY_NAME_SELECTOR, cssRuleNode);
+      const propertyName = propertyNode !== null ? await propertyNode.evaluate(n => n.textContent) : null;
+      if (propertyName === null) {
+        continue;
+      }
+
+      const authoringHintsIcon = await $(CSS_AUTHORING_HINTS_ICON_SELECTOR, cssRuleNode);
+      if (authoringHintsIcon) {
+        propertiesWithHints.push(propertyName);
+      }
+    }
+  }
+
+  return propertiesWithHints;
+};
diff --git a/test/e2e/resources/elements/BUILD.gn b/test/e2e/resources/elements/BUILD.gn
index 7beff27..d438a5b 100644
--- a/test/e2e/resources/elements/BUILD.gn
+++ b/test/e2e/resources/elements/BUILD.gn
@@ -37,6 +37,7 @@
     "grid-editor.html",
     "highlight-pseudo-inheritance.html",
     "hover.html",
+    "inactive-css-page.html",
     "limited-quirks-mode.html",
     "low-contrast.html",
     "multiple-constructed-stylesheets.html",
diff --git a/test/e2e/resources/elements/inactive-css-page.html b/test/e2e/resources/elements/inactive-css-page.html
new file mode 100644
index 0000000..d97472b
--- /dev/null
+++ b/test/e2e/resources/elements/inactive-css-page.html
@@ -0,0 +1,36 @@
+<!--
+  Copyright 2022 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<style>
+  #wrapper {
+    width: 100px;
+    height: 240px;
+    border: 1px solid black;
+    display: flex;
+    flex-wrap: nowrap;
+    align-content: center;
+    gap: 15px;
+  }
+
+  #wrapper > div {
+    width: 100px;
+    height: 75px;
+    background-color: #4285F4;
+  }
+
+  code {
+      color: red;
+  }
+</style>
+
+<h1>Inactive CSS</h1>
+
+<p><code>wrapper</code> class here is using <code>flex-wrap</code>: nowrap rule, therefore <code>'align-content'</code> has no effect. <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/align-content">Learn More</a> </p>
+
+<div id="wrapper">
+  <div></div>
+  <div></div>
+  <div></div>
+</div>
\ No newline at end of file
diff --git a/test/unittests/front_end/panels/elements/BUILD.gn b/test/unittests/front_end/panels/elements/BUILD.gn
index 2f37e878..c0706c1 100644
--- a/test/unittests/front_end/panels/elements/BUILD.gn
+++ b/test/unittests/front_end/panels/elements/BUILD.gn
@@ -7,6 +7,7 @@
 ts_library("elements") {
   testonly = true
   sources = [
+    "CSSRuleValidator_test.ts",
     "ElementsPanel_test.ts",
     "StylePropertyTreeElement_test.ts",
     "StylePropertyUtils_test.ts",
diff --git a/test/unittests/front_end/panels/elements/CSSRuleValidator_test.ts b/test/unittests/front_end/panels/elements/CSSRuleValidator_test.ts
new file mode 100644
index 0000000..147f171
--- /dev/null
+++ b/test/unittests/front_end/panels/elements/CSSRuleValidator_test.ts
@@ -0,0 +1,70 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import type * as ElementsModule from '../../../../../front_end/panels/elements/elements.js';
+import {describeWithEnvironment} from '../../helpers/EnvironmentHelpers.js';
+
+const {assert} = chai;
+
+describeWithEnvironment('CSSRuleValidator', async () => {
+  let Elements: typeof ElementsModule;
+  const tests = [
+    {
+      description:
+          'Reports a rule violation when element align-content is set on flex container whose flex-wrap property\'s value is nowrap',
+      computedStyles: new Map<string, string>([
+        ['display', 'inline-flex'],
+        ['flex-wrap', 'nowrap'],
+        ['align-content', 'center'],
+      ]),
+      parentsComputedStyles: null,
+      validator: () => new Elements.CSSRuleValidator.AlignContentValidator(),
+      expectedResult: false,
+    },
+    {
+      description: 'Passes the validation if flex-wrap is set to nowrap, but the element is not a flex container',
+      computedStyles: new Map<string, string>([
+        ['display', 'block'],
+        ['flex-wrap', 'nowrap'],
+        ['align-content', 'center'],
+      ]),
+      parentsComputedStyles: null,
+      validator: () => new Elements.CSSRuleValidator.AlignContentValidator(),
+      expectedResult: true,
+    },
+    {
+      description: 'Reports a rule validation when flex properties are set to non-flex items',
+      computedStyles: new Map<string, string>([
+        ['flex', '1'],
+      ]),
+      parentsComputedStyles: new Map<string, string>([
+        ['display', 'table'],
+      ]),
+      validator: () => new Elements.CSSRuleValidator.FlexItemValidator(),
+      expectedResult: false,
+    },
+    {
+      description: 'Passes the vlaidation when flex properties are set to flex items',
+      computedStyles: new Map<string, string>([
+        ['flex', '1'],
+      ]),
+      parentsComputedStyles: new Map<string, string>([
+        ['display', 'flex'],
+      ]),
+      validator: () => new Elements.CSSRuleValidator.FlexItemValidator(),
+      expectedResult: true,
+    },
+  ];
+
+  before(async () => {
+    Elements = await import('../../../../../front_end/panels/elements/elements.js');
+  });
+
+  for (const test of tests) {
+    it(test.description, () => {
+      const actualResult = test.validator().isRuleValid(test.computedStyles, test.parentsComputedStyles);
+      assert.deepEqual(actualResult, test.expectedResult);
+    });
+  }
+});