Repository: GoogleChrome/lighthouse
Pull request #12634 (2021-06-08 22:15:15)

core(tap-targets): add stylesheet over protocol
Fixes https://github.com/GoogleChrome/lighthouse/issues/11862

Adding the rule using the protocol will create a stylesheet that is not blocked by the CSP.



Patch:
diff --git a/lighthouse-core/gather/gatherers/seo/tap-targets.js b/lighthouse-core/gather/gatherers/seo/tap-targets.js
index 93e37fc9d..e7511a69c 100644
--- a/lighthouse-core/gather/gatherers/seo/tap-targets.js
+++ b/lighthouse-core/gather/gatherers/seo/tap-targets.js
@@ -166,15 +166,11 @@ function elementCenterIsAtZAxisTop(el, elCenterPoint) {
 /**
  * Finds all position sticky/absolute elements on the page and adds a class
  * that disables pointer events on them.
+ * @param {string} className
  * @return {() => void} - undo function to re-enable pointer events
  */
 /* c8 ignore start */
-function disableFixedAndStickyElementPointerEvents() {
-  const className = 'lighthouse-disable-pointer-events';
-  const styleTag = document.createElement('style');
-  styleTag.textContent = `.${className} { pointer-events: none !important }`;
-  document.body.appendChild(styleTag);
-
+function disableFixedAndStickyElementPointerEvents(className) {
   document.querySelectorAll('*').forEach(el => {
     const position = getComputedStyle(el).position;
     if (position === 'fixed' || position === 'sticky') {
@@ -186,17 +182,17 @@ function disableFixedAndStickyElementPointerEvents() {
     Array.from(document.getElementsByClassName(className)).forEach(el => {
       el.classList.remove(className);
     });
-    styleTag.remove();
   };
 }
 /* c8 ignore stop */
 
 /**
  * @param {string} tapTargetsSelector
+ * @param {string} className
  * @return {LH.Artifacts.TapTarget[]}
  */
 /* c8 ignore start */
-function gatherTapTargets(tapTargetsSelector) {
+function gatherTapTargets(tapTargetsSelector, className) {
   /** @type {LH.Artifacts.TapTarget[]} */
   const targets = [];
 
@@ -237,7 +233,8 @@ function gatherTapTargets(tapTargetsSelector) {
   // Disable pointer events so that tap targets below them don't get
   // detected as non-tappable (they are tappable, just not while the viewport
   // is at the current scroll position)
-  const reenableFixedAndStickyElementPointerEvents = disableFixedAndStickyElementPointerEvents();
+  const reenableFixedAndStickyElementPointerEvents =
+    disableFixedAndStickyElementPointerEvents(className);
 
   /** @type {{
     tapTargetElement: Element,
@@ -288,13 +285,49 @@ function gatherTapTargets(tapTargetsSelector) {
 /* c8 ignore stop */
 
 class TapTargets extends FRGatherer {
+  /**
+   * @param {LH.Gatherer.FRProtocolSession} session
+   * @param {string} className
+   * @return {Promise<string>}
+   */
+  async addStyleRule(session, className) {
+    const frameTreeResponse = await session.sendCommand('Page.getFrameTree');
+    const {styleSheetId} = await session.sendCommand('CSS.createStyleSheet', {
+      frameId: frameTreeResponse.frameTree.frame.id,
+    });
+    const ruleText = `.${className} { pointer-events: none !important }`;
+    await session.sendCommand('CSS.setStyleSheetText', {
+      styleSheetId,
+      text: ruleText,
+    });
+    return styleSheetId;
+  }
+
+  /**
+   * @param {LH.Gatherer.FRProtocolSession} session
+   * @param {string} styleSheetId
+   */
+  async removeStyleRule(session, styleSheetId) {
+    await session.sendCommand('CSS.setStyleSheetText', {
+      styleSheetId,
+      text: '',
+    });
+  }
+
   /**
    * @param {LH.Gatherer.FRTransitionalContext} passContext
    * @return {Promise<LH.Artifacts.TapTarget[]>} All visible tap targets with their positions and sizes
    */
-  getArtifact(passContext) {
-    return passContext.driver.executionContext.evaluate(gatherTapTargets, {
-      args: [tapTargetsSelector],
+  async getArtifact(passContext) {
+    const session = passContext.driver.defaultSession;
+    await session.sendCommand('DOM.enable');
+    await session.sendCommand('CSS.enable');
+
+    const className = 'lighthouse-disable-pointer-events';
+    const styleSheetId = await this.addStyleRule(session, className);
+
+    const tapTargets = await passContext.driver.executionContext.evaluate(gatherTapTargets, {
+      args: [tapTargetsSelector, className],
       useIsolation: true,
       deps: [
         pageFunctions.getNodeDetailsString,
@@ -312,6 +345,13 @@ class TapTargets extends FRGatherer {
         pageFunctions.getNodeLabel,
       ],
     });
+
+    await this.removeStyleRule(session, styleSheetId);
+
+    await session.sendCommand('CSS.disable');
+    await session.sendCommand('DOM.disable');
+
+    return tapTargets;
   }
 }
 


Test patch:
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/csp/csp-expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/csp/csp-expectations.js
index 53edd12f0..f86232efd 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/csp/csp-expectations.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/csp/csp-expectations.js
@@ -63,13 +63,6 @@ module.exports = [
             isReportOnly: false,
             contentSecurityPolicyViolationType: 'kURLViolation',
           },
-          {
-            // TODO: Fix style-src-elem violation when checking tap targets.
-            // https://github.com/GoogleChrome/lighthouse/issues/11862
-            violatedDirective: 'style-src-elem',
-            isReportOnly: false,
-            contentSecurityPolicyViolationType: 'kInlineViolation',
-          },
         ],
       },
       SourceMaps: [{
@@ -98,13 +91,6 @@ module.exports = [
       },
       InspectorIssues: {
         contentSecurityPolicy: [
-          {
-            // TODO: Fix style-src-elem violation when checking tap targets.
-            // https://github.com/GoogleChrome/lighthouse/issues/11862
-            violatedDirective: 'style-src-elem',
-            isReportOnly: false,
-            contentSecurityPolicyViolationType: 'kInlineViolation',
-          },
         ],
       },
       SourceMaps: [{
diff --git a/lighthouse-core/test/report/html/renderer/details-renderer-test.js b/lighthouse-core/test/report/html/renderer/details-renderer-test.js
index 62a4ec521..adbc450ef 100644
--- a/lighthouse-core/test/report/html/renderer/details-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/details-renderer-test.js
@@ -202,18 +202,30 @@ describe('DetailsRenderer', () => {
       assert.strictEqual(diagnosticEl, null);
     });
 
-    it('renders an unknown details type', () => {
-      // Disallowed by type system, but test that we get an error message out just in case.
-      const details = {
-        type: 'imaginary',
-        items: 5,
-      };
+    describe('unknown types', () => {
+      // console.error is set to a no-op, to avoid distracting log spam in the test output.
+      let consoleError;
+      beforeEach(() => {
+        consoleError = console.error;
+        console.error = () => {};
+      });
+      afterEach(() => {
+        console.error = consoleError;
+      });
 
-      const el = renderer.render(details);
-      const summaryEl = el.querySelector('summary');
-      expect(summaryEl.textContent)
-        .toContain('We don\'t know how to render audit details of type `imaginary`');
-      assert.strictEqual(el.lastChild.textContent, JSON.stringify(details, null, 2));
+      it('renders an unknown details type', () => {
+        // Disallowed by type system, but test that we get an error message out just in case.
+        const details = {
+          type: 'imaginary',
+          items: 5,
+        };
+
+        const el = renderer.render(details);
+        const summaryEl = el.querySelector('summary');
+        expect(summaryEl.textContent)
+          .toContain('We don\'t know how to render audit details of type `imaginary`');
+        assert.strictEqual(el.lastChild.textContent, JSON.stringify(details, null, 2));
+      });
     });
   });
 
@@ -659,41 +671,52 @@ describe('DetailsRenderer', () => {
       assert.strictEqual(codeItemEl.innerHTML, '<pre class="lh-code">invalid-url://example.com/</pre>');
     });
 
-    it('renders an unknown heading itemType', () => {
-      // Disallowed by type system, but test that we get an error message out just in case.
-      const details = {
-        type: 'table',
-        headings: [{key: 'content', itemType: 'notRealValueType', text: 'Heading'}],
-        items: [{content: 'some string'}],
-      };
+    describe('unknown types', () => {
+      let consoleError;
+      beforeEach(() => {
+        consoleError = console.error;
+        console.error = () => {};
+      });
+      afterEach(() => {
+        console.error = consoleError;
+      });
 
-      const el = renderer.render(details);
-      const unknownEl = el.querySelector('td.lh-table-column--notRealValueType .lh-unknown');
-      const summaryEl = unknownEl.querySelector('summary');
-      expect(summaryEl.textContent)
-        .toContain('We don\'t know how to render audit details of type `notRealValueType`');
-      assert.strictEqual(unknownEl.lastChild.textContent, '"some string"');
-    });
+      it('renders an unknown heading itemType', () => {
+        // Disallowed by type system, but test that we get an error message out just in case.
+        const details = {
+          type: 'table',
+          headings: [{key: 'content', itemType: 'notRealValueType', text: 'Heading'}],
+          items: [{content: 'some string'}],
+        };
 
-    it('renders an unknown item object type', () => {
-      // Disallowed by type system, but test that we get an error message out just in case.
-      const item = {
-        type: 'imaginaryItem',
-        items: 'alllll the items',
-      };
+        const el = renderer.render(details);
+        const unknownEl = el.querySelector('td.lh-table-column--notRealValueType .lh-unknown');
+        const summaryEl = unknownEl.querySelector('summary');
+        expect(summaryEl.textContent)
+          .toContain('We don\'t know how to render audit details of type `notRealValueType`');
+        assert.strictEqual(unknownEl.lastChild.textContent, '"some string"');
+      });
 
-      const details = {
-        type: 'table',
-        headings: [{key: 'content', itemType: 'url', text: 'Heading'}],
-        items: [{content: item}],
-      };
+      it('renders an unknown item object type', () => {
+        // Disallowed by type system, but test that we get an error message out just in case.
+        const item = {
+          type: 'imaginaryItem',
+          items: 'alllll the items',
+        };
 
-      const el = renderer.render(details);
-      const unknownEl = el.querySelector('td.lh-table-column--url .lh-unknown');
-      const summaryEl = unknownEl.querySelector('summary');
-      expect(summaryEl.textContent)
-        .toContain('We don\'t know how to render audit details of type `imaginaryItem`');
-      assert.strictEqual(unknownEl.lastChild.textContent, JSON.stringify(item, null, 2));
+        const details = {
+          type: 'table',
+          headings: [{key: 'content', itemType: 'url', text: 'Heading'}],
+          items: [{content: item}],
+        };
+
+        const el = renderer.render(details);
+        const unknownEl = el.querySelector('td.lh-table-column--url .lh-unknown');
+        const summaryEl = unknownEl.querySelector('summary');
+        expect(summaryEl.textContent)
+          .toContain('We don\'t know how to render audit details of type `imaginaryItem`');
+        assert.strictEqual(unknownEl.lastChild.textContent, JSON.stringify(item, null, 2));
+      });
     });
 
     it('uses the item\'s type over the heading type', () => {